mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e7d778740 | |||
| 3be87343b8 | |||
| 517efabbca | |||
| 1866dca818 | |||
| 1400b5d308 | |||
| b529cf4e57 | |||
| 6cc3d235b1 | |||
| a5b1156612 | |||
| c6ffd48e26 | |||
| afb6eae4c8 | |||
| cd1f39377d | |||
| 3a99e8576d | |||
| 36c8c5bc48 | |||
| 4ba7db90e6 | |||
| ab6cd7009e | |||
| b16ac683cf | |||
| 95d62b7a02 | |||
| e444e855c0 | |||
| 4ab42c06c9 | |||
| 9f6ab11839 | |||
| 5f711d5374 | |||
| 4d550112a1 | |||
| 26dc88181d | |||
| 3d4f8bfe12 | |||
| 5fb1838c7a | |||
| 38e5d76ad2 | |||
| a9af19f02a | |||
| 305b00ae01 | |||
| ed6724035e | |||
| eea83bb1c9 | |||
| 06c03cbded | |||
| b73f749880 | |||
| 53c18b861a | |||
| 9b1bf89382 | |||
| c7f144d3ff | |||
| cb41f08199 | |||
| eafb30555c | |||
| 4f1f48eec5 | |||
| 42341c2c76 | |||
| 6a60fc183a | |||
| 236b4c34ac | |||
| c6cb3dc64a | |||
| 3fc7487b58 | |||
| ac055d531f | |||
| 0ef3cbf520 | |||
| 3458238db0 | |||
| afaacec61a | |||
| 5f4fb5dae7 | |||
| 788b1f63d0 | |||
| bfb1b04f8d | |||
| a77ec6e225 | |||
| 2851eb7afe | |||
| 6c7605b4a6 | |||
| 3b286d04c3 | |||
| 21afe89b13 | |||
| 060a43ba64 | |||
| dedbf16f03 | |||
| 796f39fe1f | |||
| ee9cbce23d | |||
| 82fcdcd0fc | |||
| 3b7cdf516a | |||
| 41752887dc | |||
| b9d0d3397e | |||
| 2a2dfec247 | |||
| 21e07e20a1 |
@@ -140,7 +140,9 @@ jobs:
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# Derive version from git tags, no package.json dependency
|
# Derive version from git tags, no package.json dependency
|
||||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
# Only a plain vX.Y.Z is a release: the glob alone also matches the apps' TestFlight tags
|
||||||
|
# (v5.1.0-tf.15), whose suffix then broke the arithmetic below.
|
||||||
|
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||||
STABLE="${STABLE_TAG#v}"
|
STABLE="${STABLE_TAG#v}"
|
||||||
STABLE="${STABLE:-0.0.0}"
|
STABLE="${STABLE:-0.0.0}"
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,21 @@ jobs:
|
|||||||
working-directory: client
|
working-directory: client
|
||||||
run: npm run cap:sync -- ios
|
run: npm run cap:sync -- ios
|
||||||
|
|
||||||
|
# Capacitor's xcframeworks come from GitHub release downloads, which answer
|
||||||
|
# 504 now and then; SwiftPM then reports the half-written file as "already
|
||||||
|
# exists in file system". Resolve on a clean artifact cache, a few times
|
||||||
|
# with growing pauses, before the build needs the packages.
|
||||||
|
- name: Resolve the Swift packages
|
||||||
|
working-directory: client/ios/App
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||||
|
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
|
||||||
|
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
|
||||||
|
sleep $((attempt * 30))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
# No -sdk: the scheme builds the embedded watch app and its complications
|
# No -sdk: the scheme builds the embedded watch app and its complications
|
||||||
# against the watchOS simulator SDK and the rest against the iOS one.
|
# against the watchOS simulator SDK and the rest against the iOS one.
|
||||||
- name: Build for the simulator
|
- name: Build for the simulator
|
||||||
@@ -136,7 +151,10 @@ jobs:
|
|||||||
# The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and
|
# The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and
|
||||||
# dark, for a trip ahead, a trip under way and none, plus a home screen and
|
# dark, for a trip ahead, a trip under way and none, plus a home screen and
|
||||||
# a lock screen of them. Nobody has to install a build to see a widget change.
|
# a lock screen of them. Nobody has to install a build to see a widget change.
|
||||||
|
# Started by hand only (Run workflow): the apps are on TestFlight and Play now,
|
||||||
|
# where a change is looked at on a device; on every PR it only held the queue.
|
||||||
widget-previews:
|
widget-previews:
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
@@ -193,7 +211,9 @@ jobs:
|
|||||||
# The watch app on watchOS simulators in its screenshot mode (DemoMode.swift):
|
# The watch app on watchOS simulators in its screenshot mode (DemoMode.swift):
|
||||||
# every screen on the largest and the smallest watch, the main ones in every
|
# every screen on the largest and the smallest watch, the main ones in every
|
||||||
# language, and the complications, as PNGs to look at without a watch.
|
# language, and the complications, as PNGs to look at without a watch.
|
||||||
|
# Started by hand only, like the widget previews: up to 90 minutes of macOS.
|
||||||
watch-screens:
|
watch-screens:
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
steps:
|
steps:
|
||||||
@@ -220,6 +240,21 @@ jobs:
|
|||||||
xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS
|
xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS
|
||||||
xcrun simctl list runtimes
|
xcrun simctl list runtimes
|
||||||
|
|
||||||
|
# Capacitor's xcframeworks come from GitHub release downloads, which answer
|
||||||
|
# 504 now and then; SwiftPM then reports the half-written file as "already
|
||||||
|
# exists in file system". Resolve on a clean artifact cache, a few times
|
||||||
|
# with growing pauses, before the build needs the packages.
|
||||||
|
- name: Resolve the Swift packages
|
||||||
|
working-directory: client/ios/App
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||||
|
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
|
||||||
|
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
|
||||||
|
sleep $((attempt * 30))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Build the watch app for the simulator
|
- name: Build the watch app for the simulator
|
||||||
working-directory: client/ios/App
|
working-directory: client/ios/App
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1236,10 +1236,12 @@ jobs:
|
|||||||
bin="$RUNNER_TEMP/bin"
|
bin="$RUNNER_TEMP/bin"
|
||||||
mkdir -p "$bin"
|
mkdir -p "$bin"
|
||||||
cd "$RUNNER_TEMP"
|
cd "$RUNNER_TEMP"
|
||||||
curl -sSfLo helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
|
# GitHub's release downloads answer 504 now and then: retry, the checksum still decides.
|
||||||
|
fetch() { curl -sSfL --retry 5 --retry-delay 15 --retry-all-errors -o "$1" "$2"; }
|
||||||
|
fetch helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
|
||||||
echo "$HELM_SHA256 helm.tgz" | sha256sum -c -
|
echo "$HELM_SHA256 helm.tgz" | sha256sum -c -
|
||||||
tar -xzf helm.tgz --strip-components=1 -C "$bin" linux-amd64/helm
|
tar -xzf helm.tgz --strip-components=1 -C "$bin" linux-amd64/helm
|
||||||
curl -sSfLo kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
|
fetch kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
|
||||||
echo "$KUBECONFORM_SHA256 kubeconform.tgz" | sha256sum -c -
|
echo "$KUBECONFORM_SHA256 kubeconform.tgz" | sha256sum -c -
|
||||||
tar -xzf kubeconform.tgz -C "$bin" kubeconform
|
tar -xzf kubeconform.tgz -C "$bin" kubeconform
|
||||||
echo "$bin" >> "$GITHUB_PATH"
|
echo "$bin" >> "$GITHUB_PATH"
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ COPY server/package.json ./server/
|
|||||||
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
|
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
|
||||||
# deps are esbuild's binary check, redundant next to its optional platform
|
# deps are esbuild's binary check, redundant next to its optional platform
|
||||||
# package, and a telemetry ping.
|
# package, and a telemetry ping.
|
||||||
|
# The upgrade takes the Debian security fixes the base image was built before
|
||||||
|
# (zlib, 11/2026, failed the image scan until node:24-trixie-slim caught up).
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
|
apt-get upgrade -y --no-install-recommends && \
|
||||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
|
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
|
||||||
libkitinerary-bin libsqlite3-0 && \
|
libkitinerary-bin libsqlite3-0 && \
|
||||||
npm ci --workspace=server --omit=dev --ignore-scripts && \
|
npm ci --workspace=server --omit=dev --ignore-scripts && \
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 5.1.0
|
version: 5.1.1
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "5.1.0"
|
appVersion: "5.1.1"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class MainActivity extends BridgeActivity {
|
|||||||
// scroller natively. It scales the whole view, top bar and bottom dock included.
|
// scroller natively. It scales the whole view, top bar and bottom dock included.
|
||||||
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||||
// Set before any WebView callback can run: they are posted to this thread.
|
// Set before any WebView callback can run: they are posted to this thread.
|
||||||
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
|
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen, TrekServerStore.getSignInHosts(this)));
|
||||||
TrekSharedFiles.receive(this, getIntent());
|
TrekSharedFiles.receive(this, getIntent());
|
||||||
// Android 15's widget picker draws the widgets live from a sample trip.
|
// Android 15's widget picker draws the widgets live from a sample trip.
|
||||||
TrekWidgetUpdater.publishPreviews(this);
|
TrekWidgetUpdater.publishPreviews(this);
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import java.io.FileOutputStream;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
import org.json.JSONException;
|
import org.json.JSONException;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
@@ -18,6 +22,7 @@ final class TrekServerStore {
|
|||||||
|
|
||||||
private static final String PREFS = "trek_shell";
|
private static final String PREFS = "trek_shell";
|
||||||
private static final String KEY_URL = "server_url";
|
private static final String KEY_URL = "server_url";
|
||||||
|
private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts";
|
||||||
private static final String KEY_SHELL_ONCE = "shell_once";
|
private static final String KEY_SHELL_ONCE = "shell_once";
|
||||||
private static final String KEY_RESTART = "restart";
|
private static final String KEY_RESTART = "restart";
|
||||||
private static final String CONFIG_DIR = "trek-server-config";
|
private static final String CONFIG_DIR = "trek-server-config";
|
||||||
@@ -28,16 +33,39 @@ final class TrekServerStore {
|
|||||||
return prefs(context).getString(KEY_URL, null);
|
return prefs(context).getString(KEY_URL, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
|
||||||
static void setUrl(Context context, String url) {
|
static void setUrl(Context context, String url) {
|
||||||
|
setServer(context, url, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void setServer(Context context, String url, Set<String> signInHosts) {
|
||||||
SharedPreferences.Editor editor = prefs(context).edit();
|
SharedPreferences.Editor editor = prefs(context).edit();
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
editor.remove(KEY_URL);
|
editor.remove(KEY_URL);
|
||||||
} else {
|
} else {
|
||||||
editor.putString(KEY_URL, url);
|
editor.putString(KEY_URL, url);
|
||||||
}
|
}
|
||||||
|
if (signInHosts == null || signInHosts.isEmpty()) {
|
||||||
|
editor.remove(KEY_SIGN_IN_HOSTS);
|
||||||
|
} else {
|
||||||
|
Set<String> lower = new HashSet<>();
|
||||||
|
for (String host : signInHosts) lower.add(host.toLowerCase(Locale.ROOT));
|
||||||
|
editor.putStringSet(KEY_SIGN_IN_HOSTS, lower);
|
||||||
|
}
|
||||||
editor.commit();
|
editor.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hosts of a sign-in page that stands in front of the server (a proxy
|
||||||
|
* such as Cloudflare Access or Pangolin), as the address screen found them.
|
||||||
|
* Only these may load inside the web view besides the server itself, so
|
||||||
|
* the proxy's session lands where the app can use it (TrekWebViewClient).
|
||||||
|
*/
|
||||||
|
static Set<String> getSignInHosts(Context context) {
|
||||||
|
Set<String> hosts = prefs(context).getStringSet(KEY_SIGN_IN_HOSTS, null);
|
||||||
|
return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts));
|
||||||
|
}
|
||||||
|
|
||||||
/** Start the next launch on the bundled address screen, even with a server remembered. */
|
/** Start the next launch on the bundled address screen, even with a server remembered. */
|
||||||
static void showShellOnce(Context context) {
|
static void showShellOnce(Context context) {
|
||||||
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
|
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ final class TrekSharedFiles {
|
|||||||
file.put("mimeType", meta.optString("mimeType", "application/octet-stream"));
|
file.put("mimeType", meta.optString("mimeType", "application/octet-stream"));
|
||||||
file.put("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP));
|
file.put("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP));
|
||||||
if (meta.has("tripId")) file.put("tripId", meta.optLong("tripId"));
|
if (meta.has("tripId")) file.put("tripId", meta.optLong("tripId"));
|
||||||
|
// A page or a link: where it came from, so the UI can keep it among the trip's links.
|
||||||
|
String url = meta.optString("url", "");
|
||||||
|
if (url.startsWith("https://") || url.startsWith("http://")) {
|
||||||
|
file.put("url", url);
|
||||||
|
file.put("title", meta.optString("title", url));
|
||||||
|
}
|
||||||
pending = file;
|
pending = file;
|
||||||
if (listener != null) listener.onFile(file);
|
if (listener != null) listener.onFile(file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import android.net.Uri;
|
|||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
import androidx.browser.customtabs.CustomTabsIntent;
|
import androidx.browser.customtabs.CustomTabsIntent;
|
||||||
import androidx.core.app.NotificationManagerCompat;
|
import androidx.core.app.NotificationManagerCompat;
|
||||||
|
import com.getcapacitor.JSArray;
|
||||||
import com.getcapacitor.JSObject;
|
import com.getcapacitor.JSObject;
|
||||||
import com.getcapacitor.PermissionState;
|
import com.getcapacitor.PermissionState;
|
||||||
import com.getcapacitor.Plugin;
|
import com.getcapacitor.Plugin;
|
||||||
@@ -17,6 +19,8 @@ import com.getcapacitor.annotation.CapacitorPlugin;
|
|||||||
import com.getcapacitor.annotation.Permission;
|
import com.getcapacitor.annotation.Permission;
|
||||||
import com.getcapacitor.annotation.PermissionCallback;
|
import com.getcapacitor.annotation.PermissionCallback;
|
||||||
import com.google.firebase.messaging.FirebaseMessaging;
|
import com.google.firebase.messaging.FirebaseMessaging;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
/** The native half of client/src/native/trekShell.ts. */
|
/** The native half of client/src/native/trekShell.ts. */
|
||||||
@@ -29,6 +33,8 @@ import org.json.JSONObject;
|
|||||||
)
|
)
|
||||||
public class TrekShellPlugin extends Plugin {
|
public class TrekShellPlugin extends Plugin {
|
||||||
|
|
||||||
|
private static final String TAG = "TrekShell";
|
||||||
|
|
||||||
/** How long to wait after returning from the browser for the redirect to land. */
|
/** How long to wait after returning from the browser for the redirect to land. */
|
||||||
private static final long CANCEL_GRACE_MS = 600;
|
private static final long CANCEL_GRACE_MS = 600;
|
||||||
|
|
||||||
@@ -272,11 +278,23 @@ public class TrekShellPlugin extends Plugin {
|
|||||||
call.reject("A http(s) URL is required");
|
call.reject("A http(s) URL is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
TrekServerStore.setUrl(getContext(), url);
|
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
|
||||||
call.resolve();
|
call.resolve();
|
||||||
restart();
|
restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The sign-in hosts the address screen passes along, plain host names only. */
|
||||||
|
private static Set<String> signInHostsOf(PluginCall call) {
|
||||||
|
Set<String> hosts = new HashSet<>();
|
||||||
|
JSArray list = call.getArray("signInHosts");
|
||||||
|
if (list == null) return hosts;
|
||||||
|
for (int i = 0; i < list.length(); i++) {
|
||||||
|
String host = list.optString(i, "");
|
||||||
|
if (host.matches("[A-Za-z0-9.-]+(:[0-9]+)?")) hosts.add(host);
|
||||||
|
}
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
public void resetServer(PluginCall call) {
|
public void resetServer(PluginCall call) {
|
||||||
TrekServerStore.setUrl(getContext(), null);
|
TrekServerStore.setUrl(getContext(), null);
|
||||||
@@ -315,7 +333,32 @@ public class TrekShellPlugin extends Plugin {
|
|||||||
// A Custom Tab shares the browser's cookies, so a running SSO session at
|
// A Custom Tab shares the browser's cookies, so a running SSO session at
|
||||||
// the identity provider carries over. The redirect back to the app's
|
// the identity provider carries over. The redirect back to the app's
|
||||||
// scheme arrives through the intent-filter in AndroidManifest.xml.
|
// scheme arrives through the intent-filter in AndroidManifest.xml.
|
||||||
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url));
|
Uri target = Uri.parse(url);
|
||||||
|
getActivity().runOnUiThread(() -> openSignIn(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the sign-in in a Custom Tab, or in the browser itself where no
|
||||||
|
* Custom Tab can come up. Launched on the UI thread: some devices dropped a
|
||||||
|
* launch from the bridge's thread without a word, and the sign-in then
|
||||||
|
* looked cancelled. A browser that cannot open at all is reported as such.
|
||||||
|
*/
|
||||||
|
private void openSignIn(Uri target) {
|
||||||
|
try {
|
||||||
|
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), target);
|
||||||
|
return;
|
||||||
|
} catch (RuntimeException tabFailed) {
|
||||||
|
Log.w(TAG, "No Custom Tab for the sign-in, trying the browser", tabFailed);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
getActivity().startActivity(new Intent(Intent.ACTION_VIEW, target).addCategory(Intent.CATEGORY_BROWSABLE));
|
||||||
|
} catch (RuntimeException browserFailed) {
|
||||||
|
Log.w(TAG, "No browser for the sign-in", browserFailed);
|
||||||
|
PluginCall call = pendingAuthCall();
|
||||||
|
if (call == null) return;
|
||||||
|
call.reject("No browser could open the sign-in", "NO_BROWSER");
|
||||||
|
finishAuth(call);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.liketrek.trek;
|
package com.liketrek.trek;
|
||||||
|
|
||||||
|
import android.net.Uri;
|
||||||
import android.webkit.WebResourceError;
|
import android.webkit.WebResourceError;
|
||||||
import android.webkit.WebResourceRequest;
|
import android.webkit.WebResourceRequest;
|
||||||
import android.webkit.WebResourceResponse;
|
import android.webkit.WebResourceResponse;
|
||||||
import android.webkit.WebView;
|
import android.webkit.WebView;
|
||||||
import com.getcapacitor.Bridge;
|
import com.getcapacitor.Bridge;
|
||||||
import com.getcapacitor.BridgeWebViewClient;
|
import com.getcapacitor.BridgeWebViewClient;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used while the WebView shows the user's server. When that server cannot be
|
* Used while the WebView shows the user's server. When that server cannot be
|
||||||
@@ -18,10 +21,35 @@ import com.getcapacitor.BridgeWebViewClient;
|
|||||||
class TrekWebViewClient extends BridgeWebViewClient {
|
class TrekWebViewClient extends BridgeWebViewClient {
|
||||||
|
|
||||||
private final Runnable onUnreachable;
|
private final Runnable onUnreachable;
|
||||||
|
private final Set<String> signInHosts;
|
||||||
|
|
||||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) {
|
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
|
||||||
super(bridge);
|
super(bridge);
|
||||||
this.onUnreachable = onUnreachable;
|
this.onUnreachable = onUnreachable;
|
||||||
|
this.signInHosts = signInHosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sign-in page of a proxy in front of the server loads here rather than
|
||||||
|
* in the browser, so its session cookie is the web view's and every request
|
||||||
|
* of the app carries it, as in a browser. Only the hosts the address screen
|
||||||
|
* saw that proxy send it to (TrekServerStore.getSignInHosts), and only as a
|
||||||
|
* page: Capacitor injects its bridge into the server's origin alone, so the
|
||||||
|
* proxy's page gets no access to the app. Everything else goes on to
|
||||||
|
* Capacitor, which opens a foreign page in the browser.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||||
|
if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false;
|
||||||
|
return super.shouldOverrideUrlLoading(view, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSignInHost(Uri url) {
|
||||||
|
String scheme = url.getScheme();
|
||||||
|
String host = url.getHost();
|
||||||
|
if (host == null || !("https".equals(scheme) || "http".equals(scheme))) return false;
|
||||||
|
String key = host.toLowerCase(Locale.ROOT);
|
||||||
|
return signInHosts.contains(key) || (url.getPort() != -1 && signInHosts.contains(key + ":" + url.getPort()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */
|
/** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ object ShareHandoff {
|
|||||||
fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching {
|
fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching {
|
||||||
val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType)
|
val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType)
|
||||||
if (tripId != null) meta.put("tripId", tripId)
|
if (tripId != null) meta.put("tripId", tripId)
|
||||||
|
if (item.url != null) meta.put("url", item.url).put("title", item.title)
|
||||||
File(dir(context), "pending.data").writeBytes(item.data)
|
File(dir(context), "pending.data").writeBytes(item.data)
|
||||||
File(dir(context), "pending.json").writeText(meta.toString())
|
File(dir(context), "pending.json").writeText(meta.toString())
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ data class ShareItem(
|
|||||||
val title: String,
|
val title: String,
|
||||||
/** A host, for a page. */
|
/** A host, for a page. */
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
/**
|
||||||
|
* The web address it came from, when it is a page or a link: the app offers to
|
||||||
|
* keep it among the trip's links as well as to read a booking out of it.
|
||||||
|
*/
|
||||||
|
val url: String? = null,
|
||||||
) {
|
) {
|
||||||
enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE }
|
enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE }
|
||||||
|
|
||||||
@@ -90,11 +95,16 @@ object ShareLoader {
|
|||||||
return checked(ShareItem(kind, withExtension(name, kind), normalized, data, name.substringBeforeLast('.').ifBlank { name }))
|
return checked(ShareItem(kind, withExtension(name, kind), normalized, data, name.substringBeforeLast('.').ifBlank { name }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A link becomes the page it points at; anything else, a text file the import reads. */
|
/**
|
||||||
|
* A link becomes the page it points at, or the bare link when the page cannot be
|
||||||
|
* read (a sign-in wall, an app-only page, no network), which the app can still
|
||||||
|
* keep among the trip's links. Anything else, a text file the import reads.
|
||||||
|
*/
|
||||||
internal fun fromText(text: String, subject: String?): ShareItem {
|
internal fun fromText(text: String, subject: String?): ShareItem {
|
||||||
val link = firstLink(text)
|
val link = firstLink(text)
|
||||||
if (link != null) {
|
if (link != null) {
|
||||||
runCatching { return page(link, subject) }
|
runCatching { return page(link, subject) }
|
||||||
|
return linkOnly(link, subject)
|
||||||
}
|
}
|
||||||
val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: text
|
val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: text
|
||||||
return checked(ShareItem(ShareItem.Kind.TEXT, "shared-text.txt", "text/plain", text.toByteArray(), (subject ?: firstLine).take(80)))
|
return checked(ShareItem(ShareItem.Kind.TEXT, "shared-text.txt", "text/plain", text.toByteArray(), (subject ?: firstLine).take(80)))
|
||||||
@@ -121,12 +131,19 @@ object ShareLoader {
|
|||||||
val html = String(data, Charsets.UTF_8)
|
val html = String(data, Charsets.UTF_8)
|
||||||
val host = runCatching { Uri.parse(link).host }.getOrNull()
|
val host = runCatching { Uri.parse(link).host }.getOrNull()
|
||||||
val title = pageTitle(html) ?: subject ?: host ?: "Page"
|
val title = pageTitle(html) ?: subject ?: host ?: "Page"
|
||||||
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host))
|
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host, link))
|
||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The link alone, as a text/uri-list the app recognises as nothing to read a booking from. */
|
||||||
|
internal fun linkOnly(link: String, subject: String?): ShareItem {
|
||||||
|
// java.net.URI rather than android.net.Uri: the same host, and readable in a JVM unit test.
|
||||||
|
val host = runCatching { java.net.URI(link).host }.getOrNull()
|
||||||
|
return ShareItem(ShareItem.Kind.PAGE, "link.url", "text/uri-list", link.toByteArray(), subject ?: host ?: link, host, link)
|
||||||
|
}
|
||||||
|
|
||||||
/** The kind and the MIME type the import should see, from the name when the type is vague. */
|
/** The kind and the MIME type the import should see, from the name when the type is vague. */
|
||||||
internal fun classify(name: String, mime: String): Pair<ShareItem.Kind, String> {
|
internal fun classify(name: String, mime: String): Pair<ShareItem.Kind, String> {
|
||||||
val ext = name.substringAfterLast('.', "").lowercase()
|
val ext = name.substringAfterLast('.', "").lowercase()
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class NativeLogicTest {
|
|||||||
assertEquals("page", ShareLoader.slug("!!!"))
|
assertEquals("page", ShareLoader.slug("!!!"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aLinkWhosePageCannotBeReadIsHandedOverAsTheLinkItself() {
|
||||||
|
val item = ShareLoader.linkOnly("https://www.instagram.com/p/abc", null)
|
||||||
|
assertEquals("text/uri-list", item.mimeType)
|
||||||
|
assertEquals("https://www.instagram.com/p/abc", item.url)
|
||||||
|
assertEquals("https://www.instagram.com/p/abc", String(item.data))
|
||||||
|
assertEquals("www.instagram.com", item.title)
|
||||||
|
assertEquals("Ramen place", ShareLoader.linkOnly("https://maps.app.goo.gl/x", "Ramen place").title)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() {
|
fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() {
|
||||||
val today = LocalDate.of(2026, 11, 7)
|
val today = LocalDate.of(2026, 11, 7)
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||||||
TrekPush.shared.didFail(error)
|
TrekPush.shared.didFail(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A silent push from the TREK server: a list of a trip changed, so the Apple
|
||||||
|
/// Watch is brought up to date (TrekWatchRefresher). Nothing else arrives this way.
|
||||||
|
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||||
|
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||||
|
guard userInfo["trek"] as? String == "watch-sync" else { return completionHandler(.noData) }
|
||||||
|
TrekWatchRefresher.refresh(completion: completionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
completionHandler([.banner, .list, .sound])
|
completionHandler([.banner, .list, .sound])
|
||||||
|
|||||||
@@ -120,6 +120,10 @@
|
|||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ enum TrekSharedFiles {
|
|||||||
"data": data.base64EncodedString(),
|
"data": data.base64EncodedString(),
|
||||||
]
|
]
|
||||||
if let tripId = info["tripId"] as? Int { file["tripId"] = tripId }
|
if let tripId = info["tripId"] as? Int { file["tripId"] = tripId }
|
||||||
|
// A page or a link: where it came from, so the UI can keep it among the trip's links.
|
||||||
|
if let url = info["url"] as? String, url.hasPrefix("https://") || url.hasPrefix("http://") {
|
||||||
|
file["url"] = url
|
||||||
|
if let title = info["title"] as? String { file["title"] = title }
|
||||||
|
}
|
||||||
pending = file
|
pending = file
|
||||||
onReceive?(file)
|
onReceive?(file)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
|||||||
navigationProxy = proxy
|
navigationProxy = proxy
|
||||||
webView.navigationDelegate = proxy
|
webView.navigationDelegate = proxy
|
||||||
}
|
}
|
||||||
|
guardBridgeMessages()
|
||||||
bridge?.registerPluginInstance(TrekShellPlugin())
|
bridge?.registerPluginInstance(TrekShellPlugin())
|
||||||
// WebKit's own swipe back slides away a screenshot of the whole page,
|
// WebKit's own swipe back slides away a screenshot of the whole page,
|
||||||
// closes no sheet and takes every drag near the edge from the UI. The UI
|
// closes no sheet and takes every drag near the edge from the UI. The UI
|
||||||
@@ -51,6 +52,22 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
|||||||
webView?.allowsBackForwardNavigationGestures = false
|
webView?.allowsBackForwardNavigationGestures = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kept alive here: the content controller holds it, but only for as long as it is registered.
|
||||||
|
private var messageGuard: TrekBridgeMessageGuard?
|
||||||
|
|
||||||
|
/// Capacitor answers bridge messages from any page in the web view. With a
|
||||||
|
/// proxy's sign-in page loading there, its messages are dropped before they
|
||||||
|
/// reach the plugins.
|
||||||
|
private func guardBridgeMessages() {
|
||||||
|
guard !TrekServerStore.signInHosts.isEmpty,
|
||||||
|
let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
|
||||||
|
let controller = webView?.configuration.userContentController else { return }
|
||||||
|
let guarded = TrekBridgeMessageGuard(inner: handler)
|
||||||
|
controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name)
|
||||||
|
controller.add(guarded, name: TrekBridgeMessageGuard.name)
|
||||||
|
messageGuard = guarded
|
||||||
|
}
|
||||||
|
|
||||||
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
|
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
|
||||||
/// in the main menu, which sends their action up the responder chain from
|
/// in the main menu, which sends their action up the responder chain from
|
||||||
/// the web view, and this controller is the first stop that answers it.
|
/// the web view, and this controller is the first stop that answers it.
|
||||||
@@ -137,14 +154,43 @@ enum TrekKeyCommands {
|
|||||||
enum TrekServerStore {
|
enum TrekServerStore {
|
||||||
private static let key = "TrekServerURL"
|
private static let key = "TrekServerURL"
|
||||||
|
|
||||||
|
private static let signInHostsKey = "TrekServerSignInHosts"
|
||||||
|
|
||||||
|
/// Setting the server drops the sign-in hosts of the one before it.
|
||||||
static var url: String? {
|
static var url: String? {
|
||||||
get { UserDefaults.standard.string(forKey: key) }
|
get { UserDefaults.standard.string(forKey: key) }
|
||||||
set {
|
set {
|
||||||
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) }
|
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) }
|
||||||
|
UserDefaults.standard.removeObject(forKey: signInHostsKey)
|
||||||
try? FileManager.default.removeItem(at: offlineCopyURL)
|
try? FileManager.default.removeItem(at: offlineCopyURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The hosts of a sign-in page in front of the server (a proxy such as
|
||||||
|
/// Cloudflare Access or Pangolin), as the address screen found them. Only
|
||||||
|
/// these load in the web view besides the server, so the proxy's session
|
||||||
|
/// cookie is the web view's (TrekNavigationProxy), and none of them may
|
||||||
|
/// talk to the app (TrekBridgeMessageGuard).
|
||||||
|
static var signInHosts: Set<String> {
|
||||||
|
get { Set((UserDefaults.standard.stringArray(forKey: signInHostsKey) ?? []).map { $0.lowercased() }) }
|
||||||
|
set {
|
||||||
|
if newValue.isEmpty {
|
||||||
|
UserDefaults.standard.removeObject(forKey: signInHostsKey)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.set(Array(newValue).sorted(), forKey: signInHostsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `url` is one of the sign-in hosts, matched on host or host:port.
|
||||||
|
static func isSignInHost(_ url: URL?) -> Bool {
|
||||||
|
guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false }
|
||||||
|
let hosts = signInHosts
|
||||||
|
if hosts.contains(host) { return true }
|
||||||
|
if let port = url.port { return hosts.contains("\(host):\(port)") }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
static var offlineCopyURL: URL {
|
static var offlineCopyURL: URL {
|
||||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
return support.appendingPathComponent("offline-start-page.html")
|
return support.appendingPathComponent("offline-start-page.html")
|
||||||
@@ -391,6 +437,23 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
|||||||
(inner as AnyObject).responds(to: aSelector) ? inner : nil
|
(inner as AnyObject).responds(to: aSelector) ? inner : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sign-in page of a proxy in front of the server loads here instead of
|
||||||
|
/// in Safari, so its session is the web view's and every request carries it.
|
||||||
|
/// Only the hosts the address screen saw (TrekServerStore.signInHosts), and
|
||||||
|
/// only in the main frame; everything else is Capacitor's decision as before.
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
if navigationAction.targetFrame?.isMainFrame == true, TrekServerStore.isSignInHost(navigationAction.request.url) {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:")
|
||||||
|
guard (inner as AnyObject).responds(to: selector) else {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inner.webView?(webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
if Self.isReplaced(error) { return }
|
if Self.isReplaced(error) { return }
|
||||||
inner.webView?(webView, didFail: navigation, withError: error)
|
inner.webView?(webView, didFail: navigation, withError: error)
|
||||||
@@ -401,3 +464,26 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
|||||||
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stands in front of Capacitor's bridge message handler and drops what a
|
||||||
|
/// proxy's sign-in page posts: that page loads in the web view (see
|
||||||
|
/// TrekNavigationProxy), and Capacitor itself would hand its messages to the
|
||||||
|
/// plugins whatever page they come from.
|
||||||
|
final class TrekBridgeMessageGuard: NSObject, WKScriptMessageHandler {
|
||||||
|
static let name = "bridge"
|
||||||
|
private let inner: WKScriptMessageHandler
|
||||||
|
|
||||||
|
init(inner: WKScriptMessageHandler) {
|
||||||
|
self.inner = inner
|
||||||
|
}
|
||||||
|
|
||||||
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
let origin = message.frameInfo.securityOrigin
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = origin.protocol
|
||||||
|
components.host = origin.host
|
||||||
|
if origin.port != 0 { components.port = origin.port }
|
||||||
|
if TrekServerStore.isSignInHost(components.url) { return }
|
||||||
|
inner.userContentController(userContentController, didReceive: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -324,6 +324,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
TrekServerStore.url = raw
|
TrekServerStore.url = raw
|
||||||
|
// After the URL: setting it clears the hosts of the server before.
|
||||||
|
let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil }
|
||||||
|
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
|
||||||
call.resolve()
|
call.resolve()
|
||||||
restartBridge()
|
restartBridge()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import CryptoKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
import WatchConnectivity
|
import WatchConnectivity
|
||||||
|
import WebKit
|
||||||
|
|
||||||
/// The phone's end of WatchConnectivity (feature "watch"). The UI hands over the
|
/// The phone's end of WatchConnectivity (feature "watch"). The UI hands over the
|
||||||
/// watch's snapshot (client/src/native/watch.ts through TrekShell.setWatchSnapshot);
|
/// watch's snapshot (client/src/native/watch.ts through TrekShell.setWatchSnapshot);
|
||||||
@@ -93,6 +94,11 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The snapshot the watch has now, as last published.
|
||||||
|
func storedSnapshot() -> Data? {
|
||||||
|
queue.sync { UserDefaults.standard.data(forKey: snapshotKey) }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Ticks from the watch
|
// MARK: Ticks from the watch
|
||||||
|
|
||||||
/// The ticks queued so far, oldest first, and the queue emptied.
|
/// The ticks queued so far, oldest first, and the queue emptied.
|
||||||
@@ -110,6 +116,25 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// While the app is in front, its UI writes the tick, through its offline queue
|
||||||
|
/// as a tap would. In the background the WebView is asleep, so the tick is
|
||||||
|
/// written to the server from here at once (TrekWatchWriter), and the trip's
|
||||||
|
/// other devices hear of it over their WebSocket; only a write that fails is
|
||||||
|
/// queued for the UI, which then tries again once it runs.
|
||||||
|
private func receive(_ action: [String: Any], then done: @escaping () -> Void) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard UIApplication.shared.applicationState != .active else {
|
||||||
|
self.enqueue(action)
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TrekWatchWriter.write(action) { written in
|
||||||
|
if !written { self.enqueue(action) }
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func enqueue(_ action: [String: Any]) {
|
private func enqueue(_ action: [String: Any]) {
|
||||||
queue.async {
|
queue.async {
|
||||||
// A tick sent twice (the watch resends after a restart) replaces the earlier copy.
|
// A tick sent twice (the watch resends after a restart) replaces the earlier copy.
|
||||||
@@ -145,11 +170,16 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||||
if let action = userInfo["action"] as? [String: Any] { enqueue(action) }
|
if let action = userInfo["action"] as? [String: Any] { receive(action) {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The watch asks for the latest snapshot when it opens while the phone is in reach.
|
/// The watch asks for the latest snapshot when it opens while the phone is in
|
||||||
|
/// reach, and sends its ticks this way while it is (WatchLink.send).
|
||||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
||||||
|
if let action = message["action"] as? [String: Any] {
|
||||||
|
receive(action) { replyHandler(["queued": true]) }
|
||||||
|
return
|
||||||
|
}
|
||||||
queue.async {
|
queue.async {
|
||||||
if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) {
|
if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) {
|
||||||
replyHandler(["snapshot": data])
|
replyHandler(["snapshot": data])
|
||||||
@@ -159,3 +189,211 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes a tick from the watch straight to the TREK server, for when the app is
|
||||||
|
/// in the background and its UI cannot. The same request the UI makes for a tap
|
||||||
|
/// (PUT /api/trips/:trip/packing|todo/:item with the box's new state), with the
|
||||||
|
/// session the WebView holds (its cookies) and the tick's id as the idempotency
|
||||||
|
/// key, so a tick that also reaches the server another way is taken once.
|
||||||
|
enum TrekWatchWriter {
|
||||||
|
private final class BackgroundTask {
|
||||||
|
var id = UIBackgroundTaskIdentifier.invalid
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
guard id != .invalid else { return }
|
||||||
|
UIApplication.shared.endBackgroundTask(id)
|
||||||
|
id = .invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called on the main queue, as is `completion`.
|
||||||
|
static func write(_ action: [String: Any], completion: @escaping (Bool) -> Void) {
|
||||||
|
guard let request = Self.request(for: action) else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let task = BackgroundTask()
|
||||||
|
task.id = UIApplication.shared.beginBackgroundTask(withName: "trek.watch.tick") { task.end() }
|
||||||
|
let finish: (Bool) -> Void = { written in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
completion(written)
|
||||||
|
task.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
perform(request) { _, ok in finish(ok) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a request with the WebView's session (its cookies for the server).
|
||||||
|
/// Called on the main queue; `completion` runs on a background queue.
|
||||||
|
static func perform(_ request: URLRequest, completion: @escaping (Data?, Bool) -> Void) {
|
||||||
|
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||||
|
guard let host = request.url?.host else { return completion(nil, false) }
|
||||||
|
let session = cookies.filter { cookie in
|
||||||
|
let domain = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain
|
||||||
|
return host == domain || host.hasSuffix("." + domain)
|
||||||
|
}
|
||||||
|
guard !session.isEmpty else { return completion(nil, false) }
|
||||||
|
var signed = request
|
||||||
|
for (field, value) in HTTPCookie.requestHeaderFields(with: session) {
|
||||||
|
signed.setValue(value, forHTTPHeaderField: field)
|
||||||
|
}
|
||||||
|
URLSession.shared.dataTask(with: signed) { data, response, error in
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
completion(data, error == nil && (200..<300).contains(status))
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's address for an API path, or nil without a server.
|
||||||
|
static func url(_ path: [String]) -> URL? {
|
||||||
|
guard let server = TrekServerStore.url, let base = URL(string: server),
|
||||||
|
base.scheme == "https" || base.scheme == "http" else { return nil }
|
||||||
|
return path.reduce(base.appendingPathComponent("api")) { $0.appendingPathComponent($1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The write for a tick as the watch sends it, or nil for anything else.
|
||||||
|
static func request(for action: [String: Any]) -> URLRequest? {
|
||||||
|
guard let server = TrekServerStore.url, let base = URL(string: server),
|
||||||
|
base.scheme == "https" || base.scheme == "http",
|
||||||
|
let id = action["id"] as? String, !id.isEmpty, id.count <= 64,
|
||||||
|
let tripId = action["tripId"] as? Int, let itemId = action["itemId"] as? Int,
|
||||||
|
let list = action["list"] as? String, list == "packing" || list == "todo",
|
||||||
|
let done = action["done"] as? Bool,
|
||||||
|
let body = try? JSONSerialization.data(withJSONObject: ["checked": done]) else { return nil }
|
||||||
|
let url = base.appendingPathComponent("api").appendingPathComponent("trips")
|
||||||
|
.appendingPathComponent(String(tripId)).appendingPathComponent(list).appendingPathComponent(String(itemId))
|
||||||
|
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||||
|
request.httpMethod = "PUT"
|
||||||
|
request.httpShouldHandleCookies = false
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("watch-\(id)", forHTTPHeaderField: "X-Idempotency-Key")
|
||||||
|
request.httpBody = body
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brings the watch's packing list and to-dos up to date while the app is in the
|
||||||
|
/// background, woken by the server's silent push (WatchSyncPushService in TREK):
|
||||||
|
/// reads both lists of the trip the watch shows from the server and puts them
|
||||||
|
/// into the snapshot the watch has, built the way the UI builds them
|
||||||
|
/// (watchPacking and watchTodos in client/src/native/watch.ts). Everything else
|
||||||
|
/// in the snapshot stays as the UI last made it. In front, the UI does this
|
||||||
|
/// itself as soon as the lists change, so nothing is done here then.
|
||||||
|
enum TrekWatchRefresher {
|
||||||
|
private static let maxName = 80
|
||||||
|
private static let maxGroup = 40
|
||||||
|
/// Below what WatchConnectivity carries in one application context.
|
||||||
|
private static let maxBytes = 55_000
|
||||||
|
|
||||||
|
static func refresh(completion: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||||
|
guard TrekWatchLink.isAvailable, UIApplication.shared.applicationState != .active,
|
||||||
|
let stored = TrekWatchLink.shared.storedSnapshot(),
|
||||||
|
var snapshot = (try? JSONSerialization.jsonObject(with: stored)) as? [String: Any],
|
||||||
|
let trip = snapshot["trip"] as? [String: Any], let tripId = trip["id"] as? Int,
|
||||||
|
let packingURL = TrekWatchWriter.url(["trips", String(tripId), "packing"]),
|
||||||
|
let todoURL = TrekWatchWriter.url(["trips", String(tripId), "todo"]) else {
|
||||||
|
return completion(.noData)
|
||||||
|
}
|
||||||
|
let group = DispatchGroup()
|
||||||
|
var packing: [[String: Any]]?
|
||||||
|
var todos: [[String: Any]]?
|
||||||
|
group.enter()
|
||||||
|
items(at: packingURL) { list in
|
||||||
|
packing = list
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
group.enter()
|
||||||
|
items(at: todoURL) { list in
|
||||||
|
todos = list
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
group.notify(queue: .main) {
|
||||||
|
guard let packing, let todos else { return completion(.failed) }
|
||||||
|
snapshot["packing"] = packingList(packing)
|
||||||
|
snapshot["todos"] = todoList(todos)
|
||||||
|
snapshot["generatedAt"] = ISO8601DateFormatter().string(from: Date())
|
||||||
|
guard let data = fitted(snapshot) else { return completion(.failed) }
|
||||||
|
TrekWatchLink.shared.publish(data)
|
||||||
|
completion(.newData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `items` of a list endpoint, on the main queue; nil when it could not be read.
|
||||||
|
private static func items(at url: URL, then completion: @escaping ([[String: Any]]?) -> Void) {
|
||||||
|
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||||
|
request.httpShouldHandleCookies = false
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
TrekWatchWriter.perform(request) { data, ok in
|
||||||
|
var list: [[String: Any]]?
|
||||||
|
if ok, let data, let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
|
||||||
|
list = body["items"] as? [[String: Any]]
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { completion(list) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func done(_ item: [String: Any]) -> Bool {
|
||||||
|
(item["checked"] as? Bool) ?? ((item["checked"] as? Int).map { $0 != 0 } ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func trimmed(_ value: Any?, _ max: Int) -> String? {
|
||||||
|
guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil }
|
||||||
|
return String(text.prefix(max))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func entry(_ item: [String: Any]) -> [String: Any]? {
|
||||||
|
guard let id = item["id"] as? Int, let name = item["name"] as? String else { return nil }
|
||||||
|
var out: [String: Any] = ["id": id, "name": String(name.prefix(maxName)), "done": done(item)]
|
||||||
|
if let group = trimmed(item["category"], maxGroup) { out["group"] = group }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpacked first, then packed, each in the list's own order.
|
||||||
|
static func packingList(_ items: [[String: Any]]) -> Any {
|
||||||
|
guard !items.isEmpty else { return NSNull() }
|
||||||
|
let sorted = items.sorted {
|
||||||
|
let (a, b) = (done($0) ? 1 : 0, done($1) ? 1 : 0)
|
||||||
|
return a != b ? a < b : (($0["sort_order"] as? Int) ?? 0) < (($1["sort_order"] as? Int) ?? 0)
|
||||||
|
}
|
||||||
|
let packed = items.filter { done($0) }.count
|
||||||
|
return ["items": sorted.compactMap(entry), "packed": packed, "total": items.count]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open ones first by due date (undated last) and priority, then the done ones.
|
||||||
|
static func todoList(_ items: [[String: Any]]) -> Any {
|
||||||
|
guard !items.isEmpty else { return NSNull() }
|
||||||
|
let due = { (item: [String: Any]) -> String? in trimmed(item["due_date"], 10) }
|
||||||
|
let sorted = items.sorted { a, b in
|
||||||
|
if done(a) != done(b) { return !done(a) }
|
||||||
|
if (due(a) == nil) != (due(b) == nil) { return due(a) != nil }
|
||||||
|
if let da = due(a), let db = due(b), da != db { return da < db }
|
||||||
|
let (pa, pb) = ((a["priority"] as? Int) ?? 0, (b["priority"] as? Int) ?? 0)
|
||||||
|
if pa != pb { return pa > pb }
|
||||||
|
return ((a["sort_order"] as? Int) ?? 0) < ((b["sort_order"] as? Int) ?? 0)
|
||||||
|
}
|
||||||
|
let rows = sorted.compactMap { item -> [String: Any]? in
|
||||||
|
guard var row = entry(item) else { return nil }
|
||||||
|
if let date = due(item) { row["due"] = date }
|
||||||
|
if let priority = item["priority"] as? Int, priority > 0 { row["priority"] = min(3, priority) }
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
return ["items": rows, "open": items.filter { !done($0) }.count, "total": items.count]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The snapshot as JSON within the size the watch takes: done items go first, as on the UI's side.
|
||||||
|
private static func fitted(_ snapshot: [String: Any]) -> Data? {
|
||||||
|
var object = snapshot
|
||||||
|
// A list that is now empty leaves the snapshot, as the UI leaves it out.
|
||||||
|
for key in ["packing", "todos"] where object[key] is NSNull { object.removeValue(forKey: key) }
|
||||||
|
guard var data = try? JSONSerialization.data(withJSONObject: object) else { return nil }
|
||||||
|
if data.count > maxBytes {
|
||||||
|
for key in ["packing", "todos"] {
|
||||||
|
guard var list = object[key] as? [String: Any], let items = list["items"] as? [[String: Any]] else { continue }
|
||||||
|
list["items"] = items.filter { ($0["done"] as? Bool) != true }
|
||||||
|
object[key] = list
|
||||||
|
}
|
||||||
|
data = (try? JSONSerialization.data(withJSONObject: object)) ?? data
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ enum ShareHandoff {
|
|||||||
static func store(_ item: ShareItem, tripId: Int?) -> Bool {
|
static func store(_ item: ShareItem, tripId: Int?) -> Bool {
|
||||||
var meta: [String: Any] = ["name": item.name, "mimeType": item.mimeType]
|
var meta: [String: Any] = ["name": item.name, "mimeType": item.mimeType]
|
||||||
if let tripId { meta["tripId"] = tripId }
|
if let tripId { meta["tripId"] = tripId }
|
||||||
|
if let url = item.url {
|
||||||
|
meta["url"] = url
|
||||||
|
meta["title"] = item.title
|
||||||
|
}
|
||||||
guard let metaData = try? JSONSerialization.data(withJSONObject: meta) else { return false }
|
guard let metaData = try? JSONSerialization.data(withJSONObject: meta) else { return false }
|
||||||
return write(dataAccount, item.data) && write(metaAccount, metaData)
|
return write(dataAccount, item.data) && write(metaAccount, metaData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ struct ShareItem {
|
|||||||
var title: String
|
var title: String
|
||||||
/// A host, for a page.
|
/// A host, for a page.
|
||||||
var source: String?
|
var source: String?
|
||||||
|
/// The web address it came from, when it is a page or a link: the app offers to
|
||||||
|
/// keep it among the trip's links as well as to read a booking out of it.
|
||||||
|
var url: String? = nil
|
||||||
|
|
||||||
var sizeLabel: String {
|
var sizeLabel: String {
|
||||||
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
|
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
|
||||||
@@ -88,6 +91,10 @@ enum ShareLoader {
|
|||||||
let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { throw ShareLoadError.unsupported }
|
guard !trimmed.isEmpty else { throw ShareLoadError.unsupported }
|
||||||
|
// A text that is just a link (what many apps share) is that link.
|
||||||
|
if !trimmed.contains(where: \.isWhitespace), let url = URL(string: trimmed), webAddress(url) != nil {
|
||||||
|
return try await page(at: url)
|
||||||
|
}
|
||||||
let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
|
let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
|
||||||
return try checked(ShareItem(kind: .text, name: "shared-text.txt", mimeType: "text/plain",
|
return try checked(ShareItem(kind: .text, name: "shared-text.txt", mimeType: "text/plain",
|
||||||
data: Data(trimmed.utf8), title: String(firstLine.prefix(80)), source: nil))
|
data: Data(trimmed.utf8), title: String(firstLine.prefix(80)), source: nil))
|
||||||
@@ -133,21 +140,39 @@ enum ShareLoader {
|
|||||||
let url = (results["url"] as? String).flatMap(URL.init(string:))
|
let url = (results["url"] as? String).flatMap(URL.init(string:))
|
||||||
let title = (results["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? url?.host ?? "Page"
|
let title = (results["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? url?.host ?? "Page"
|
||||||
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
||||||
data: Data(html.utf8), title: title, source: url?.host))
|
data: Data(html.utf8), title: title, source: url?.host,
|
||||||
|
url: url.flatMap(webAddress)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A link shared from an app other than Safari: the page as the server sends it.
|
/// A link shared from an app other than Safari: the page as the server sends it,
|
||||||
|
/// or the bare link when the page cannot be read (a sign-in wall, an app-only
|
||||||
|
/// page, no network), which the app can still keep among the trip's links.
|
||||||
private static func page(at url: URL) async throws -> ShareItem {
|
private static func page(at url: URL) async throws -> ShareItem {
|
||||||
guard url.scheme == "https" || url.scheme == "http" else { throw ShareLoadError.unsupported }
|
guard let address = webAddress(url) else { throw ShareLoadError.unsupported }
|
||||||
var request = URLRequest(url: url, timeoutInterval: 10)
|
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
if let fetched = try? await URLSession.shared.data(for: request),
|
||||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
let http = fetched.1 as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||||
(http.mimeType ?? "").contains("html") else { throw ShareLoadError.unsupported }
|
(http.mimeType ?? "").contains("html"), !fetched.0.isEmpty, fetched.0.count <= maxBytes {
|
||||||
let html = String(decoding: data, as: UTF8.self)
|
let data = fetched.0
|
||||||
let title = pageTitle(html) ?? url.host ?? "Page"
|
let html = String(decoding: data, as: UTF8.self)
|
||||||
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
let title = pageTitle(html) ?? url.host ?? "Page"
|
||||||
data: data, title: title, source: url.host))
|
return ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
||||||
|
data: data, title: title, source: url.host, url: address)
|
||||||
|
}
|
||||||
|
return link(url, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The link alone, as a text/uri-list the app recognises as nothing to read a booking from.
|
||||||
|
private static func link(_ url: URL, _ address: String) -> ShareItem {
|
||||||
|
ShareItem(kind: .page, name: "link.url", mimeType: "text/uri-list", data: Data(address.utf8),
|
||||||
|
title: url.host ?? address, source: url.host, url: address)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An http(s) address, the only kind a trip's links take.
|
||||||
|
private static func webAddress(_ url: URL) -> String? {
|
||||||
|
guard url.scheme == "https" || url.scheme == "http", url.host != nil else { return nil }
|
||||||
|
return url.absoluteString
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func fromFile(_ url: URL) throws -> ShareItem {
|
private static func fromFile(_ url: URL) throws -> ShareItem {
|
||||||
|
|||||||
@@ -100,9 +100,27 @@ final class WatchLink: NSObject, WCSessionDelegate {
|
|||||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A tick to the phone. In reach it goes as a message, which arrives at once and
|
||||||
|
/// wakes the phone app for it, so the trip's other devices see it within seconds.
|
||||||
|
/// Out of reach, or when the message fails or a phone app too old to take it
|
||||||
|
/// answers without "queued", it goes as user info, which the system delivers later.
|
||||||
func send(_ action: PendingAction) {
|
func send(_ action: PendingAction) {
|
||||||
guard WCSession.default.activationState == .activated else { return }
|
let session = WCSession.default
|
||||||
WCSession.default.transferUserInfo(["action": action.message])
|
guard session.activationState == .activated else { return }
|
||||||
|
let message: [String: Any] = ["action": action.message]
|
||||||
|
guard session.isReachable else {
|
||||||
|
session.transferUserInfo(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.sendMessage(message, replyHandler: { reply in
|
||||||
|
if reply["queued"] as? Bool == true {
|
||||||
|
Task { @MainActor in WatchModel.shared.markSent(action.id) }
|
||||||
|
} else {
|
||||||
|
session.transferUserInfo(message)
|
||||||
|
}
|
||||||
|
}, errorHandler: { _ in
|
||||||
|
session.transferUserInfo(message)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asks the phone for a fresh snapshot while it is in reach (the app answers from what it stored).
|
/// Asks the phone for a fresh snapshot while it is in reach (the app answers from what it stored).
|
||||||
|
|||||||
@@ -66,12 +66,12 @@ wait_ready() {
|
|||||||
|
|
||||||
TOKEN=0
|
TOKEN=0
|
||||||
# shot <udid> <file> <screen> <scenario> <time or -> <language>: one launch, retried
|
# shot <udid> <file> <screen> <scenario> <time or -> <language>: one launch, retried
|
||||||
# after 2, 5 and 10 seconds when the simulator refuses it.
|
# after 2, 5, 10 and 20 seconds when the simulator refuses it, saying which step failed.
|
||||||
shot() {
|
shot() {
|
||||||
local udid=$1 file=$2 screen=$3 scenario=$4 clock=$5 lang=$6
|
local udid=$1 file=$2 screen=$3 scenario=$4 clock=$5 lang=$6
|
||||||
local locale=${lang//-/_} attempt
|
local locale=${lang//-/_} attempt failed=
|
||||||
for attempt in 0 2 5 10; do
|
for attempt in 0 2 5 10 20; do
|
||||||
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s"; sleep "$attempt"; }
|
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s ($failed)"; sleep "$attempt"; }
|
||||||
TOKEN=$((TOKEN + 1))
|
TOKEN=$((TOKEN + 1))
|
||||||
xcrun simctl terminate "$udid" "$BUNDLE" >/dev/null 2>&1 || true
|
xcrun simctl terminate "$udid" "$BUNDLE" >/dev/null 2>&1 || true
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
@@ -79,11 +79,12 @@ shot() {
|
|||||||
[ "$clock" = - ] || extra=(-TrekNow "$clock")
|
[ "$clock" = - ] || extra=(-TrekNow "$clock")
|
||||||
xcrun simctl launch "$udid" "$BUNDLE" \
|
xcrun simctl launch "$udid" "$BUNDLE" \
|
||||||
-AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" \
|
-AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" \
|
||||||
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || continue
|
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || { failed=launch; continue; }
|
||||||
wait_ready "$udid" "$TOKEN" || continue
|
wait_ready "$udid" "$TOKEN" || { failed='not ready'; continue; }
|
||||||
# The map's tiles come over the network after the screen is up.
|
# The map's tiles come over the network after the screen is up.
|
||||||
[ "$screen" = place ] && sleep 6
|
[ "$screen" = place ] && sleep 6
|
||||||
xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 && return 0
|
xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 && return 0
|
||||||
|
failed=screenshot
|
||||||
done
|
done
|
||||||
echo "gave up on $(basename "$file")"
|
echo "gave up on $(basename "$file")"
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-15
@@ -1,3 +1,4 @@
|
|||||||
|
import { App } from '@capacitor/app';
|
||||||
import { Capacitor, CapacitorHttp } from '@capacitor/core';
|
import { Capacitor, CapacitorHttp } from '@capacitor/core';
|
||||||
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
|
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
|
||||||
import { SplashScreen } from '@capacitor/splash-screen';
|
import { SplashScreen } from '@capacitor/splash-screen';
|
||||||
@@ -5,8 +6,17 @@ import { StatusBar, Style } from '@capacitor/status-bar';
|
|||||||
import logoUrl from '../public/icons/icon-white.svg';
|
import logoUrl from '../public/icons/icon-white.svg';
|
||||||
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
|
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
|
||||||
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
|
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
|
||||||
|
import { AUTH_GATE_NAMES, canSignInInApp, type AuthGate } from '../src/native/authGate';
|
||||||
|
import { opensSoon, refundedBeforeOpening } from '../src/native/cloudOrders';
|
||||||
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
|
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
|
||||||
import { CLOUD_TERMS_URL, CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
|
import {
|
||||||
|
AUTH_PROXY_DOCS_URL,
|
||||||
|
CLOUD_TERMS_URL,
|
||||||
|
CLOUD_URL,
|
||||||
|
PRIVACY_URL,
|
||||||
|
SERVER_DOCS_URL,
|
||||||
|
STORE_TERMS_URL,
|
||||||
|
} from '../src/native/links';
|
||||||
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
|
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
|
||||||
import { TrekShell } from '../src/native/trekShell';
|
import { TrekShell } from '../src/native/trekShell';
|
||||||
import { directionOf, pickLocale, translator } from './i18n';
|
import { directionOf, pickLocale, translator } from './i18n';
|
||||||
@@ -50,7 +60,7 @@ function cloudSuffix(): string {
|
|||||||
// choosing, which has no reason to answer this page's origin with CORS.
|
// choosing, which has no reason to answer this page's origin with CORS.
|
||||||
const get: ProbeGet = async (url) => {
|
const get: ProbeGet = async (url) => {
|
||||||
const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS });
|
const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS });
|
||||||
return { status: response.status, data: response.data, url: response.url };
|
return { status: response.status, data: response.data, url: response.url, headers: response.headers };
|
||||||
};
|
};
|
||||||
|
|
||||||
function failureMessage(reason: ProbeFailure): string {
|
function failureMessage(reason: ProbeFailure): string {
|
||||||
@@ -61,6 +71,8 @@ function failureMessage(reason: ProbeFailure): string {
|
|||||||
return t('native.connect.errorUnreachable');
|
return t('native.connect.errorUnreachable');
|
||||||
case 'notTrek':
|
case 'notTrek':
|
||||||
return t('native.connect.errorNotTrek');
|
return t('native.connect.errorNotTrek');
|
||||||
|
case 'authGate':
|
||||||
|
return t('native.gate.titleUnknown');
|
||||||
case 'tooOld':
|
case 'tooOld':
|
||||||
return t('native.connect.errorTooOld');
|
return t('native.connect.errorTooOld');
|
||||||
}
|
}
|
||||||
@@ -250,13 +262,19 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
|||||||
const label = el('span', {}, t('native.connect.submit'));
|
const label = el('span', {}, t('native.connect.submit'));
|
||||||
const submit = el('button', { type: 'submit', className: 'btn' }, label);
|
const submit = el('button', { type: 'submit', className: 'btn' }, label);
|
||||||
const error = el('p', { className: 'error', role: 'alert' });
|
const error = el('p', { className: 'error', role: 'alert' });
|
||||||
const form = el('form', { className: 'form', noValidate: true }, field, submit, error);
|
const gatePanel = el('div', { className: 'gate', role: 'alert', hidden: true });
|
||||||
|
const form = el('form', { className: 'form', noValidate: true }, field, submit, error, gatePanel);
|
||||||
|
|
||||||
const fail = (reason: ProbeFailure): void => {
|
const fail = (reason: ProbeFailure, gate?: AuthGate, server?: string): void => {
|
||||||
void notify(NotificationType.Error);
|
void notify(NotificationType.Error);
|
||||||
field.classList.add('bad');
|
field.classList.add('bad');
|
||||||
setTimeout(() => field.classList.remove('bad'), 350);
|
setTimeout(() => field.classList.remove('bad'), 350);
|
||||||
error.textContent = failureMessage(reason);
|
if (reason === 'authGate' && gate && server) {
|
||||||
|
error.textContent = '';
|
||||||
|
fillGatePanel(gatePanel, gate, server);
|
||||||
|
} else {
|
||||||
|
error.textContent = failureMessage(reason);
|
||||||
|
}
|
||||||
input.disabled = false;
|
input.disabled = false;
|
||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
label.textContent = t('native.connect.submit');
|
label.textContent = t('native.connect.submit');
|
||||||
@@ -268,6 +286,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void tap(ImpactStyle.Medium);
|
void tap(ImpactStyle.Medium);
|
||||||
error.textContent = '';
|
error.textContent = '';
|
||||||
|
gatePanel.hidden = true;
|
||||||
|
gatePanel.replaceChildren();
|
||||||
input.disabled = true;
|
input.disabled = true;
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'));
|
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'));
|
||||||
@@ -276,7 +296,7 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
|||||||
await connected(result.url);
|
await connected(result.url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fail(result.reason);
|
fail(result.reason, result.gate, result.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
show(
|
show(
|
||||||
@@ -297,6 +317,43 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
|||||||
requestAnimationFrame(() => input.focus());
|
requestAnimationFrame(() => input.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sign-in page of the user's own stands in front of the server (authGate.ts):
|
||||||
|
* say which one, offer to sign in to it inside the app, which keeps its session
|
||||||
|
* as a browser would, and how to let the app through instead.
|
||||||
|
*/
|
||||||
|
function fillGatePanel(panel: HTMLElement, gate: AuthGate, server: string): void {
|
||||||
|
const name = gate.kind === 'unknown' || gate.kind === 'basic' ? null : AUTH_GATE_NAMES[gate.kind];
|
||||||
|
const title =
|
||||||
|
gate.kind === 'basic'
|
||||||
|
? t('native.gate.titleBasic')
|
||||||
|
: name
|
||||||
|
? t('native.gate.title', { name })
|
||||||
|
: t('native.gate.titleUnknown');
|
||||||
|
const children: Node[] = [el('p', { className: 'gate-title' }, title)];
|
||||||
|
if (canSignInInApp(gate)) {
|
||||||
|
const signIn = el(
|
||||||
|
'button',
|
||||||
|
{ type: 'button', className: 'btn' },
|
||||||
|
name ? t('native.gate.signIn', { name }) : t('native.gate.signInGeneric')
|
||||||
|
);
|
||||||
|
signIn.addEventListener('click', () => {
|
||||||
|
void tap(ImpactStyle.Medium);
|
||||||
|
void connected(server, gate.loginHost ? [gate.loginHost] : []);
|
||||||
|
});
|
||||||
|
children.push(
|
||||||
|
el('p', { className: 'gate-text' }, t('native.gate.hint')),
|
||||||
|
signIn,
|
||||||
|
el('p', { className: 'gate-note' }, t('native.gate.googleNote'))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
children.push(el('p', { className: 'gate-text' }, t('native.gate.basicHint')));
|
||||||
|
}
|
||||||
|
children.push(externalLink(t('native.gate.help'), AUTH_PROXY_DOCS_URL));
|
||||||
|
panel.replaceChildren(...children);
|
||||||
|
panel.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Get TREK Cloud ──────────────────────────────────────────────────────────
|
// ── Get TREK Cloud ──────────────────────────────────────────────────────────
|
||||||
function feature(path: string, text: string): HTMLElement {
|
function feature(path: string, text: string): HTMLElement {
|
||||||
return el(
|
return el(
|
||||||
@@ -429,12 +486,14 @@ function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): {
|
|||||||
|
|
||||||
async function loadOffer(list: CloudPlans): Promise<Offer> {
|
async function loadOffer(list: CloudPlans): Promise<Offer> {
|
||||||
const available = await iapReady;
|
const available = await iapReady;
|
||||||
// Closed: a store purchase would be refused at the cloud, so only a test build may buy.
|
// Closed: a real purchase is refused at the cloud, so only a test build may buy on iOS;
|
||||||
const opensSoon = available && list.closed && !(await iap.isSandbox().catch(() => false));
|
// Android keeps the button for the licence testers (see cloudOrders.ts).
|
||||||
const canBuy = available && !opensSoon;
|
const sandbox = list.closed && !onPlay ? await iap.isSandbox().catch(() => false) : false;
|
||||||
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon };
|
const soon = opensSoon({ available, closed: list.closed, sandbox, onPlay });
|
||||||
|
const canBuy = available && !soon;
|
||||||
|
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon: soon };
|
||||||
const ids = list.plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
|
const ids = list.plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
|
||||||
return { canBuy, storePrices: await iap.prices(ids), opensSoon };
|
return { canBuy, storePrices: await iap.prices(ids), opensSoon: soon };
|
||||||
}
|
}
|
||||||
|
|
||||||
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
|
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
|
||||||
@@ -526,7 +585,10 @@ function planFooter(offer: Offer, plans: CloudPlan[]): Node[] {
|
|||||||
void notify(NotificationType.Error);
|
void notify(NotificationType.Error);
|
||||||
restore.disabled = false;
|
restore.disabled = false;
|
||||||
status.className = 'hint bad';
|
status.className = 'hint bad';
|
||||||
status.textContent = t('native.plans.restoreFailed');
|
// Bought before the opening: refused, unacknowledged, and refunded by Google Play.
|
||||||
|
status.textContent = refundedBeforeOpening(redeemed.code, onPlay)
|
||||||
|
? t('native.claim.closedRefund')
|
||||||
|
: t('native.plans.restoreFailed');
|
||||||
});
|
});
|
||||||
|
|
||||||
// A payment that was pending (cash, some carrier billing) and has cleared since:
|
// A payment that was pending (cash, some carrier billing) and has cleared since:
|
||||||
@@ -838,7 +900,9 @@ function showClaim(
|
|||||||
slugNote.textContent = t('native.claim.taken');
|
slugNote.textContent = t('native.claim.taken');
|
||||||
slugNote.className = 'hint bad';
|
slugNote.className = 'hint bad';
|
||||||
} else {
|
} else {
|
||||||
error.textContent = t('native.claim.redeemFailed');
|
error.textContent = refundedBeforeOpening(redeemed.code, onPlay)
|
||||||
|
? t('native.claim.closedRefund')
|
||||||
|
: t('native.claim.redeemFailed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -934,7 +998,7 @@ async function runDemo(button: HTMLButtonElement, error?: HTMLElement): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The tick, then the server takes over. A beat so the success registers. */
|
/** The tick, then the server takes over. A beat so the success registers. */
|
||||||
async function connected(url: string): Promise<void> {
|
async function connected(url: string, signInHosts: string[] = []): Promise<void> {
|
||||||
void notify(NotificationType.Success);
|
void notify(NotificationType.Success);
|
||||||
const host = (() => {
|
const host = (() => {
|
||||||
try {
|
try {
|
||||||
@@ -959,7 +1023,7 @@ async function connected(url: string): Promise<void> {
|
|||||||
el('div', { className: 'success' }, tick, el('h1', { className: 'title-sm' }, t('native.connecting', { host })))
|
el('div', { className: 'success' }, tick, el('h1', { className: 'title-sm' }, t('native.connecting', { host })))
|
||||||
);
|
);
|
||||||
await new Promise((r) => setTimeout(r, 700));
|
await new Promise((r) => setTimeout(r, 700));
|
||||||
await TrekShell.setServer({ url });
|
await TrekShell.setServer(signInHosts.length ? { url, signInHosts } : { url });
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUnreachable(server: string): void {
|
function showUnreachable(server: string): void {
|
||||||
@@ -1005,8 +1069,25 @@ async function paintStatusBar(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android's back gesture steps back through the first screens as their own
|
||||||
|
* back button does, and only leaves the app from a screen that has none.
|
||||||
|
*/
|
||||||
|
function followBackGesture(): void {
|
||||||
|
try {
|
||||||
|
void App.addListener('backButton', () => {
|
||||||
|
const back = current?.querySelector<HTMLButtonElement>('.topbar .back');
|
||||||
|
if (back) back.click();
|
||||||
|
else void App.minimizeApp();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* web / unavailable: no system back gesture */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function start(): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
void paintStatusBar();
|
void paintStatusBar();
|
||||||
|
followBackGesture();
|
||||||
let url: string | null;
|
let url: string | null;
|
||||||
try {
|
try {
|
||||||
({ url } = await TrekShell.getServer());
|
({ url } = await TrekShell.getServer());
|
||||||
|
|||||||
@@ -103,6 +103,19 @@
|
|||||||
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
|
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
|
||||||
--grid: rgba(255, 255, 255, 0.055);
|
--grid: rgba(255, 255, 255, 0.055);
|
||||||
--wash-1: rgba(129, 140, 248, 0.12);
|
--wash-1: rgba(129, 140, 248, 0.12);
|
||||||
|
/* Mixes with transparent, spelled out per scheme: older Android web views (Chrome 113)
|
||||||
|
resolved color-mix() towards transparent as opaque and drew solid halos and rings. */
|
||||||
|
--accent-ink-a16: rgba(9, 9, 11, 0.16);
|
||||||
|
--accent-ink-a35: rgba(9, 9, 11, 0.35);
|
||||||
|
--danger-a16: rgba(248, 113, 113, 0.16);
|
||||||
|
--ink-a22: rgba(250, 250, 250, 0.22);
|
||||||
|
--ink-a3: rgba(250, 250, 250, 0.03);
|
||||||
|
--ink-a4: rgba(250, 250, 250, 0.04);
|
||||||
|
--ink-a9: rgba(250, 250, 250, 0.09);
|
||||||
|
--ok-a16: rgba(74, 222, 128, 0.16);
|
||||||
|
--ok-a22: rgba(74, 222, 128, 0.22);
|
||||||
|
--wash-1-a85: rgba(129, 140, 248, 0.102);
|
||||||
|
--wash-1-a90: rgba(129, 140, 248, 0.108);
|
||||||
--wash-2: rgba(244, 114, 182, 0.08);
|
--wash-2: rgba(244, 114, 182, 0.08);
|
||||||
--veil-top: rgba(9, 9, 11, 0.4);
|
--veil-top: rgba(9, 9, 11, 0.4);
|
||||||
--veil-mid: rgba(9, 9, 11, 0.72);
|
--veil-mid: rgba(9, 9, 11, 0.72);
|
||||||
@@ -135,6 +148,17 @@
|
|||||||
--ok: #16a34a;
|
--ok: #16a34a;
|
||||||
--grid: rgba(17, 24, 39, 0.075);
|
--grid: rgba(17, 24, 39, 0.075);
|
||||||
--wash-1: rgba(79, 70, 229, 0.1);
|
--wash-1: rgba(79, 70, 229, 0.1);
|
||||||
|
--accent-ink-a16: rgba(255, 255, 255, 0.16);
|
||||||
|
--accent-ink-a35: rgba(255, 255, 255, 0.35);
|
||||||
|
--danger-a16: rgba(220, 38, 38, 0.16);
|
||||||
|
--ink-a22: rgba(17, 24, 39, 0.22);
|
||||||
|
--ink-a3: rgba(17, 24, 39, 0.03);
|
||||||
|
--ink-a4: rgba(17, 24, 39, 0.04);
|
||||||
|
--ink-a9: rgba(17, 24, 39, 0.09);
|
||||||
|
--ok-a16: rgba(22, 163, 74, 0.16);
|
||||||
|
--ok-a22: rgba(22, 163, 74, 0.22);
|
||||||
|
--wash-1-a85: rgba(79, 70, 229, 0.085);
|
||||||
|
--wash-1-a90: rgba(79, 70, 229, 0.09);
|
||||||
--wash-2: rgba(219, 39, 119, 0.07);
|
--wash-2: rgba(219, 39, 119, 0.07);
|
||||||
--veil-top: rgba(255, 255, 255, 0.12);
|
--veil-top: rgba(255, 255, 255, 0.12);
|
||||||
--veil-mid: rgba(255, 255, 255, 0.74);
|
--veil-mid: rgba(255, 255, 255, 0.74);
|
||||||
@@ -398,16 +422,16 @@ body::after {
|
|||||||
height: 80px;
|
height: 80px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
animation: floaty 6s ease-in-out infinite;
|
animation: floaty 6s ease-in-out infinite;
|
||||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
|
||||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
drop-shadow(0 6px 16px var(--ink-a22));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
|
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
|
||||||
.hero-logo {
|
.hero-logo {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 22px;
|
margin-bottom: 22px;
|
||||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
|
||||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
drop-shadow(0 6px 16px var(--ink-a22));
|
||||||
animation: logoRise 0.7s var(--ease) both;
|
animation: logoRise 0.7s var(--ease) both;
|
||||||
}
|
}
|
||||||
.hero-logo img {
|
.hero-logo img {
|
||||||
@@ -564,7 +588,7 @@ body::after {
|
|||||||
border-color: var(--edge);
|
border-color: var(--edge);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 2px rgba(0, 0, 0, 0.22),
|
0 1px 2px rgba(0, 0, 0, 0.22),
|
||||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent);
|
inset 0 0 0 1px var(--ink-a4);
|
||||||
}
|
}
|
||||||
.btn-ghost:active {
|
.btn-ghost:active {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
@@ -689,7 +713,7 @@ a.linkbtn {
|
|||||||
outline: none;
|
outline: none;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||||
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
|
inset 0 1px 0 var(--ink-a4);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s,
|
border-color 0.2s,
|
||||||
box-shadow 0.25s;
|
box-shadow 0.25s;
|
||||||
@@ -701,11 +725,11 @@ a.linkbtn {
|
|||||||
}
|
}
|
||||||
.field input:focus {
|
.field input:focus {
|
||||||
border-color: var(--edge-strong);
|
border-color: var(--edge-strong);
|
||||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
|
box-shadow: 0 0 0 4px var(--ink-a9);
|
||||||
}
|
}
|
||||||
.field.bad input {
|
.field.bad input {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
|
box-shadow: 0 0 0 4px var(--danger-a16);
|
||||||
animation: shake 0.3s;
|
animation: shake 0.3s;
|
||||||
}
|
}
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
@@ -729,11 +753,48 @@ a.linkbtn {
|
|||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A sign-in proxy in front of the server: which one, sign in to it here, or let the app through. */
|
||||||
|
.gate {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
.gate[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.gate-title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.gate-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.gate-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.gate a {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Spinner inside the connect button while probing. */
|
/* Spinner inside the connect button while probing. */
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border: 2px solid color-mix(in srgb, var(--accent-ink) 35%, transparent);
|
border: 2px solid var(--accent-ink-a35);
|
||||||
border-top-color: var(--accent-ink);
|
border-top-color: var(--accent-ink);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.7s linear infinite;
|
animation: spin 0.7s linear infinite;
|
||||||
@@ -788,7 +849,7 @@ a.linkbtn {
|
|||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: color-mix(in srgb, var(--ok) 16%, transparent);
|
background: var(--ok-a16);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -889,11 +950,11 @@ a.linkbtn {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: color-mix(in srgb, var(--ok) 22%, transparent);
|
background: var(--ok-a22);
|
||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
.segmented button[aria-pressed='true'] .save {
|
.segmented button[aria-pressed='true'] .save {
|
||||||
background: color-mix(in srgb, var(--accent-ink) 16%, transparent);
|
background: var(--accent-ink-a16);
|
||||||
color: var(--accent-ink);
|
color: var(--accent-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,7 +970,7 @@ a.linkbtn {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 2px 10px -4px rgba(0, 0, 0, 0.3),
|
0 2px 10px -4px rgba(0, 0, 0, 0.3),
|
||||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 3%, transparent);
|
inset 0 0 0 1px var(--ink-a3);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s,
|
border-color 0.2s,
|
||||||
box-shadow 0.2s,
|
box-shadow 0.2s,
|
||||||
@@ -917,7 +978,7 @@ a.linkbtn {
|
|||||||
}
|
}
|
||||||
.plan.featured {
|
.plan.featured {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 30px -10px color-mix(in srgb, var(--wash-1) 85%, transparent),
|
0 10px 30px -10px var(--wash-1-a85),
|
||||||
0 2px 8px -3px rgba(0, 0, 0, 0.35);
|
0 2px 8px -3px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
.plan:active {
|
.plan:active {
|
||||||
@@ -1025,7 +1086,7 @@ a.linkbtn {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||||
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
|
inset 0 1px 0 var(--ink-a4);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s,
|
border-color 0.2s,
|
||||||
box-shadow 0.25s;
|
box-shadow 0.25s;
|
||||||
@@ -1046,11 +1107,11 @@ a.linkbtn {
|
|||||||
}
|
}
|
||||||
.slug-field:focus-within {
|
.slug-field:focus-within {
|
||||||
border-color: var(--edge-strong);
|
border-color: var(--edge-strong);
|
||||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
|
box-shadow: 0 0 0 4px var(--ink-a9);
|
||||||
}
|
}
|
||||||
.slug-field.bad {
|
.slug-field.bad {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
|
box-shadow: 0 0 0 4px var(--danger-a16);
|
||||||
animation: shake 0.3s;
|
animation: shake 0.3s;
|
||||||
}
|
}
|
||||||
.slug-suffix {
|
.slug-suffix {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/client",
|
"name": "@trek/client",
|
||||||
"version": "5.1.0",
|
"version": "5.1.1",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"src": {
|
"src": {
|
||||||
"@typescript-eslint/no-non-null-assertion": 375,
|
"@typescript-eslint/no-non-null-assertion": 375,
|
||||||
"@typescript-eslint/no-misused-promises": 845,
|
"@typescript-eslint/no-misused-promises": 843,
|
||||||
"no-console": 88,
|
"no-console": 88,
|
||||||
"complexity": 218,
|
"complexity": 218,
|
||||||
"@typescript-eslint/no-explicit-any": 144,
|
"@typescript-eslint/no-explicit-any": 144,
|
||||||
|
|||||||
@@ -669,6 +669,12 @@
|
|||||||
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"],
|
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"],
|
||||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"]
|
"mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"feature": "Road-trip Google Maps route import",
|
||||||
|
"hooks": ["src/components/Roadtrip/useGoogleRouteImport.ts"],
|
||||||
|
"desktop": ["src/components/Roadtrip/GoogleRouteImport.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/trip/roadtrip/MRtRouteImportSheet.tsx"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"feature": "Road-trip stay length draft",
|
"feature": "Road-trip stay length draft",
|
||||||
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
|
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
|
||||||
@@ -828,6 +834,12 @@
|
|||||||
"desktop": ["src/components/shared/Toast.tsx"],
|
"desktop": ["src/components/shared/Toast.tsx"],
|
||||||
"mobile": ["src/mobile/components/MToastHost.tsx"]
|
"mobile": ["src/mobile/components/MToastHost.tsx"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"feature": "Tour editor workspace",
|
||||||
|
"hooks": ["src/pages/tripPlanner/useTripPlannerPage.ts"],
|
||||||
|
"desktop": ["src/pages/TripPlannerPage.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/trip/tours/MTourWorkspace.tsx"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"feature": "Tours list filter",
|
"feature": "Tours list filter",
|
||||||
"hooks": ["src/components/Tours/tourPresentation.ts"],
|
"hooks": ["src/components/Tours/tourPresentation.ts"],
|
||||||
@@ -1026,6 +1038,7 @@
|
|||||||
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
|
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
|
||||||
"src/mobile/screens/settings/MTileCacheUsage.tsx": "Phone only: the native tile cache size and clear action, which the browser build has no counterpart for.",
|
"src/mobile/screens/settings/MTileCacheUsage.tsx": "Phone only: the native tile cache size and clear action, which the browser build has no counterpart for.",
|
||||||
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
|
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
|
||||||
|
"src/mobile/screens/trip/tabHeadSlot.ts": "Phone shell only; the place in the shell's top row the tours tab and the journal fill with their own switches. The desktop heads its panels itself.",
|
||||||
"src/mobile/screens/trip/MTripRail.tsx": "Phone shell only; the trip's sections on the leading edge of a wide window in place of the dock, with the tabs and handlers MTripShell hands in.",
|
"src/mobile/screens/trip/MTripRail.tsx": "Phone shell only; the trip's sections on the leading edge of a wide window in place of the dock, with the tabs and handlers MTripShell hands in.",
|
||||||
"src/mobile/screens/trip/MTripShellParts.tsx": "Markup only; small pieces of MTripShell's top controls (segment pill, compact toggle, document sync button), with the state and handlers MTripShell hands in.",
|
"src/mobile/screens/trip/MTripShellParts.tsx": "Markup only; small pieces of MTripShell's top controls (segment pill, compact toggle, document sync button), with the state and handlers MTripShell hands in.",
|
||||||
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
|
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
|
||||||
@@ -1045,8 +1058,10 @@
|
|||||||
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
|
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
|
||||||
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
|
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
|
||||||
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
|
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
|
||||||
|
"src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx": "Large screen form inside MRtInfoSheet over the planner's saveRoadtripLimit, the desktop dialog's own save, and the shared dayWindow, parseAvoid and rangeFromSpec.",
|
||||||
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
|
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
|
||||||
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
|
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
|
||||||
|
"src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx": "Large screen sheet over the planner's followTrack, the same hook RoadtripTrackModal runs on; the phone has no entry to it.",
|
||||||
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
|
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
|
||||||
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
|
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
|
||||||
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
|
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
"components/Map/DawarichTrailPill.tsx": 1,
|
"components/Map/DawarichTrailPill.tsx": 1,
|
||||||
"components/Map/LocationButton.tsx": 1,
|
"components/Map/LocationButton.tsx": 1,
|
||||||
"components/Map/MapTogglePill.tsx": 1,
|
"components/Map/MapTogglePill.tsx": 1,
|
||||||
"components/Map/MapView.tsx": 6,
|
"components/Map/MapView.tsx": 5,
|
||||||
"components/Map/MapViewGL.tsx": 8,
|
"components/Map/MapViewGL.tsx": 7,
|
||||||
"components/Map/NightPauseTooltip.tsx": 1,
|
"components/Map/NightPauseTooltip.tsx": 1,
|
||||||
"components/Map/PlaceHoverCard.tsx": 1,
|
"components/Map/PlaceHoverCard.tsx": 1,
|
||||||
"components/Map/PoiCategoryPill.tsx": 1,
|
"components/Map/PoiCategoryPill.tsx": 1,
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"components/Map/locationMarkerMapbox.ts": 2,
|
"components/Map/locationMarkerMapbox.ts": 2,
|
||||||
"components/Map/nightPauseMarker.ts": 4,
|
"components/Map/nightPauseMarker.ts": 4,
|
||||||
"components/Map/ratingBadge.ts": 1,
|
"components/Map/ratingBadge.ts": 1,
|
||||||
|
"components/Map/switcherRow.ts": 1,
|
||||||
"components/PDF/TripPDF.tsx": 7,
|
"components/PDF/TripPDF.tsx": 7,
|
||||||
"components/PDF/tripMapImage.ts": 1,
|
"components/PDF/tripMapImage.ts": 1,
|
||||||
"components/Packing/PackingListPanelBagCard.tsx": 1,
|
"components/Packing/PackingListPanelBagCard.tsx": 1,
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||||
"src/components/Map/MapView.tsx": 1201,
|
"src/components/Map/MapView.tsx": 1200,
|
||||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||||
"src/components/Map/MapViewGL.tsx": 2127,
|
"src/components/Map/MapViewGL.tsx": 2126,
|
||||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||||
|
|||||||
@@ -60,8 +60,8 @@
|
|||||||
"components/Map/LocationButton.tsx": 1,
|
"components/Map/LocationButton.tsx": 1,
|
||||||
"components/Map/MapPluginMarkers.tsx": 2,
|
"components/Map/MapPluginMarkers.tsx": 2,
|
||||||
"components/Map/MapTogglePill.tsx": 1,
|
"components/Map/MapTogglePill.tsx": 1,
|
||||||
"components/Map/MapView.tsx": 8,
|
"components/Map/MapView.tsx": 7,
|
||||||
"components/Map/MapViewGL.tsx": 11,
|
"components/Map/MapViewGL.tsx": 10,
|
||||||
"components/Map/NightPauseTooltip.tsx": 1,
|
"components/Map/NightPauseTooltip.tsx": 1,
|
||||||
"components/Map/PlaceHoverCard.tsx": 1,
|
"components/Map/PlaceHoverCard.tsx": 1,
|
||||||
"components/Map/ReservationOverlay.tsx": 5,
|
"components/Map/ReservationOverlay.tsx": 5,
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
"components/Map/poiCategories.ts": 7,
|
"components/Map/poiCategories.ts": 7,
|
||||||
"components/Map/ratingBadge.ts": 2,
|
"components/Map/ratingBadge.ts": 2,
|
||||||
"components/Map/reservationsMapbox.ts": 1,
|
"components/Map/reservationsMapbox.ts": 1,
|
||||||
|
"components/Map/switcherRow.ts": 1,
|
||||||
"components/PDF/TripPDF.tsx": 51,
|
"components/PDF/TripPDF.tsx": 51,
|
||||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import NewVersionNotice from './components/Layout/NewVersionNotice'
|
|||||||
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
|
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
|
||||||
import AppLockGate from './components/Layout/AppLockGate'
|
import AppLockGate from './components/Layout/AppLockGate'
|
||||||
import NativeImportSheet from './mobile/components/NativeImportSheet'
|
import NativeImportSheet from './mobile/components/NativeImportSheet'
|
||||||
|
import MPushPrompt from './mobile/components/MPushPrompt'
|
||||||
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
|
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
|
||||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||||
import HelpPanel from './components/Help/HelpPanel'
|
import HelpPanel from './components/Help/HelpPanel'
|
||||||
@@ -453,6 +454,7 @@ export default function App() {
|
|||||||
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
|
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
|
||||||
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
|
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
|
||||||
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
|
<ErrorBoundary boundaryId="widget:native-import" fallback={null}><NativeImportSheet /></ErrorBoundary>
|
||||||
|
{!hideAuthedWidgets && isPhone && <ErrorBoundary boundaryId="widget:push-prompt" fallback={null}><MPushPrompt /></ErrorBoundary>}
|
||||||
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
|
<ErrorBoundary boundaryId="widget:native-widgets" fallback={null}><NativeWidgetsSync /></ErrorBoundary>
|
||||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||||
across navigations. react-router runs location updates inside a transition,
|
across navigations. react-router runs location updates inside a transition,
|
||||||
|
|||||||
@@ -77,10 +77,24 @@ describe('CollectionsWidget', () => {
|
|||||||
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
|
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-COLWIDGET-008: nothing renders while the fetch is still in flight', async () => {
|
it('FE-COMP-COLWIDGET-008: only a placeholder shows while the fetch is still in flight', async () => {
|
||||||
render(<CollectionsWidget onOpen={() => {}} />);
|
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
|
||||||
|
|
||||||
expect(screen.queryByText('No saved places yet')).not.toBeInTheDocument();
|
// Nothing remembered yet: the empty hint's shape, hidden from assistive tech, holds the place.
|
||||||
await waitFor(() => expect(screen.getByText('No saved places yet')).toBeInTheDocument());
|
expect(container.querySelector('.col-empty.trek-skeleton')).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
await waitFor(() => expect(container.querySelector('.trek-skeleton')).toBeNull());
|
||||||
|
expect(screen.getByText('No saved places yet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLWIDGET-009: #3009 holds the badges it showed last time while the lists load, then remembers the new count', async () => {
|
||||||
|
localStorage.setItem('trek_dash_collections_shown', '3');
|
||||||
|
collections([1, 2].map(id => list({ id, name: `List ${id}` })));
|
||||||
|
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(3);
|
||||||
|
expect(await screen.findByText('List 1')).toBeInTheDocument();
|
||||||
|
expect(container.querySelectorAll('.col-badge-pending')).toHaveLength(0);
|
||||||
|
await waitFor(() => expect(localStorage.getItem('trek_dash_collections_shown')).toBe('2'));
|
||||||
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Bookmark, ArrowRight, MapPin } from 'lucide-react'
|
import { Bookmark, ArrowRight, MapPin } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { entityGradient } from '../../utils/gradients'
|
import { entityGradient } from '../../utils/gradients'
|
||||||
import { useCollectionLists } from './useCollectionLists'
|
import { useCollectionLists } from './useCollectionLists'
|
||||||
|
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard sidebar widget — a glassy `.tool` card that surfaces the user's
|
* Dashboard sidebar widget — a glassy `.tool` card that surfaces the user's
|
||||||
@@ -16,6 +17,10 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { lists, loading } = useCollectionLists()
|
const { lists, loading } = useCollectionLists()
|
||||||
|
const [expected] = useState(readShownCollections)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) rememberShownCollections(Math.min(lists.length, 6))
|
||||||
|
}, [loading, lists.length])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tool">
|
<div className="tool">
|
||||||
@@ -25,7 +30,15 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
|
|||||||
<ArrowRight size={14} />
|
<ArrowRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{loading ? null : lists.length === 0 ? (
|
{loading ? (
|
||||||
|
expected ? (
|
||||||
|
<div className="col-badges" aria-hidden>
|
||||||
|
{Array.from({ length: expected }, (_, i) => <div key={i} className="col-badge col-badge-pending trek-skeleton" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="col-empty trek-skeleton" aria-hidden>{t('collections.widget.empty')}</div>
|
||||||
|
)
|
||||||
|
) : lists.length === 0 ? (
|
||||||
<div className="col-empty">{t('collections.widget.empty')}</div>
|
<div className="col-empty">{t('collections.widget.empty')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="col-badges">
|
<div className="col-badges">
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// FE-COMP-COLMEMORY-001 to -002: how many lists the collections widget showed last time.
|
||||||
|
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory';
|
||||||
|
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
describe('collectionsWidgetMemory', () => {
|
||||||
|
it('FE-COMP-COLMEMORY-001: remembers the count, kept within the six badges the widget shows', () => {
|
||||||
|
expect(readShownCollections()).toBeNull();
|
||||||
|
rememberShownCollections(4);
|
||||||
|
expect(readShownCollections()).toBe(4);
|
||||||
|
rememberShownCollections(9);
|
||||||
|
expect(readShownCollections()).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLMEMORY-002: ignores a value that is not a count', () => {
|
||||||
|
localStorage.setItem('trek_dash_collections_shown', 'many');
|
||||||
|
expect(readShownCollections()).toBeNull();
|
||||||
|
localStorage.setItem('trek_dash_collections_shown', '42');
|
||||||
|
expect(readShownCollections()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const KEY = 'trek_dash_collections_shown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many lists the dashboard's collections widget showed last time. While the
|
||||||
|
* lists load, the widget stands in with that many placeholder badges, so it
|
||||||
|
* opens at the height it will have and the widgets under it stay put (#3009).
|
||||||
|
* Null when nothing is remembered yet, or storage is unavailable.
|
||||||
|
*/
|
||||||
|
export function readShownCollections(): number | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
if (raw == null) return null;
|
||||||
|
const n = Number.parseInt(raw, 10);
|
||||||
|
return Number.isInteger(n) && n >= 0 && n <= 6 ? n : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberShownCollections(count: number): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, String(Math.max(0, Math.min(6, count))));
|
||||||
|
} catch {
|
||||||
|
/* storage blocked: the widget just opens at its empty height next time */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-COMP-FXHOOK-001 to -008: the currency converter both dashboards share.
|
// FE-COMP-FXHOOK-001 to -010: the currency converter both dashboards share.
|
||||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
@@ -13,7 +13,7 @@ const RATES = [
|
|||||||
const updateSetting = vi.fn<(key: string, value: unknown) => Promise<void>>();
|
const updateSetting = vi.fn<(key: string, value: unknown) => Promise<void>>();
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
function seedSettings(over: Record<string, unknown> = {}, isLoaded = false) {
|
function seedSettings(over: Record<string, unknown> = {}, isLoaded = true) {
|
||||||
useSettingsStore.setState((s) => ({
|
useSettingsStore.setState((s) => ({
|
||||||
isLoaded,
|
isLoaded,
|
||||||
updateSetting: updateSetting as unknown as typeof s.updateSetting,
|
updateSetting: updateSetting as unknown as typeof s.updateSetting,
|
||||||
@@ -133,4 +133,33 @@ describe('useCurrencyConverter', () => {
|
|||||||
});
|
});
|
||||||
expect(result.current.rate).toBe(1.3);
|
expect(result.current.rate).toBe(1.3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FXHOOK-009: #3009 waits for the saved pair before it fetches or shows a pair', async () => {
|
||||||
|
seedSettings({}, false);
|
||||||
|
const { result } = renderHook(() => useCurrencyConverter());
|
||||||
|
expect(result.current.pairPending).toBe(true);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
|
||||||
|
expect(result.current.pairPending).toBe(false);
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toContain('base=JPY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FXHOOK-010: #3009 never prices a new pair with the previous base and says it is still loading', async () => {
|
||||||
|
fetchMock = vi.fn((url: string) =>
|
||||||
|
respond(url.endsWith('base=JPY') ? [{ quote: 'EUR', rate: 0.0056 }] : [{ quote: 'USD', rate: 1.1 }])
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const { result } = renderHook(() => useCurrencyConverter());
|
||||||
|
await waitFor(() => expect(result.current.rate).toBe(1.1));
|
||||||
|
|
||||||
|
act(() => seedSettings({ dashboard_fx_from: 'JPY', dashboard_fx_to: 'EUR' }));
|
||||||
|
// EUR's map holds EUR at 1, which used to show as "1 JPY = 1.0000 EUR".
|
||||||
|
expect(result.current.rate).toBeNull();
|
||||||
|
expect(result.current.ratePending).toBe(true);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.rate).toBe(0.0056));
|
||||||
|
expect(result.current.ratePending).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,10 +23,18 @@ export function useCurrencyConverter() {
|
|||||||
updateSetting('dashboard_fx_to', v).catch(() => {});
|
updateSetting('dashboard_fx_to', v).catch(() => {});
|
||||||
};
|
};
|
||||||
const [amount, setAmount] = useState('100');
|
const [amount, setAmount] = useState('100');
|
||||||
const [rates, setRates] = useState<Record<string, number> | null>(null);
|
// The rates carry the base they were fetched for: the pair can change before
|
||||||
|
// the new base's rates arrive, and the old map priced the new pair wrongly
|
||||||
|
// (EUR's self-rate of 1 showed as "1 JPY = 1.0000 EUR", #3009).
|
||||||
|
const [rates, setRates] = useState<{ base: string; map: Record<string, number> } | null>(null);
|
||||||
|
// The base whose rates could not be had, so a failure belongs to its pair only.
|
||||||
|
const [failedBase, setFailedBase] = useState<string | null>(null);
|
||||||
const inFlight = useRef<AbortController | null>(null);
|
const inFlight = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const fetchRates = useCallback(() => {
|
const fetchRates = useCallback(() => {
|
||||||
|
// Not before the user's pair is known: the defaults' rates were fetched and
|
||||||
|
// shown first, then replaced by the saved pair (#3009).
|
||||||
|
if (!isLoaded) return;
|
||||||
inFlight.current?.abort();
|
inFlight.current?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
inFlight.current = controller;
|
inFlight.current = controller;
|
||||||
@@ -37,18 +45,22 @@ export function useCurrencyConverter() {
|
|||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
if (!Array.isArray(d)) {
|
if (!Array.isArray(d)) {
|
||||||
setRates(null);
|
setRates(null);
|
||||||
|
setFailedBase(from);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
|
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
|
||||||
const map: Record<string, number> = { [from]: 1 };
|
const map: Record<string, number> = { [from]: 1 };
|
||||||
for (const r of d) map[r.quote] = r.rate;
|
for (const r of d) map[r.quote] = r.rate;
|
||||||
setRates(map);
|
setFailedBase(null);
|
||||||
|
setRates({ base: from, map });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// An abort is not a failure: it means nobody is waiting for the answer.
|
// An abort is not a failure: it means nobody is waiting for the answer.
|
||||||
if (!signal.aborted) setRates(null);
|
if (signal.aborted) return;
|
||||||
|
setRates(null);
|
||||||
|
setFailedBase(from);
|
||||||
});
|
});
|
||||||
}, [from]);
|
}, [from, isLoaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRates();
|
fetchRates();
|
||||||
@@ -78,13 +90,32 @@ export function useCurrencyConverter() {
|
|||||||
});
|
});
|
||||||
}, [isLoaded, updateSetting]);
|
}, [isLoaded, updateSetting]);
|
||||||
|
|
||||||
const currencies: string[] = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
|
const current = rates?.base === from ? rates.map : null;
|
||||||
const rate = rates?.[to] ?? null;
|
const currencies: string[] = current ? Object.keys(current).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
|
||||||
|
const rate = current?.[to] ?? null;
|
||||||
|
// The pair is not known yet, or its rates are still on their way: neither is
|
||||||
|
// "rate unavailable", and neither may show a rate.
|
||||||
|
const pairPending = !isLoaded;
|
||||||
|
const ratePending = isLoaded && !current && failedBase !== from;
|
||||||
const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null;
|
const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null;
|
||||||
const swap = () => {
|
const swap = () => {
|
||||||
setFrom(to);
|
setFrom(to);
|
||||||
setTo(from);
|
setTo(from);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates };
|
return {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
setFrom,
|
||||||
|
setTo,
|
||||||
|
amount,
|
||||||
|
setAmount,
|
||||||
|
currencies,
|
||||||
|
rate,
|
||||||
|
converted,
|
||||||
|
swap,
|
||||||
|
fetchRates,
|
||||||
|
pairPending,
|
||||||
|
ratePending,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { JourneyMapPlace } from '@trek/shared';
|
||||||
import { useCallback, useState, type ComponentProps, type Ref } from 'react';
|
import { useCallback, useState, type ComponentProps, type Ref } from 'react';
|
||||||
import JourneyMap, { type JourneyMapHandle } from './JourneyMap';
|
import JourneyMap, { type JourneyMapHandle } from './JourneyMap';
|
||||||
import JourneyMapAuto from './JourneyMapAuto';
|
import JourneyMapAuto from './JourneyMapAuto';
|
||||||
@@ -27,6 +28,13 @@ interface Props extends MapProps {
|
|||||||
/** Leads from a picked place or stay to its entry. */
|
/** Leads from a picked place or stay to its entry. */
|
||||||
onOpenEntry?: (entryId: string) => void;
|
onOpenEntry?: (entryId: string) => void;
|
||||||
entryTitleOf?: (entryId: number) => string | null;
|
entryTitleOf?: (entryId: number) => string | null;
|
||||||
|
/** Starts an entry about a picked planned place nothing was written about; absent for a reader who cannot. */
|
||||||
|
onWriteAboutPlace?: (place: JourneyMapPlace) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A card action, only where the screen offers one. */
|
||||||
|
function offeredIf<T>(offered: unknown, action: T): T | undefined {
|
||||||
|
return offered ? action : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +52,7 @@ export default function JourneyLayeredMap({
|
|||||||
controlsTop = 12,
|
controlsTop = 12,
|
||||||
onOpenEntry,
|
onOpenEntry,
|
||||||
entryTitleOf,
|
entryTitleOf,
|
||||||
|
onWriteAboutPlace,
|
||||||
photos,
|
photos,
|
||||||
tracks,
|
tracks,
|
||||||
height = 220,
|
height = 220,
|
||||||
@@ -55,6 +64,13 @@ export default function JourneyLayeredMap({
|
|||||||
setPicked({ marks, key: marks.map((m) => m.key).join(',') });
|
setPicked({ marks, key: marks.map((m) => m.key).join(',') });
|
||||||
}, []);
|
}, []);
|
||||||
const close = useCallback(() => setPicked(null), []);
|
const close = useCallback(() => setPicked(null), []);
|
||||||
|
const writeAbout = useCallback(
|
||||||
|
(place: JourneyMapPlace) => {
|
||||||
|
setPicked(null);
|
||||||
|
onWriteAboutPlace?.(place);
|
||||||
|
},
|
||||||
|
[onWriteAboutPlace]
|
||||||
|
);
|
||||||
const openEntry = useCallback(
|
const openEntry = useCallback(
|
||||||
(entryId: string) => {
|
(entryId: string) => {
|
||||||
setPicked(null);
|
setPicked(null);
|
||||||
@@ -99,6 +115,7 @@ export default function JourneyLayeredMap({
|
|||||||
marks={card.marks}
|
marks={card.marks}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
onOpenEntry={onOpenEntry ? openEntry : undefined}
|
onOpenEntry={onOpenEntry ? openEntry : undefined}
|
||||||
|
onWriteAbout={offeredIf(onWriteAboutPlace, writeAbout)}
|
||||||
entryTitleOf={entryTitleOf}
|
entryTitleOf={entryTitleOf}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -86,4 +86,23 @@ describe('JourneyMapPickCard', () => {
|
|||||||
fireEvent.keyDown(window, { key: 'Escape' });
|
fireEvent.keyDown(window, { key: 'Escape' });
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-JOURNEY-MAPPICK-004: a place nothing was written about offers a new entry to whoever can write', () => {
|
||||||
|
const onWriteAbout = vi.fn();
|
||||||
|
const { unmount } = render(<JourneyMapPickCard marks={[plan()]} onClose={vi.fn()} onWriteAbout={onWriteAbout} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'New Entry' }));
|
||||||
|
expect(onWriteAbout).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4, name: 'Piazza San Marco' }));
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// A reader who cannot write, or a place already written about, gets no such button.
|
||||||
|
render(
|
||||||
|
<JourneyMapPickCard
|
||||||
|
marks={[plan({ entry_id: 7 })]}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onOpenEntry={vi.fn()}
|
||||||
|
onWriteAbout={onWriteAbout}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('button', { name: 'New Entry' })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
|
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
|
||||||
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, X } from 'lucide-react';
|
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, PenLine, X } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
import { SoftPill, WhiteBadge } from '../Planner/planParts';
|
import { SoftPill, WhiteBadge } from '../Planner/planParts';
|
||||||
@@ -14,6 +14,8 @@ interface Props {
|
|||||||
onOpenEntry?: (entryId: string) => void;
|
onOpenEntry?: (entryId: string) => void;
|
||||||
/** The title of an entry the reader can see, for the stay that belongs to it. */
|
/** The title of an entry the reader can see, for the stay that belongs to it. */
|
||||||
entryTitleOf?: (entryId: number) => string | null;
|
entryTitleOf?: (entryId: number) => string | null;
|
||||||
|
/** Starts an entry about a planned place nothing was written about; absent where the reader cannot write. */
|
||||||
|
onWriteAbout?: (place: JourneyMapPlace) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayLabel(date: string, locale: string): string {
|
function dayLabel(date: string, locale: string): string {
|
||||||
@@ -41,7 +43,15 @@ function OpenEntry({ entryId, onOpenEntry }: { entryId: number; onOpenEntry?: (e
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEntry?: (entryId: string) => void }) {
|
function PlaceDetail({
|
||||||
|
place,
|
||||||
|
onOpenEntry,
|
||||||
|
onWriteAbout,
|
||||||
|
}: {
|
||||||
|
place: JourneyMapPlace;
|
||||||
|
onOpenEntry?: (entryId: string) => void;
|
||||||
|
onWriteAbout?: (place: JourneyMapPlace) => void;
|
||||||
|
}) {
|
||||||
const { t, locale } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -56,9 +66,22 @@ function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEnt
|
|||||||
{place.entry_id !== null ? (
|
{place.entry_id !== null ? (
|
||||||
<OpenEntry entryId={place.entry_id} onOpenEntry={onOpenEntry} />
|
<OpenEntry entryId={place.entry_id} onOpenEntry={onOpenEntry} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-content-muted" style={fs(11.5, 'body')}>
|
<>
|
||||||
{t('journey.map.pick.notWritten')}
|
<p className="text-content-muted" style={fs(11.5, 'body')}>
|
||||||
</p>
|
{t('journey.map.pick.notWritten')}
|
||||||
|
</p>
|
||||||
|
{onWriteAbout && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onWriteAbout(place)}
|
||||||
|
className="inline-flex items-center gap-1 self-start rounded-lg bg-accent px-2.5 py-1.5 font-semibold text-accent-text hover:opacity-90"
|
||||||
|
style={fs(11.5, 'body')}
|
||||||
|
>
|
||||||
|
<PenLine size={12} strokeWidth={2.2} aria-hidden />
|
||||||
|
{t('journey.detail.newEntry')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -106,7 +129,7 @@ function markName(mark: JourneyMapMark): string {
|
|||||||
* one spot come as a list first, each with what kind it is, so overlapping
|
* one spot come as a list first, each with what kind it is, so overlapping
|
||||||
* hits stay reachable one by one.
|
* hits stay reachable one by one.
|
||||||
*/
|
*/
|
||||||
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf }: Props) {
|
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf, onWriteAbout }: Props) {
|
||||||
const { t, locale } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const [picked, setPicked] = useState<JourneyMapMark | null>(marks.length === 1 ? marks[0] : null);
|
const [picked, setPicked] = useState<JourneyMapMark | null>(marks.length === 1 ? marks[0] : null);
|
||||||
|
|
||||||
@@ -150,7 +173,9 @@ export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryT
|
|||||||
<X size={14} strokeWidth={2.2} />
|
<X size={14} strokeWidth={2.2} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{picked?.kind === 'plan' && <PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} />}
|
{picked?.kind === 'plan' && (
|
||||||
|
<PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} onWriteAbout={onWriteAbout} />
|
||||||
|
)}
|
||||||
{picked?.kind === 'stay' && (
|
{picked?.kind === 'stay' && (
|
||||||
<StayDetail stay={picked.stay} onOpenEntry={onOpenEntry} entryTitleOf={entryTitleOf} />
|
<StayDetail stay={picked.stay} onOpenEntry={onOpenEntry} entryTitleOf={entryTitleOf} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import JourneyDayScrubber from './JourneyDayScrubber'
|
|||||||
import { dayColorOf } from './journeyCard'
|
import { dayColorOf } from './journeyCard'
|
||||||
import { useJourneyCarouselSync } from './useJourneyCarouselSync'
|
import { useJourneyCarouselSync } from './useJourneyCarouselSync'
|
||||||
import type { JourneyEntry } from '../../store/journeyStore'
|
import type { JourneyEntry } from '../../store/journeyStore'
|
||||||
import type { JourneyTrack } from '@trek/shared'
|
import type { JourneyMapPlace, JourneyTrack } from '@trek/shared'
|
||||||
|
|
||||||
interface MapEntry {
|
interface MapEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -40,6 +40,8 @@ interface Props {
|
|||||||
/** The map's layers (JRN-04); a picked place or stay leads to its entry's card. */
|
/** The map's layers (JRN-04); a picked place or stay leads to its entry's card. */
|
||||||
layers?: JourneyMapLayersView | null
|
layers?: JourneyMapLayersView | null
|
||||||
entryTitleOf?: (entryId: number) => string | null
|
entryTitleOf?: (entryId: number) => string | null
|
||||||
|
/** Starts an entry about a picked planned place nothing was written about. */
|
||||||
|
onWriteAboutPlace?: (place: JourneyMapPlace) => void
|
||||||
/** Geotagged photos on the map, a tap opening them. */
|
/** Geotagged photos on the map, a tap opening them. */
|
||||||
photos?: MapPhoto[]
|
photos?: MapPhoto[]
|
||||||
onPhotoClick?: (photoIds: string[]) => void
|
onPhotoClick?: (photoIds: string[]) => void
|
||||||
@@ -64,6 +66,7 @@ export default function MobileMapTimeline({
|
|||||||
initialEntryId,
|
initialEntryId,
|
||||||
layers,
|
layers,
|
||||||
entryTitleOf,
|
entryTitleOf,
|
||||||
|
onWriteAboutPlace,
|
||||||
photos,
|
photos,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
controlsTop,
|
controlsTop,
|
||||||
@@ -118,6 +121,7 @@ export default function MobileMapTimeline({
|
|||||||
hideLayerControl
|
hideLayerControl
|
||||||
controlsTop={controlsTop}
|
controlsTop={controlsTop}
|
||||||
onOpenEntry={handleMarkerClick}
|
onOpenEntry={handleMarkerClick}
|
||||||
|
onWriteAboutPlace={onWriteAboutPlace}
|
||||||
entryTitleOf={entryTitleOf}
|
entryTitleOf={entryTitleOf}
|
||||||
entries={mapEntries}
|
entries={mapEntries}
|
||||||
checkins={[]}
|
checkins={[]}
|
||||||
@@ -160,6 +164,7 @@ export default function MobileMapTimeline({
|
|||||||
hideLayerControl
|
hideLayerControl
|
||||||
controlsTop={controlsTop}
|
controlsTop={controlsTop}
|
||||||
onOpenEntry={handleMarkerClick}
|
onOpenEntry={handleMarkerClick}
|
||||||
|
onWriteAboutPlace={onWriteAboutPlace}
|
||||||
entryTitleOf={entryTitleOf}
|
entryTitleOf={entryTitleOf}
|
||||||
entries={mapEntries}
|
entries={mapEntries}
|
||||||
checkins={[]}
|
checkins={[]}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useTranslation } from '../../i18n';
|
|||||||
import { useNativeCapabilities } from '../../native/capabilities';
|
import { useNativeCapabilities } from '../../native/capabilities';
|
||||||
import { TrekShell } from '../../native/trekShell';
|
import { TrekShell } from '../../native/trekShell';
|
||||||
import { applyWatchActions, buildWatchSnapshot, publishWatch, takeWatchActions, watchActive } from '../../native/watch';
|
import { applyWatchActions, buildWatchSnapshot, publishWatch, takeWatchActions, watchActive } from '../../native/watch';
|
||||||
|
import { onWatchListsChange } from '../../native/watchListChanges';
|
||||||
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
|
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
|
||||||
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
|
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
|
||||||
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
|
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
|
||||||
@@ -229,8 +230,11 @@ export default function NativeWidgetsSync(): null {
|
|||||||
if (isActive) void sync().then(drainWatch);
|
if (isActive) void sync().then(drainWatch);
|
||||||
});
|
});
|
||||||
const watchListener = watchActive() ? TrekShell.addListener('watchAction', () => void drainWatch()) : null;
|
const watchListener = watchActive() ? TrekShell.addListener('watchAction', () => void drainWatch()) : null;
|
||||||
|
// A tick here, or one another member made, reaches the watch at once rather than on the next refresh.
|
||||||
|
const stopListWatch = watchActive() ? onWatchListsChange(() => void sync()) : null;
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
stopListWatch?.();
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
void listener.then((handle) => handle.remove());
|
void listener.then((handle) => handle.remove());
|
||||||
void watchListener?.then((handle) => handle.remove());
|
void watchListener?.then((handle) => handle.remove());
|
||||||
|
|||||||
@@ -312,3 +312,50 @@ describe('TourMapLayerSwitcher piste switch (SKI-01)', () => {
|
|||||||
expect(screen.getByRole('status')).toHaveTextContent('')
|
expect(screen.getByRole('status')).toHaveTextContent('')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('TourMapLayerSwitcher fitting its map', () => {
|
||||||
|
/** A parent of `room` px, and the pill reporting `withWords` px while its words show. */
|
||||||
|
function renderIn(room: { width: number }, withWords: number) {
|
||||||
|
const host = document.createElement('div')
|
||||||
|
Object.defineProperty(host, 'clientWidth', { get: () => room.width })
|
||||||
|
document.body.appendChild(host)
|
||||||
|
const view = render(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />, { container: host })
|
||||||
|
const pill = () => screen.getByRole('group')
|
||||||
|
Object.defineProperty(pill(), 'scrollWidth', {
|
||||||
|
get: () => (pill().dataset.layout === 'labels' ? withWords : 200),
|
||||||
|
})
|
||||||
|
return { ...view, pill }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('FE-COMP-MAPLAYER-025: keeps its words while they fit, goes to its icons on a narrow map, and back once there is room', () => {
|
||||||
|
const room = { width: 900 }
|
||||||
|
const { pill, rerender } = renderIn(room, 600)
|
||||||
|
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||||
|
expect(pill().dataset.layout).toBe('labels')
|
||||||
|
expect(screen.getByText('Topo')).toBeInTheDocument()
|
||||||
|
|
||||||
|
room.width = 500
|
||||||
|
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||||
|
expect(pill().dataset.layout).toBe('icons')
|
||||||
|
expect(screen.queryByText('Topo')).toBeNull()
|
||||||
|
expect(screen.getByRole('button', { name: 'Topo' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
room.width = 599
|
||||||
|
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||||
|
expect(pill().dataset.layout).toBe('icons')
|
||||||
|
room.width = 600
|
||||||
|
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
|
||||||
|
expect(pill().dataset.layout).toBe('labels')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-MAPLAYER-026: stood on the side it is a column of icons, and a set compact is never second-guessed', () => {
|
||||||
|
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} vertical />)
|
||||||
|
expect(screen.getByRole('group').dataset.layout).toBe('vertical')
|
||||||
|
expect(screen.getByRole('group').style.flexDirection).toBe('column')
|
||||||
|
expect(screen.queryByText('Topo')).toBeNull()
|
||||||
|
|
||||||
|
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} compact={false} />)
|
||||||
|
expect(screen.getByRole('group').dataset.layout).toBe('labels')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
|
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
|
||||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
||||||
import { PisteKeyButton } from '../Tours/winter/PisteKey'
|
import { PisteKeyButton } from '../Tours/winter/PisteKey'
|
||||||
import type { ComponentType } from 'react'
|
import type { ComponentType, CSSProperties } from 'react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
||||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||||
@@ -11,6 +11,7 @@ import { Tooltip } from '../shared/Tooltip'
|
|||||||
import { FROSTED_SHELL, segmentStyle } from './mapControlShadow'
|
import { FROSTED_SHELL, segmentStyle } from './mapControlShadow'
|
||||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||||
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
|
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
|
||||||
|
import { useLabelFit } from './useLabelFit'
|
||||||
|
|
||||||
export type BaseLayer = 'default' | 'satellite'
|
export type BaseLayer = 'default' | 'satellite'
|
||||||
export type TourBaseLayer = BaseLayer | TourRasterBase
|
export type TourBaseLayer = BaseLayer | TourRasterBase
|
||||||
@@ -31,6 +32,7 @@ export interface TourLayerOffer {
|
|||||||
|
|
||||||
/** Distance from the map's left edge (plus any side panel) to the switcher. */
|
/** Distance from the map's left edge (plus any side panel) to the switcher. */
|
||||||
export const MAP_LAYER_SWITCHER_INSET = 20
|
export const MAP_LAYER_SWITCHER_INSET = 20
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outer size of the round shell: a 34px button in 4px of padding, the same shell as
|
* Outer size of the round shell: a 34px button in 4px of padding, the same shell as
|
||||||
* MapCompassPill. The markup below keeps its literals, and FE-COMP-MAPLAYER-006 holds
|
* MapCompassPill. The markup below keeps its literals, and FE-COMP-MAPLAYER-006 holds
|
||||||
@@ -257,11 +259,31 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Words, icons only, or a column on the map's side: set by the caller, or fitted to the room. */
|
||||||
|
function useSwitcherLayout(compactProp: boolean | undefined, vertical: boolean) {
|
||||||
|
const { attach, iconsOnly } = useLabelFit(compactProp === undefined && !vertical)
|
||||||
|
const compact = vertical || (compactProp ?? iconsOnly)
|
||||||
|
const layout = vertical ? 'vertical' : compact ? 'icons' : 'labels'
|
||||||
|
return { attachFit: attach, compact, layout }
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellStyle(vertical: boolean): CSSProperties {
|
||||||
|
return vertical ? { ...FROSTED_SHELL, gap: 2, flexDirection: 'column', borderRadius: 22 } : { ...FROSTED_SHELL, gap: 2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider({ vertical }: { vertical: boolean }) {
|
||||||
|
return <span aria-hidden className={vertical ? 'my-0.5 h-px w-5 bg-edge' : 'mx-0.5 h-5 w-px bg-edge'} />
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-view Tours control. It never reads or writes the global map setting.
|
* Per-view Tours control. It never reads or writes the global map setting.
|
||||||
* `compact` shows the base layers and the via ferrata switch by their icons
|
* `compact` shows the base layers and the via ferrata switch by their icons
|
||||||
* alone, each named by its tooltip, for a bottom row that has to share its
|
* alone, each named by its tooltip, for a bottom row that has to share its
|
||||||
* width (the 3D map's, beside the flyover dock).
|
* width (the 3D map's, beside the flyover dock). Left out, the control goes to
|
||||||
|
* its icons by itself once its words no longer fit the room its parent gives
|
||||||
|
* it (a narrow map, such as the iPad's beside the tour pane).
|
||||||
|
* `vertical` stands it on the map's side, icons only (the 3D map, where its
|
||||||
|
* bottom row has no room left for it).
|
||||||
*/
|
*/
|
||||||
export function TourMapLayerSwitcher({
|
export function TourMapLayerSwitcher({
|
||||||
active,
|
active,
|
||||||
@@ -271,7 +293,8 @@ export function TourMapLayerSwitcher({
|
|||||||
ferrataLayer,
|
ferrataLayer,
|
||||||
placeTool,
|
placeTool,
|
||||||
layers,
|
layers,
|
||||||
compact = false,
|
compact: compactProp,
|
||||||
|
vertical = false,
|
||||||
}: {
|
}: {
|
||||||
active: TourBaseLayer
|
active: TourBaseLayer
|
||||||
onChange: (layer: TourBaseLayer) => void
|
onChange: (layer: TourBaseLayer) => void
|
||||||
@@ -285,15 +308,23 @@ export function TourMapLayerSwitcher({
|
|||||||
/** Offers the "set points" switch, while a tour is being edited. */
|
/** Offers the "set points" switch, while a tour is being edited. */
|
||||||
placeTool?: MapPlaceTool
|
placeTool?: MapPlaceTool
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
vertical?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { attachFit, compact, layout } = useSwitcherLayout(compactProp, vertical)
|
||||||
const bases = layers?.bases
|
const bases = layers?.bases
|
||||||
const overlays = layers?.overlays
|
const overlays = layers?.overlays
|
||||||
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
|
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
|
||||||
const health = layers?.baseHealth
|
const health = layers?.baseHealth
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
<div
|
||||||
|
ref={attachFit}
|
||||||
|
style={shellStyle(vertical)}
|
||||||
|
role="group"
|
||||||
|
aria-label={t('settings.mapStyle')}
|
||||||
|
data-layout={layout}
|
||||||
|
>
|
||||||
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
||||||
const selected = active === id
|
const selected = active === id
|
||||||
const label = t(labelKey)
|
const label = t(labelKey)
|
||||||
@@ -321,7 +352,7 @@ export function TourMapLayerSwitcher({
|
|||||||
{health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')}
|
{health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <Divider vertical={vertical} />}
|
||||||
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
||||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
||||||
{overlays?.map(overlay => <OverlayToggle key={overlay.id} overlay={overlay} compact={compact} />)}
|
{overlays?.map(overlay => <OverlayToggle key={overlay.id} overlay={overlay} compact={compact} />)}
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ import VectorBasemap from './VectorBasemap'
|
|||||||
import { nativeRasterTemplate } from '../../native/tileCache'
|
import { nativeRasterTemplate } from '../../native/tileCache'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
|
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
|
||||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
|
import { MapLayerSwitcher, TourMapLayerSwitcher, type TourBaseLayer } from './MapLayerSwitcher'
|
||||||
|
import { switcherRowStyle } from './switcherRow'
|
||||||
import { MapLockPill } from './MapLockPill'
|
import { MapLockPill } from './MapLockPill'
|
||||||
import type { ViewportPadding } from '../../utils/mapViewport'
|
import type { ViewportPadding } from '../../utils/mapViewport'
|
||||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
|
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
|
||||||
@@ -1126,7 +1127,7 @@ export const MapView = memo(function MapView({
|
|||||||
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
|
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
|
||||||
smaller gap its shadow ran into the sidebar edge and the two read as one
|
smaller gap its shadow ran into the sidebar edge and the two read as one
|
||||||
surface. */}
|
surface. */}
|
||||||
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ ...switcherRowStyle(leftWidth, rightWidth), bottom: switcherBottom }}>
|
||||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||||
{hasViewBaseLayer
|
{hasViewBaseLayer
|
||||||
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
|
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ vi.mock('../../hooks/useNetworkMode', () => ({
|
|||||||
useNetworkMode: () => ({ offline: env.offline, forced: false, setForced: () => {} }),
|
useNetworkMode: () => ({ offline: env.offline, forced: false, setForced: () => {} }),
|
||||||
}));
|
}));
|
||||||
vi.mock('../../hooks/useIsDark', () => ({ useIsDark: () => env.dark }));
|
vi.mock('../../hooks/useIsDark', () => ({ useIsDark: () => env.dark }));
|
||||||
vi.mock('../../native/tileCache', () => ({ tileCacheActive: () => env.tileStore }));
|
vi.mock('../../native/tileCache', () => ({
|
||||||
|
tileCacheActive: () => env.tileStore,
|
||||||
|
glTileStoreActive: () => env.tileStore,
|
||||||
|
}));
|
||||||
vi.mock('../../native/platform', async (importOriginal) => ({
|
vi.mock('../../native/platform', async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import('../../native/platform')>()),
|
...(await importOriginal<typeof import('../../native/platform')>()),
|
||||||
isNativeIos: () => false,
|
isNativeIos: () => false,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useNetworkMode } from '../../hooks/useNetworkMode'
|
|||||||
import { useIsDark } from '../../hooks/useIsDark'
|
import { useIsDark } from '../../hooks/useIsDark'
|
||||||
import { OFM_POSITRON } from '../../constants/mapDefaults'
|
import { OFM_POSITRON } from '../../constants/mapDefaults'
|
||||||
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap'
|
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap'
|
||||||
import { tileCacheActive } from '../../native/tileCache'
|
import { glTileStoreActive, tileCacheActive } from '../../native/tileCache'
|
||||||
|
|
||||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||||
@@ -50,7 +50,7 @@ export function MapViewAuto(props: MapViewAutoProps) {
|
|||||||
// nothing stored is swapped for the stored one straight away.
|
// nothing stored is swapped for the stored one straight away.
|
||||||
const nativeStore = tileCacheActive()
|
const nativeStore = tileCacheActive()
|
||||||
const [preferStored, setPreferStored] = useState(false)
|
const [preferStored, setPreferStored] = useState(false)
|
||||||
const mapboxKept = configuredGl === 'mapbox-gl' && nativeStore
|
const mapboxKept = configuredGl === 'mapbox-gl' && glTileStoreActive()
|
||||||
const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle) && (!mapboxKept || preferStored)
|
const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle) && (!mapboxKept || preferStored)
|
||||||
const glProvider = glFallback ? 'maplibre-gl' : configuredGl
|
const glProvider = glFallback ? 'maplibre-gl' : configuredGl
|
||||||
const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON, nativeIosWithoutStore(nativeStore))
|
const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON, nativeIosWithoutStore(nativeStore))
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { useTranslation } from '../../i18n/TranslationContext'
|
|||||||
import { applyBaseLayer } from './glRasterLayers'
|
import { applyBaseLayer } from './glRasterLayers'
|
||||||
import { reportGlViewport } from './glViewport'
|
import { reportGlViewport } from './glViewport'
|
||||||
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
|
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
|
||||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
import { MapLayerSwitcher, TourMapLayerSwitcher, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
||||||
|
import { switcherRowStyle } from './switcherRow'
|
||||||
import { MapLockPill } from './MapLockPill'
|
import { MapLockPill } from './MapLockPill'
|
||||||
import { useMarkerThumbs } from './useMarkerThumbs'
|
import { useMarkerThumbs } from './useMarkerThumbs'
|
||||||
import { placeMarkerFrame, placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
|
import { placeMarkerFrame, placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
|
||||||
@@ -2060,8 +2061,7 @@ export function MapViewGL({
|
|||||||
card too, and used to send the pill to the top of the map for a corner it never
|
card too, and used to send the pill to the top of the map for a corner it never
|
||||||
covers. */}
|
covers. */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, zIndex: 1000, pointerEvents: 'none',
|
...switcherRowStyle(leftWidth, rightWidth),
|
||||||
display: 'flex', flexDirection: 'column', gap: 8,
|
|
||||||
bottom: isMobile && hasDayDetail
|
bottom: isMobile && hasDayDetail
|
||||||
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||||
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
|
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* change, and what is left behind on teardown.
|
* change, and what is left behind on teardown.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { render, waitFor } from '@testing-library/react'
|
import { act, render, waitFor } from '@testing-library/react'
|
||||||
import type * as L from 'leaflet'
|
import type * as L from 'leaflet'
|
||||||
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
|
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
|
||||||
import { hasWebGL } from '../../utils/webgl'
|
import { hasWebGL } from '../../utils/webgl'
|
||||||
@@ -39,6 +39,8 @@ vi.mock('react-leaflet', () => ({
|
|||||||
// jsdom has no WebGL, so without this every case below would take the raster
|
// jsdom has no WebGL, so without this every case below would take the raster
|
||||||
// branch. The suite is about the GL wiring; the raster branch has its own cases.
|
// branch. The suite is about the GL wiring; the raster branch has its own cases.
|
||||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
|
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
|
||||||
|
const platform = vi.hoisted(() => ({ ios: false }))
|
||||||
|
vi.mock('../../native/platform', () => ({ isNativeIos: () => platform.ios, isNativeApp: () => platform.ios }))
|
||||||
vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } }))
|
vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } }))
|
||||||
vi.mock('@maplibre/maplibre-gl-leaflet', () => {
|
vi.mock('@maplibre/maplibre-gl-leaflet', () => {
|
||||||
const maplibreGL = vi.fn(() => {
|
const maplibreGL = vi.fn(() => {
|
||||||
@@ -47,6 +49,8 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => {
|
|||||||
const gl = {
|
const gl = {
|
||||||
setStyle: vi.fn(),
|
setStyle: vi.fn(),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
|
once: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
isStyleLoaded: vi.fn(() => false),
|
isStyleLoaded: vi.fn(() => false),
|
||||||
getStyle: vi.fn(() => ({
|
getStyle: vi.fn(() => ({
|
||||||
layers: [
|
layers: [
|
||||||
@@ -73,6 +77,8 @@ type Spy = ReturnType<typeof vi.fn>
|
|||||||
interface MockGl {
|
interface MockGl {
|
||||||
setStyle: Spy
|
setStyle: Spy
|
||||||
on: Spy
|
on: Spy
|
||||||
|
once: Spy
|
||||||
|
off: Spy
|
||||||
isStyleLoaded: Spy
|
isStyleLoaded: Spy
|
||||||
getStyle: Spy
|
getStyle: Spy
|
||||||
setLayoutProperty: Spy
|
setLayoutProperty: Spy
|
||||||
@@ -124,6 +130,42 @@ describe('VectorBasemap', () => {
|
|||||||
expect(maplibreGL).toHaveBeenCalledTimes(1)
|
expect(maplibreGL).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-VECBM-020: a style that errors before it loads gives way to raster tiles; later errors do not', async () => {
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||||
|
const { findByTestId, queryByTestId } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||||
|
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
|
||||||
|
const { layer, gl } = lastLayer()
|
||||||
|
const onError = gl.on.mock.calls.find(([name]) => name === 'error')?.[1] as (e: { error?: unknown }) => void
|
||||||
|
expect(gl.once).toHaveBeenCalledWith('style.load', expect.any(Function))
|
||||||
|
expect(queryByTestId('raster-basemap')).toBeNull()
|
||||||
|
|
||||||
|
act(() => onError({ error: new Error('trek-offline failed') }))
|
||||||
|
expect(await findByTestId('raster-basemap')).toBeInTheDocument()
|
||||||
|
expect(layer.remove).toHaveBeenCalled()
|
||||||
|
expect(gl.off).toHaveBeenCalledWith('error', onError)
|
||||||
|
|
||||||
|
// A second map whose style did load keeps its vector basemap through a tile error.
|
||||||
|
render(<VectorBasemap style={OFM_POSITRON} />)
|
||||||
|
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(2))
|
||||||
|
const second = lastLayer()
|
||||||
|
const secondError = second.gl.on.mock.calls.find(([name]) => name === 'error')?.[1] as (e: { error?: unknown }) => void
|
||||||
|
;(second.gl.once.mock.calls.find(([name]) => name === 'style.load')?.[1] as () => void)()
|
||||||
|
act(() => secondError({ error: new Error('one tile') }))
|
||||||
|
expect(second.layer.remove).not.toHaveBeenCalled()
|
||||||
|
warn.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-VECBM-021: in the iOS app the basemap is raster tiles, and no GL layer is built', async () => {
|
||||||
|
platform.ios = true
|
||||||
|
try {
|
||||||
|
const { findByTestId } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||||
|
expect(await findByTestId('raster-basemap')).toBeInTheDocument()
|
||||||
|
expect(maplibreGL).not.toHaveBeenCalled()
|
||||||
|
} finally {
|
||||||
|
platform.ios = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-COMP-VECBM-004: takes the layer off the map on unmount', async () => {
|
it('FE-COMP-VECBM-004: takes the layer off the map on unmount', async () => {
|
||||||
const { unmount } = render(<VectorBasemap style={OFM_POSITRON} />)
|
const { unmount } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||||
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
|
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { hasWebGL, reportWebGLFailure } from '../../utils/webgl'
|
|||||||
import { isGpuInitFailure } from './glSupport'
|
import { isGpuInitFailure } from './glSupport'
|
||||||
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
|
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
|
||||||
import { offlineStyle } from '../../native/offlineTiles'
|
import { offlineStyle } from '../../native/offlineTiles'
|
||||||
|
import { isNativeIos } from '../../native/platform'
|
||||||
|
import { nativeRasterTemplate } from '../../native/tileCache'
|
||||||
|
|
||||||
/** The Leaflet layer maplibre-gl-leaflet hands back. */
|
/** The Leaflet layer maplibre-gl-leaflet hands back. */
|
||||||
export type GlLeafletLayer = InstanceType<typeof MaplibreGL>
|
export type GlLeafletLayer = InstanceType<typeof MaplibreGL>
|
||||||
@@ -107,13 +109,43 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Whether the vector basemap can draw here. In the iOS app the MapLibre layer
|
||||||
|
* inside Leaflet drew nothing at all, with no error to fall back on (iPhone and
|
||||||
|
* iPad on iOS 26 and 27, 11/2026), while raster tiles and the 3D map drew; until
|
||||||
|
* that is traced on a device, the app draws Leaflet's basemap as raster tiles,
|
||||||
|
* through its tile store so they stay offline too.
|
||||||
|
*/
|
||||||
|
function vectorBasemapDraws(): boolean {
|
||||||
|
return hasWebGL() && !isNativeIos()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `fail` once when the layer's style errors before it ever loaded. Errors
|
||||||
|
* after that (a tile here and there) are the map's ordinary weather and leave it be.
|
||||||
|
*/
|
||||||
|
function onStyleFailure(layer: GlLeafletLayer, fail: () => void): void {
|
||||||
|
const gl = layer.getMaplibreMap?.()
|
||||||
|
if (!gl) return
|
||||||
|
let loaded = false
|
||||||
|
gl.once('style.load', () => {
|
||||||
|
loaded = true
|
||||||
|
})
|
||||||
|
const onError = () => {
|
||||||
|
if (loaded) return
|
||||||
|
gl.off('error', onError)
|
||||||
|
fail()
|
||||||
|
}
|
||||||
|
gl.on('error', onError)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The raster stand-in, attached imperatively for the maps that build Leaflet
|
* The raster stand-in, attached imperatively for the maps that build Leaflet
|
||||||
* themselves. The react-leaflet callers get the same tiles through the <TileLayer>
|
* themselves. The react-leaflet callers get the same tiles through the <TileLayer>
|
||||||
* below instead, the way they already draw a user's own raster template.
|
* below instead, the way they already draw a user's own raster template.
|
||||||
*/
|
*/
|
||||||
function attachRasterFallback(map: L.Map): L.TileLayer {
|
function attachRasterFallback(map: L.Map): L.TileLayer {
|
||||||
const layer = L.tileLayer(RASTER_FALLBACK_TILE_URL, {
|
const layer = L.tileLayer(nativeRasterTemplate(RASTER_FALLBACK_TILE_URL), {
|
||||||
maxZoom: RASTER_FALLBACK_MAX_ZOOM,
|
maxZoom: RASTER_FALLBACK_MAX_ZOOM,
|
||||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||||
} as L.TileLayerOptions)
|
} as L.TileLayerOptions)
|
||||||
@@ -174,7 +206,7 @@ export function VectorBasemap({ style }: { style: string }) {
|
|||||||
// State rather than a plain constant: the probe can say yes and the attach can
|
// State rather than a plain constant: the probe can say yes and the attach can
|
||||||
// still fail once the page has spent its context budget, and that has to reach
|
// still fail once the page has spent its context budget, and that has to reach
|
||||||
// the render or the raster tiles never appear.
|
// the render or the raster tiles never appear.
|
||||||
const [glUsable, setGlUsable] = useState(hasWebGL)
|
const [glUsable, setGlUsable] = useState(vectorBasemapDraws)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!glUsable) return
|
if (!glUsable) return
|
||||||
@@ -201,6 +233,14 @@ export function VectorBasemap({ style }: { style: string }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
layerRef.current = layer
|
layerRef.current = layer
|
||||||
|
// A style that never loads (its server, or the iOS app's offline protocol
|
||||||
|
// failing) would leave the map white under its markers: raster instead.
|
||||||
|
onStyleFailure(layer, () => {
|
||||||
|
if (cancelled || layerRef.current !== layer) return
|
||||||
|
detachBasemapLayer(layer)
|
||||||
|
layerRef.current = null
|
||||||
|
setGlUsable(false)
|
||||||
|
})
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -222,7 +262,7 @@ export function VectorBasemap({ style }: { style: string }) {
|
|||||||
return (
|
return (
|
||||||
<TileLayer
|
<TileLayer
|
||||||
key="webgl-fallback"
|
key="webgl-fallback"
|
||||||
url={RASTER_FALLBACK_TILE_URL}
|
url={nativeRasterTemplate(RASTER_FALLBACK_TILE_URL)}
|
||||||
attribution={attributionForTile(RASTER_FALLBACK_TILE_URL)}
|
attribution={attributionForTile(RASTER_FALLBACK_TILE_URL)}
|
||||||
maxZoom={RASTER_FALLBACK_MAX_ZOOM}
|
maxZoom={RASTER_FALLBACK_MAX_ZOOM}
|
||||||
keepBuffer={8}
|
keepBuffer={8}
|
||||||
@@ -256,7 +296,7 @@ export async function attachVectorBasemap(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Same gate as the component: a browser without WebGL never pays for the
|
// Same gate as the component: a browser without WebGL never pays for the
|
||||||
// maplibre chunk and gets raster tiles rather than an empty map (#2288).
|
// maplibre chunk and gets raster tiles rather than an empty map (#2288).
|
||||||
if (!hasWebGL()) {
|
if (!vectorBasemapDraws()) {
|
||||||
if (!cancelled()) ref.current = attachRasterFallback(map)
|
if (!cancelled()) ref.current = attachRasterFallback(map)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher';
|
||||||
|
|
||||||
|
/** What the switcher row leaves free at the map's trailing edge: the locate button (20px off the edge, 44px wide) and a gap. */
|
||||||
|
const LOCATE_BUTTON_ROOM = 76;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The column along a map's bottom edge that holds the layer switcher, both
|
||||||
|
* renderers alike. Its width ends before the locate button, so the switcher can
|
||||||
|
* tell when its words no longer fit (useLabelFit) and go to its icons.
|
||||||
|
*/
|
||||||
|
export function switcherRowStyle(leftWidth: number, rightWidth: number): CSSProperties {
|
||||||
|
return {
|
||||||
|
position: 'absolute',
|
||||||
|
left: leftWidth + MAP_LAYER_SWITCHER_INSET,
|
||||||
|
width: `calc(100% - ${leftWidth + MAP_LAYER_SWITCHER_INSET + rightWidth + LOCATE_BUTTON_ROOM}px)`,
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 8,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a control row keeps its words or goes to its icons: it keeps them
|
||||||
|
* while it fits the room its parent gives it (a map's bottom edge, between the
|
||||||
|
* panels and the locate button), and goes back to them once the room has grown
|
||||||
|
* to what the words needed, so it never flips back and forth at one width.
|
||||||
|
*
|
||||||
|
* `enabled` false leaves the words on and measures nothing.
|
||||||
|
*/
|
||||||
|
export function useLabelFit(enabled: boolean) {
|
||||||
|
const [node, setNode] = useState<HTMLElement | null>(null);
|
||||||
|
const [iconsOnly, setIconsOnly] = useState(false);
|
||||||
|
// The width the row needed with its words, once it had to give them up.
|
||||||
|
const needed = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const check = useCallback(() => {
|
||||||
|
const room = node?.parentElement?.clientWidth;
|
||||||
|
if (!node || !room) return;
|
||||||
|
setIconsOnly((current) => {
|
||||||
|
if (current) return needed.current !== null && room >= needed.current ? false : current;
|
||||||
|
if (node.scrollWidth > room + 1) {
|
||||||
|
needed.current = node.scrollWidth;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, [node]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (enabled) check();
|
||||||
|
});
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const parent = node?.parentElement;
|
||||||
|
if (!enabled || !parent || typeof ResizeObserver === 'undefined') return;
|
||||||
|
const observer = new ResizeObserver(() => check());
|
||||||
|
observer.observe(parent);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [enabled, node, check]);
|
||||||
|
|
||||||
|
return { attach: setNode, iconsOnly: enabled && iconsOnly };
|
||||||
|
}
|
||||||
@@ -103,7 +103,10 @@ describe('localizedParams', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('FE-NOTIF-MODEL-009: anything that is not a bare date passes through, and no params stay none', () => {
|
it('FE-NOTIF-MODEL-009: anything that is not a bare date passes through, and no params stay none', () => {
|
||||||
expect(localizedParams({ trip: '2026-11-05 at noon', n: '3' }, 'de', NOW)).toEqual({ trip: '2026-11-05 at noon', n: '3' });
|
expect(localizedParams({ trip: '2026-11-05 at noon', n: '3' }, 'de', NOW)).toEqual({
|
||||||
|
trip: '2026-11-05 at noon',
|
||||||
|
n: '3',
|
||||||
|
});
|
||||||
expect(localizedParams(undefined, 'de', NOW)).toEqual({});
|
expect(localizedParams(undefined, 'de', NOW)).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -569,7 +569,9 @@ describe('ArtikelZeile — weight and bag', () => {
|
|||||||
)
|
)
|
||||||
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
|
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
|
||||||
|
|
||||||
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } })
|
const weightField = container.querySelector('input[placeholder="—"]')!
|
||||||
|
fireEvent.change(weightField, { target: { value: '450' } })
|
||||||
|
fireEvent.blur(weightField)
|
||||||
|
|
||||||
await waitFor(() => expect(body).toMatchObject({ weight_grams: 450 }))
|
await waitFor(() => expect(body).toMatchObject({ weight_grams: 450 }))
|
||||||
})
|
})
|
||||||
@@ -587,7 +589,9 @@ describe('ArtikelZeile — weight and bag', () => {
|
|||||||
bagTrackingEnabled: true,
|
bagTrackingEnabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '' } })
|
const weightField = container.querySelector('input[placeholder="—"]')!
|
||||||
|
fireEvent.change(weightField, { target: { value: '' } })
|
||||||
|
fireEvent.blur(weightField)
|
||||||
|
|
||||||
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
|
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
|
||||||
})
|
})
|
||||||
@@ -596,7 +600,9 @@ describe('ArtikelZeile — weight and bag', () => {
|
|||||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||||
const { container } = setup({ bagTrackingEnabled: true })
|
const { container } = setup({ bagTrackingEnabled: true })
|
||||||
|
|
||||||
fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } })
|
const weightField = container.querySelector('input[placeholder="—"]')!
|
||||||
|
fireEvent.change(weightField, { target: { value: '450' } })
|
||||||
|
fireEvent.blur(weightField)
|
||||||
|
|
||||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||||
})
|
})
|
||||||
@@ -609,6 +615,7 @@ describe('ArtikelZeile — weight and bag', () => {
|
|||||||
const weight = container.querySelector<HTMLInputElement>('input[placeholder="—"]')!
|
const weight = container.querySelector<HTMLInputElement>('input[placeholder="—"]')!
|
||||||
expect(weight.readOnly).toBe(true)
|
expect(weight.readOnly).toBe(true)
|
||||||
fireEvent.change(weight, { target: { value: '450' } })
|
fireEvent.change(weight, { target: { value: '450' } })
|
||||||
|
fireEvent.blur(weight)
|
||||||
fireEvent.click(bagButton(container))
|
fireEvent.click(bagButton(container))
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByRole('button', { name: /Carry-on/ })).toBeNull())
|
await waitFor(() => expect(screen.queryByRole('button', { name: /Carry-on/ })).toBeNull())
|
||||||
@@ -929,6 +936,7 @@ describe('ArtikelZeile — overflow menu', () => {
|
|||||||
|
|
||||||
const weight = fieldOf(container, 'Total weight')
|
const weight = fieldOf(container, 'Total weight')
|
||||||
fireEvent.change(weight, { target: { value: '900' } })
|
fireEvent.change(weight, { target: { value: '900' } })
|
||||||
|
fireEvent.blur(weight)
|
||||||
await waitFor(() => expect(bodies[0]).toMatchObject({ weight_grams: 900 }))
|
await waitFor(() => expect(bodies[0]).toMatchObject({ weight_grams: 900 }))
|
||||||
|
|
||||||
// The first entry mirrors the current bag and clears it when tapped.
|
// The first entry mirrors the current bag and clears it when tapped.
|
||||||
@@ -945,6 +953,7 @@ describe('ArtikelZeile — overflow menu', () => {
|
|||||||
fireEvent.click(overflowTrigger(container))
|
fireEvent.click(overflowTrigger(container))
|
||||||
|
|
||||||
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '900' } })
|
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '900' } })
|
||||||
|
fireEvent.blur(fieldOf(container, 'Total weight'))
|
||||||
|
|
||||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||||
|
|
||||||
@@ -995,6 +1004,7 @@ describe('ArtikelZeile — overflow menu', () => {
|
|||||||
fireEvent.click(overflowTrigger(container))
|
fireEvent.click(overflowTrigger(container))
|
||||||
|
|
||||||
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '' } })
|
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '' } })
|
||||||
|
fireEvent.blur(fieldOf(container, 'Total weight'))
|
||||||
|
|
||||||
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
|
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import PackingShareControl from './PackingShareControl'
|
|||||||
import { PopoverItem } from './PackingPopover'
|
import { PopoverItem } from './PackingPopover'
|
||||||
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
|
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
|
||||||
import type { TripMember } from './usePackingListPanel'
|
import type { TripMember } from './usePackingListPanel'
|
||||||
import { NumericInput } from '../shared/NumericInput'
|
import { WeightInput } from './PackingListPanelWeightInput'
|
||||||
import { bringersOf, packingBringerNames } from './packingBringers'
|
import { bringersOf, packingBringerNames } from './packingBringers'
|
||||||
import { Tooltip } from '../shared/Tooltip'
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
import { BringerAvatar, BringersCard, MoreBringers } from './PackingBringersCard'
|
import { BringerAvatar, BringersCard, MoreBringers } from './PackingBringersCard'
|
||||||
@@ -109,6 +109,10 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
|||||||
catch { toast.error(t('common.error')) }
|
catch { toast.error(t('common.error')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveWeight = async (grams: number | null) => {
|
||||||
|
try { await updatePackingItem(tripId, item.id, { weight_grams: grams }) } catch { toast.error(t('packing.toast.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
const canDrag = canEdit && !isPlaceholder && !!drag
|
const canDrag = canEdit && !isPlaceholder && !!drag
|
||||||
const selectedBag = bags.find(b => b.id === item.bag_id)
|
const selectedBag = bags.find(b => b.id === item.bag_id)
|
||||||
const quantity = item.quantity || 1
|
const quantity = item.quantity || 1
|
||||||
@@ -251,17 +255,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
|||||||
{bagTrackingEnabled && (
|
{bagTrackingEnabled && (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', ...badge(active || item.weight_grams != null) }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', ...badge(active || item.weight_grams != null) }}>
|
||||||
<NumericInput
|
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={36} onSave={saveWeight} />
|
||||||
value={item.weight_grams ?? ''}
|
|
||||||
readOnly={!canEdit}
|
|
||||||
onValueChange={async raw => {
|
|
||||||
if (!canEdit) return
|
|
||||||
const v = raw === '' ? null : Number.parseInt(raw)
|
|
||||||
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) }
|
|
||||||
}}
|
|
||||||
placeholder="—"
|
|
||||||
style={{ width: 36, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'end', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
|
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
@@ -381,16 +375,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '6px 8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '6px 8px' }}>
|
||||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0 }}>{t('packing.totalWeight')}</span>
|
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0 }}>{t('packing.totalWeight')}</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent' }}>
|
||||||
<NumericInput
|
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={42} onSave={saveWeight} />
|
||||||
value={item.weight_grams ?? ''}
|
|
||||||
readOnly={!canEdit}
|
|
||||||
onValueChange={async raw => {
|
|
||||||
const v = raw === '' ? null : Number.parseInt(raw)
|
|
||||||
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) }
|
|
||||||
}}
|
|
||||||
placeholder="—"
|
|
||||||
style={{ width: 42, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'end', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
|
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// FE-COMP-PACKING-095 to FE-COMP-PACKING-098
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { WeightInput } from './PackingListPanelWeightInput';
|
||||||
|
|
||||||
|
/** A row whose stored weight changes only once a save answers, as the store does. */
|
||||||
|
function SlowRow({ onSave }: { onSave: (grams: number | null) => void }) {
|
||||||
|
const [stored, setStored] = useState<number | null>(null);
|
||||||
|
return (
|
||||||
|
<WeightInput
|
||||||
|
value={stored}
|
||||||
|
width={36}
|
||||||
|
onSave={(grams) => {
|
||||||
|
onSave(grams);
|
||||||
|
setTimeout(() => setStored(grams), 300);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WeightInput', () => {
|
||||||
|
it('FE-COMP-PACKING-095: #3005 keeps every typed digit and saves the whole weight once', async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<SlowRow onSave={onSave} />);
|
||||||
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.focus(input);
|
||||||
|
for (const typed of ['2', '25', '250']) {
|
||||||
|
fireEvent.change(input, { target: { value: typed } });
|
||||||
|
expect(input.value).toBe(typed);
|
||||||
|
}
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.blur(input);
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSave).toHaveBeenCalledWith(250);
|
||||||
|
await waitFor(() => expect(input.value).toBe('250'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PACKING-096: saves on Enter and stores an empty field as no weight', () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<WeightInput value={300} width={36} onSave={onSave} />);
|
||||||
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
fireEvent.focus(input);
|
||||||
|
fireEvent.change(input, { target: { value: '' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PACKING-097: leaves an unchanged weight alone and follows a change from elsewhere', () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const { rerender } = render(<WeightInput value={300} width={36} onSave={onSave} />);
|
||||||
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.focus(input);
|
||||||
|
fireEvent.blur(input);
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender(<WeightInput value={450} width={36} onSave={onSave} />);
|
||||||
|
expect(input.value).toBe('450');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PACKING-098: a read-only field never saves', () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<WeightInput value={300} width={36} readOnly onSave={onSave} />);
|
||||||
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: '9' } });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState, type CSSProperties } from 'react';
|
||||||
|
import { NumericInput } from '../shared/NumericInput';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: number | null | undefined;
|
||||||
|
onSave: (grams: number | null) => void | Promise<void>;
|
||||||
|
readOnly?: boolean;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The weight of a packing item, in grams. It keeps what is typed in a draft of
|
||||||
|
* its own and saves once, on blur or Enter, the way the quantity does: saving
|
||||||
|
* every keystroke let a slow reply put the stored value back under the cursor,
|
||||||
|
* so the next digit went onto the old number and a fragment was saved.
|
||||||
|
*/
|
||||||
|
export function WeightInput({ value, onSave, readOnly, width }: Props) {
|
||||||
|
const stored = value == null ? '' : String(value);
|
||||||
|
// What is being typed, while the field has it; null shows the stored weight,
|
||||||
|
// so a change from elsewhere (another member) shows whenever nobody types.
|
||||||
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
|
// A saved weight until the store has it, so the field does not fall back to
|
||||||
|
// the old number for the length of the request.
|
||||||
|
const [pending, setPending] = useState<string | null>(null);
|
||||||
|
const shown = draft ?? pending ?? stored;
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
if (draft === null) return;
|
||||||
|
const typed = draft;
|
||||||
|
setDraft(null);
|
||||||
|
if (readOnly) return;
|
||||||
|
const grams = typed.trim() === '' ? null : Math.max(0, Number.parseInt(typed, 10) || 0);
|
||||||
|
const next = grams == null ? '' : String(grams);
|
||||||
|
if (next === stored) return;
|
||||||
|
setPending(next);
|
||||||
|
void Promise.resolve(onSave(grams)).finally(() => setPending(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
width,
|
||||||
|
border: 'none',
|
||||||
|
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||||
|
textAlign: 'end',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
outline: 'none',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
background: 'transparent',
|
||||||
|
padding: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumericInput
|
||||||
|
value={shown}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onFocus={() => setDraft(shown)}
|
||||||
|
onValueChange={setDraft}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||||
|
}}
|
||||||
|
placeholder="—"
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,60 +1,17 @@
|
|||||||
import { useEffect, useId, useRef, useState } from 'react'
|
import { useId } from 'react'
|
||||||
import { MoreHorizontal, Link2 } from 'lucide-react'
|
import { MoreHorizontal, Link2 } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n/TranslationContext'
|
import { useTranslation } from '../../i18n/TranslationContext'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
|
||||||
import { googleRouteRepo } from '../../repo/googleRouteRepo'
|
|
||||||
import { generateUUID } from '../../sync/mutationQueue'
|
|
||||||
import { getApiErrorMessage } from '../../types'
|
|
||||||
import type { GoogleRoutePreview } from '@trek/shared'
|
|
||||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
||||||
import { Tooltip } from '../shared/Tooltip'
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
|
import { useGoogleRouteImport } from './useGoogleRouteImport'
|
||||||
|
|
||||||
export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; dayId?: number }) {
|
export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; dayId?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const days = useTripStore(s => s.days)
|
const { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit } = useGoogleRouteImport(tripId, dayId)
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [url, setUrl] = useState('')
|
|
||||||
const [preview, setPreview] = useState<GoogleRoutePreview | null>(null)
|
|
||||||
const [target, setTarget] = useState('')
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [failure, setFailure] = useState('')
|
|
||||||
const [saved, setSaved] = useState(false)
|
|
||||||
const controller = useRef<AbortController | null>(null)
|
|
||||||
const requestId = useRef('')
|
|
||||||
const menu = useContextMenu()
|
const menu = useContextMenu()
|
||||||
const labelId = useId()
|
const labelId = useId()
|
||||||
useEffect(() => () => controller.current?.abort(), [])
|
|
||||||
const inspect = async () => {
|
|
||||||
controller.current?.abort()
|
|
||||||
const abort = new AbortController()
|
|
||||||
controller.current = abort
|
|
||||||
setBusy(true); setFailure(''); setPreview(null)
|
|
||||||
try { const parsed = await googleRouteRepo.preview(url, abort.signal); if (!abort.signal.aborted) setPreview(parsed) }
|
|
||||||
catch (error) { if (!abort.signal.aborted) setFailure(getApiErrorMessage(error, t('common.error'))) }
|
|
||||||
finally { if (!abort.signal.aborted) setBusy(false) }
|
|
||||||
}
|
|
||||||
const save = async () => {
|
|
||||||
if (!preview || !target) return
|
|
||||||
setBusy(true); setFailure('')
|
|
||||||
try {
|
|
||||||
const stops = preview.stops.filter(s => s.lat != null && s.lng != null).map(s => ({ name: s.name, lat: s.lat!, lng: s.lng! }))
|
|
||||||
await googleRouteRepo.append(tripId, { dayId: Number(target), stops }, requestId.current)
|
|
||||||
setSaved(true)
|
|
||||||
await useTripStore.getState().loadTrip(tripId)
|
|
||||||
setOpen(false)
|
|
||||||
} catch (error) { setFailure(getApiErrorMessage(error, t('common.error'))) }
|
|
||||||
finally { setBusy(false) }
|
|
||||||
}
|
|
||||||
const show = () => {
|
|
||||||
setUrl(''); setPreview(null); setFailure(''); setSaved(false)
|
|
||||||
setTarget(String(dayId ?? days[0]?.id ?? ''))
|
|
||||||
requestId.current = generateUUID(); setOpen(true)
|
|
||||||
}
|
|
||||||
// Every way out is held while a preview or an import is in flight, as before.
|
|
||||||
const close = () => { if (!busy) setOpen(false) }
|
|
||||||
const resolved = preview?.stops.filter(s => s.lat != null && s.lng != null) ?? []
|
|
||||||
return <>
|
return <>
|
||||||
<Tooltip label={t('roadtrip.import.title')}>
|
<Tooltip label={t('roadtrip.import.title')}>
|
||||||
<button type="button" aria-label={t('roadtrip.import.title')} className="shrink-0 rounded-lg p-2 text-content-muted hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
<button type="button" aria-label={t('roadtrip.import.title')} className="shrink-0 rounded-lg p-2 text-content-muted hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
@@ -84,7 +41,7 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<FooterSpacer />
|
<FooterSpacer />
|
||||||
<DialogButton disabled={busy} onClick={close}>{t('common.cancel')}</DialogButton>
|
<DialogButton disabled={busy} onClick={close}>{t('common.cancel')}</DialogButton>
|
||||||
<DialogButton variant="primary" disabled={busy || saved || (!preview ? !url.trim() : resolved.length < 2 || !target)} onClick={() => void (preview ? save() : inspect())}>
|
<DialogButton variant="primary" disabled={!canSubmit} onClick={submit}>
|
||||||
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
|
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -94,7 +51,7 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d
|
|||||||
className="w-full rounded-[10px] border border-edge bg-surface-input px-3 py-2.5 text-content outline-none placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)] disabled:opacity-50"
|
className="w-full rounded-[10px] border border-edge bg-surface-input px-3 py-2.5 text-content outline-none placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)] disabled:opacity-50"
|
||||||
style={fs(13, 'body')} /> : <>
|
style={fs(13, 'body')} /> : <>
|
||||||
<DialogSection label={t('roadtrip.import.day')}>
|
<DialogSection label={t('roadtrip.import.day')}>
|
||||||
<CustomSelect value={target} onChange={value => { setTarget(String(value)); requestId.current = generateUUID() }}
|
<CustomSelect value={target} onChange={value => pickDay(String(value))}
|
||||||
options={days.map(day => ({ value: String(day.id), label: day.title || `${t('roadtrip.import.day')} ${day.day_number}` }))} disabled={busy || saved} />
|
options={days.map(day => ({ value: String(day.id), label: day.title || `${t('roadtrip.import.day')} ${day.day_number}` }))} disabled={busy || saved} />
|
||||||
</DialogSection>
|
</DialogSection>
|
||||||
<ol className="m-0 flex max-h-64 list-none flex-col gap-2.5 overflow-auto rounded-[14px] border border-edge-faint bg-surface-secondary p-3.5">
|
<ol className="m-0 flex max-h-64 list-none flex-col gap-2.5 overflow-auto rounded-[14px] border border-edge-faint bg-surface-secondary p-3.5">
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { GoogleRoutePreview } from '@trek/shared';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n/TranslationContext';
|
||||||
|
import { googleRouteRepo } from '../../repo/googleRouteRepo';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { generateUUID } from '../../sync/mutationQueue';
|
||||||
|
import { getApiErrorMessage } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A route shared from Google Maps, read and appended to a day of the trip: the logic
|
||||||
|
* behind the desktop's import dialog (GoogleRouteImport) and the large screens' sheet
|
||||||
|
* (MRtRouteImportSheet), so the two cannot drift.
|
||||||
|
*
|
||||||
|
* A link is previewed first, the stops it names in order, and only the ones that resolved
|
||||||
|
* to a place are appended, at least two of them. One request id per opening and per day
|
||||||
|
* picked, so a retried append lands once. Every way out is held while a preview or an
|
||||||
|
* import is in flight.
|
||||||
|
*/
|
||||||
|
export function useGoogleRouteImport(tripId: number, dayId?: number) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const days = useTripStore((s) => s.days);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [preview, setPreview] = useState<GoogleRoutePreview | null>(null);
|
||||||
|
const [target, setTarget] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [failure, setFailure] = useState('');
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const controller = useRef<AbortController | null>(null);
|
||||||
|
const requestId = useRef('');
|
||||||
|
useEffect(() => () => controller.current?.abort(), []);
|
||||||
|
|
||||||
|
const inspect = async () => {
|
||||||
|
controller.current?.abort();
|
||||||
|
const abort = new AbortController();
|
||||||
|
controller.current = abort;
|
||||||
|
setBusy(true);
|
||||||
|
setFailure('');
|
||||||
|
setPreview(null);
|
||||||
|
try {
|
||||||
|
const parsed = await googleRouteRepo.preview(url, abort.signal);
|
||||||
|
if (!abort.signal.aborted) setPreview(parsed);
|
||||||
|
} catch (error) {
|
||||||
|
if (!abort.signal.aborted) setFailure(getApiErrorMessage(error, t('common.error')));
|
||||||
|
} finally {
|
||||||
|
if (!abort.signal.aborted) setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const save = async () => {
|
||||||
|
if (!preview || !target) return;
|
||||||
|
setBusy(true);
|
||||||
|
setFailure('');
|
||||||
|
try {
|
||||||
|
const stops = preview.stops
|
||||||
|
.filter((s) => s.lat != null && s.lng != null)
|
||||||
|
.map((s) => ({ name: s.name, lat: s.lat!, lng: s.lng! }));
|
||||||
|
await googleRouteRepo.append(tripId, { dayId: Number(target), stops }, requestId.current);
|
||||||
|
setSaved(true);
|
||||||
|
await useTripStore.getState().loadTrip(tripId);
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
setFailure(getApiErrorMessage(error, t('common.error')));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const show = () => {
|
||||||
|
setUrl('');
|
||||||
|
setPreview(null);
|
||||||
|
setFailure('');
|
||||||
|
setSaved(false);
|
||||||
|
setTarget(String(dayId ?? days[0]?.id ?? ''));
|
||||||
|
requestId.current = generateUUID();
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const close = () => {
|
||||||
|
if (!busy) setOpen(false);
|
||||||
|
};
|
||||||
|
/** Another day is another append, so it gets a request id of its own. */
|
||||||
|
const pickDay = (value: string) => {
|
||||||
|
setTarget(value);
|
||||||
|
requestId.current = generateUUID();
|
||||||
|
};
|
||||||
|
const resolved = preview?.stops.filter((s) => s.lat != null && s.lng != null) ?? [];
|
||||||
|
const canSubmit = !busy && !saved && (!preview ? !!url.trim() : resolved.length >= 2 && !!target);
|
||||||
|
/** The one button: the preview while there is none, the import once there is. */
|
||||||
|
const submit = () => {
|
||||||
|
void (preview ? save() : inspect());
|
||||||
|
};
|
||||||
|
|
||||||
|
return { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit };
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { NewerBookOverlay } from './SaveIndicator'
|
|||||||
import { StudioNotices } from './StudioNotices'
|
import { StudioNotices } from './StudioNotices'
|
||||||
import '../../styles/dashboard.css'
|
import '../../styles/dashboard.css'
|
||||||
import '../../styles/studio.css'
|
import '../../styles/studio.css'
|
||||||
|
import '../../styles/studio-floating.css'
|
||||||
import './bookFontFaces'
|
import './bookFontFaces'
|
||||||
import { useWarmBookFaces } from './bookFontWarm'
|
import { useWarmBookFaces } from './bookFontWarm'
|
||||||
|
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ describe('the rail', () => {
|
|||||||
const columns = [...STUDIO_CSS.matchAll(/\.st-body \{[^}]*?grid-template-columns:\s*([^;]+);/g)].map((m) => m[1]);
|
const columns = [...STUDIO_CSS.matchAll(/\.st-body \{[^}]*?grid-template-columns:\s*([^;]+);/g)].map((m) => m[1]);
|
||||||
expect(columns).toEqual([
|
expect(columns).toEqual([
|
||||||
'minmax(64px, min-content) 236px minmax(0, 1fr) 300px',
|
'minmax(64px, min-content) 236px minmax(0, 1fr) 300px',
|
||||||
'minmax(64px, min-content) 236px minmax(0, 1fr)',
|
'minmax(64px, min-content) 236px minmax(0, 1fr) 280px',
|
||||||
'minmax(64px, min-content) minmax(0, 1fr)',
|
'minmax(64px, min-content) minmax(0, 1fr)',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { useSettingsStore } from '../../../store/settingsStore'
|
|||||||
import { useTourFerrataCrossingStore } from '../../../store/tourFerrataCrossingStore'
|
import { useTourFerrataCrossingStore } from '../../../store/tourFerrataCrossingStore'
|
||||||
import { formatDistance } from '../../../utils/units'
|
import { formatDistance } from '../../../utils/units'
|
||||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||||
import { NEUTRAL_TINT, fs } from '../../shared/DialogShell'
|
import { fs } from '../../shared/DialogShell'
|
||||||
|
import { useRailHeadProps } from './railHead'
|
||||||
import EmptyState from '../../shared/EmptyState'
|
import EmptyState from '../../shared/EmptyState'
|
||||||
import { useToast } from '../../shared/Toast'
|
import { useToast } from '../../shared/Toast'
|
||||||
import { Tooltip } from '../../shared/Tooltip'
|
import { Tooltip } from '../../shared/Tooltip'
|
||||||
@@ -74,7 +75,6 @@ import { SkiPlannerSummary } from '../ski/SkiRouteSummary'
|
|||||||
import { walkTimeFields } from '../walk/walkTimeFields'
|
import { walkTimeFields } from '../walk/walkTimeFields'
|
||||||
|
|
||||||
/** The tinted head band both rails open with, like every panel head in the planner. */
|
/** The tinted head band both rails open with, like every panel head in the planner. */
|
||||||
export const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5'
|
|
||||||
|
|
||||||
/** The title of a rail's head band, with an optional line under it. */
|
/** The title of a rail's head band, with an optional line under it. */
|
||||||
export function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) {
|
export function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) {
|
||||||
@@ -136,10 +136,11 @@ function NeutralRail({
|
|||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
onOpenWanderer?: () => void
|
onOpenWanderer?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const railHead = useRailHeadProps({ bare: true })
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
|
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
|
||||||
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
|
<div {...railHead}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
@@ -207,6 +208,7 @@ function GpxViewRail({
|
|||||||
paddleChecklist?: PaddleChecklist
|
paddleChecklist?: PaddleChecklist
|
||||||
skiClassification?: SkiClassification
|
skiClassification?: SkiClassification
|
||||||
}) {
|
}) {
|
||||||
|
const railHead = useRailHeadProps()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit)
|
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit)
|
||||||
const { readOnlyGpxTour } = planner
|
const { readOnlyGpxTour } = planner
|
||||||
@@ -226,7 +228,7 @@ function GpxViewRail({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="flex h-full min-h-0 flex-col" aria-label={tour.name}>
|
<section className="flex h-full min-h-0 flex-col" aria-label={tour.name}>
|
||||||
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
|
<div {...railHead}>
|
||||||
<BarButton
|
<BarButton
|
||||||
label={t('tours.planner.backToTours')}
|
label={t('tours.planner.backToTours')}
|
||||||
onClick={() => planner.returnToNeutral({ keepView: true })}
|
onClick={() => planner.returnToNeutral({ keepView: true })}
|
||||||
@@ -328,6 +330,7 @@ export function TourPlannerRail({ planner,
|
|||||||
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
|
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
|
||||||
tourPlaceIds?: ReadonlySet<number>
|
tourPlaceIds?: ReadonlySet<number>
|
||||||
} & TourPermissionProps) {
|
} & TourPermissionProps) {
|
||||||
|
const railHead = useRailHeadProps()
|
||||||
const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
|
const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
||||||
@@ -364,7 +367,7 @@ export function TourPlannerRail({ planner,
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
|
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
|
||||||
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
|
<div {...railHead}>
|
||||||
<BarButton label={t('tours.planner.backToTours')} onClick={() => requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
|
<BarButton label={t('tours.planner.backToTours')} onClick={() => requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
|
||||||
<ArrowLeft size={16} strokeWidth={2} />
|
<ArrowLeft size={16} strokeWidth={2} />
|
||||||
</BarButton>
|
</BarButton>
|
||||||
@@ -673,6 +676,7 @@ function stagesFold(tour: TourListItem, rail: ReturnType<typeof useTourStageRail
|
|||||||
|
|
||||||
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour,
|
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour,
|
||||||
gpxImport, onTakenOver, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
|
gpxImport, onTakenOver, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
|
||||||
|
const railHead = useRailHeadProps()
|
||||||
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
|
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -734,7 +738,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
|
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
|
||||||
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
|
<div {...railHead}>
|
||||||
<RailTitle title={t('tours.planner.tripTours')} />
|
<RailTitle title={t('tours.planner.tripTours')} />
|
||||||
{canEdit && <TourAddMenu planner={planner} gpxImport={gpxImport} onTakenOver={onTakenOver} disabled={isSaving} />}
|
{canEdit && <TourAddMenu planner={planner} gpxImport={gpxImport} onTakenOver={onTakenOver} disabled={isSaving} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { render, screen } from '../../../../tests/helpers/render';
|
||||||
|
import { FloatingRailHeads, RAIL_HEAD, useRailHeadProps } from './railHead';
|
||||||
|
|
||||||
|
function Head() {
|
||||||
|
return <div data-testid="head" {...useRailHeadProps()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rail heads', () => {
|
||||||
|
it('FE-TOUR-RAILHEAD-001: a ruled, tinted strip on the desktop, a rounded card-like band in the touch tab', () => {
|
||||||
|
const { unmount } = render(<Head />);
|
||||||
|
expect(screen.getByTestId('head').className).toBe(RAIL_HEAD);
|
||||||
|
expect(screen.getByTestId('head').getAttribute('style')).toContain('background');
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FloatingRailHeads value>
|
||||||
|
<Head />
|
||||||
|
</FloatingRailHeads>
|
||||||
|
);
|
||||||
|
const head = screen.getByTestId('head');
|
||||||
|
expect(head.className).toContain('rounded-2xl');
|
||||||
|
expect(head.className).not.toContain('border-b');
|
||||||
|
expect(head.getAttribute('style')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-RAILHEAD-002: a head of buttons only stands without a band in the touch tab, and keeps its strip on the desktop', () => {
|
||||||
|
function Bare() {
|
||||||
|
return <div data-testid="bare" {...useRailHeadProps({ bare: true })} />;
|
||||||
|
}
|
||||||
|
const { unmount } = render(<Bare />);
|
||||||
|
expect(screen.getByTestId('bare').className).toBe(RAIL_HEAD);
|
||||||
|
unmount();
|
||||||
|
render(
|
||||||
|
<FloatingRailHeads value>
|
||||||
|
<Bare />
|
||||||
|
</FloatingRailHeads>
|
||||||
|
);
|
||||||
|
const bare = screen.getByTestId('bare');
|
||||||
|
expect(bare.className).not.toMatch(/border|rounded|bg-/);
|
||||||
|
expect(bare.getAttribute('style')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { createContext, use } from 'react';
|
||||||
|
import { NEUTRAL_TINT } from '../../shared/DialogShell';
|
||||||
|
|
||||||
|
/** The head band of a tours rail: ruled off from the rail under it, as the desktop planner shows it. */
|
||||||
|
export const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5';
|
||||||
|
|
||||||
|
/** The head as a rounded band of its own, inset and shaped like the cards under it. */
|
||||||
|
const FLOATING_RAIL_HEAD =
|
||||||
|
'mx-3 mt-3 flex flex-none items-start gap-2 rounded-2xl border border-edge-faint bg-surface-secondary px-3 py-2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set by the touch shell's tours tab: there each rail's head (the trip's tours,
|
||||||
|
* a new or saved tour, a GPX track, the wanderer trails) is a rounded band like
|
||||||
|
* the cards below it rather than a ruled strip across the pane.
|
||||||
|
*/
|
||||||
|
export const FloatingRailHeads = createContext(false);
|
||||||
|
|
||||||
|
/** A head that holds only buttons: in the touch tab they stand on their own, without a band. */
|
||||||
|
const BARE_RAIL_HEAD = 'mx-3 mt-3 flex flex-none items-start gap-2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The class and style of a rail's head band, as the surrounding tab wants it.
|
||||||
|
* `bare`: the head holds only buttons (the editor with nothing open).
|
||||||
|
*/
|
||||||
|
export function useRailHeadProps({ bare = false }: { bare?: boolean } = {}): {
|
||||||
|
className: string;
|
||||||
|
style?: { background: string };
|
||||||
|
} {
|
||||||
|
if (!use(FloatingRailHeads)) return { className: RAIL_HEAD, style: { background: NEUTRAL_TINT } };
|
||||||
|
return { className: bare ? BARE_RAIL_HEAD : FLOATING_RAIL_HEAD };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// FE-COMP-TERRAIN-001 to FE-COMP-TERRAIN-018
|
// FE-COMP-TERRAIN-001 to FE-COMP-TERRAIN-018
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
|
import { act, fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||||
|
import { resetSizeClass, setSizeClass } from '../../../../tests/helpers/sizeClass';
|
||||||
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
|
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
|
||||||
import TourTerrainMap, { type TourTerrainMapProps } from './TourTerrainMap';
|
import TourTerrainMap, { type TourTerrainMapProps } from './TourTerrainMap';
|
||||||
|
|
||||||
@@ -257,6 +258,17 @@ describe('TourTerrainMap', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'Play flyover' })).toBeNull();
|
expect(screen.queryByRole('button', { name: 'Play flyover' })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-TERRAIN-002b: on an iPad or an unfolded foldable the how-to line stays out of the map', async () => {
|
||||||
|
setSizeClass('split');
|
||||||
|
try {
|
||||||
|
await renderTerrain();
|
||||||
|
expect(screen.queryByText('Open a tour on the right to fly it in 3D and colour it by grade.')).toBeNull();
|
||||||
|
expect(screen.queryByText(/Click the terrain to set points/)).toBeNull();
|
||||||
|
} finally {
|
||||||
|
resetSizeClass();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-COMP-TERRAIN-003: a click on the terrain adds a control point only while editing', async () => {
|
it('FE-COMP-TERRAIN-003: a click on the terrain adds a control point only while editing', async () => {
|
||||||
const onAdd = vi.fn();
|
const onAdd = vi.fn();
|
||||||
const { rerender } = await renderTerrain({ waypoints: { points: [], selectedId: null, onSelect: vi.fn(), onAdd } });
|
const { rerender } = await renderTerrain({ waypoints: { points: [], selectedId: null, onSelect: vi.fn(), onAdd } });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FerrataBbox } from '@trek/shared';
|
import type { FerrataBbox } from '@trek/shared';
|
||||||
import { ChevronDown, ChevronUp, Gauge, Palette, Pause, Play, RotateCcw, Scan, TrendingUp } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Gauge, Palette, Pause, Play, RotateCcw, Scan, TrendingUp } from 'lucide-react';
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
|
import { useIsWide } from '../../../hooks/sizeClass';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import type { DistanceUnit } from '../../../types';
|
import type { DistanceUnit } from '../../../types';
|
||||||
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
|
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
|
||||||
@@ -84,11 +85,25 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
|||||||
const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } });
|
const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } });
|
||||||
const stats = useMemo(() => terrainStats(samples), [samples]);
|
const stats = useMemo(() => terrainStats(samples), [samples]);
|
||||||
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
|
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
|
||||||
|
const touchShell = useIsWide();
|
||||||
const legend =
|
const legend =
|
||||||
terrain.canReplay && !props.water ? (
|
terrain.canReplay && !props.water ? (
|
||||||
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} />
|
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} compact={fit === 'side'} />
|
||||||
) : null;
|
) : null;
|
||||||
|
const side = fit === 'side';
|
||||||
const legendAbove = fit === 'stacked';
|
const legendAbove = fit === 'stacked';
|
||||||
|
const layerSwitcher = (
|
||||||
|
<TourMapLayerSwitcher
|
||||||
|
active={baseLayer}
|
||||||
|
onChange={onBaseLayerChange}
|
||||||
|
terrainActive
|
||||||
|
onToggleTerrain={onExit}
|
||||||
|
ferrataLayer={props.ferrataLayer}
|
||||||
|
placeTool={props.placeTool}
|
||||||
|
layers={props.tourLayers}
|
||||||
|
compact={fit !== 'full'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (controlsHeight !== null) onControlsHeightChange?.(controlsHeight);
|
if (controlsHeight !== null) onControlsHeightChange?.(controlsHeight);
|
||||||
@@ -112,35 +127,38 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
|||||||
insetInlineEnd: rightWidth + MAP_LAYER_SWITCHER_INSET,
|
insetInlineEnd: rightWidth + MAP_LAYER_SWITCHER_INSET,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Too narrow for one row (an iPad beside the tour pane): the flyover controls
|
||||||
|
and the grade legend stand on the trailing edge as one column of icons, and
|
||||||
|
the bottom row keeps the layer pill alone. */}
|
||||||
|
{side && (
|
||||||
|
<div className="flex flex-col items-center gap-2" data-testid="terrain-side-column">
|
||||||
|
{legend}
|
||||||
|
{terrain.canReplay && <TerrainDock terrain={terrain} water={props.water} vertical />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<BaseMapCredit layer={baseLayer} />
|
<BaseMapCredit layer={baseLayer} />
|
||||||
{legendAbove && legend}
|
{legendAbove && legend}
|
||||||
<div ref={attachRow} className="flex w-full items-end gap-3" data-fit={fit} data-testid="terrain-control-row">
|
<div ref={attachRow} className="flex w-full items-end gap-3" data-fit={fit} data-testid="terrain-control-row">
|
||||||
<div className="flex-none">
|
<div className="flex-none">{layerSwitcher}</div>
|
||||||
<TourMapLayerSwitcher
|
{!side && (
|
||||||
active={baseLayer}
|
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
|
||||||
onChange={onBaseLayerChange}
|
<span />
|
||||||
terrainActive
|
{terrain.canReplay ? (
|
||||||
onToggleTerrain={onExit}
|
<TerrainDock terrain={terrain} water={props.water} />
|
||||||
ferrataLayer={props.ferrataLayer}
|
) : touchShell ? (
|
||||||
placeTool={props.placeTool}
|
// A tablet's map is short on room, and its user learns the gestures by touch: no how-to line.
|
||||||
layers={props.tourLayers}
|
<span />
|
||||||
compact={fit !== 'full'}
|
) : (
|
||||||
/>
|
<div
|
||||||
</div>
|
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
|
||||||
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
|
style={{ ...GLASS, ...fs(12, 'body') }}
|
||||||
<span />
|
>
|
||||||
{terrain.canReplay ? (
|
<PickHint editing={!!props.waypoints.onAdd} placeOff={props.placeTool?.active === false} />
|
||||||
<TerrainDock terrain={terrain} water={props.water} />
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div
|
<div className="flex justify-end">{!legendAbove && legend}</div>
|
||||||
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
|
</div>
|
||||||
style={{ ...GLASS, ...fs(12, 'body') }}
|
)}
|
||||||
>
|
|
||||||
<PickHint editing={!!props.waypoints.onAdd} placeOff={props.placeTool?.active === false} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-end">{!legendAbove && legend}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,21 +224,89 @@ function IconButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Divider() {
|
function Divider({ vertical = false }: { vertical?: boolean }) {
|
||||||
return <span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />;
|
return <span aria-hidden className={`flex-none bg-edge ${vertical ? 'my-0.5 h-px w-5' : 'mx-0.5 h-5 w-px'}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TerrainControls = Omit<ReturnType<typeof useTourTerrain>, 'setContainer'>;
|
||||||
|
|
||||||
|
/** The relief steps the column's relief button goes through, a tap each. */
|
||||||
|
const RELIEF_STEPS = [1, 1.5, 2, 3] as const;
|
||||||
|
|
||||||
|
/** The next step after `value` in `steps`, round again after the last. */
|
||||||
|
function nextStep<T extends number>(steps: readonly T[], value: number): T {
|
||||||
|
const at = steps.findIndex((step) => step >= value - 0.001);
|
||||||
|
return steps[at < 0 || at === steps.length - 1 ? 0 : at + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The column's half after play, restart and overview: speed and relief a tap each, the grade switch as its icon. */
|
||||||
|
function DockColumnTail({ terrain, water }: { terrain: TerrainControls; water?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const grade = terrain.colorMode === 'grade';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider vertical />
|
||||||
|
<Tooltip label={t('tours.terrain.speed')} placement="left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${t('tours.terrain.speed')}: ${terrain.speed}×`}
|
||||||
|
onClick={() => terrain.setSpeed(nextStep(SPEEDS, terrain.speed))}
|
||||||
|
className="h-[34px] w-[34px] rounded-full font-semibold tabular-nums text-content-muted hover:bg-surface-hover"
|
||||||
|
style={fs(11)}
|
||||||
|
>
|
||||||
|
{terrain.speed}×
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('tours.terrain.exaggeration')} placement="left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${t('tours.terrain.exaggeration')}: ${terrain.exaggeration.toFixed(1)}×`}
|
||||||
|
onClick={() => terrain.setExaggeration(nextStep(RELIEF_STEPS, terrain.exaggeration))}
|
||||||
|
className="flex h-[34px] w-[34px] flex-col items-center justify-center rounded-full font-semibold tabular-nums text-content-muted hover:bg-surface-hover"
|
||||||
|
style={fs(9)}
|
||||||
|
>
|
||||||
|
<TrendingUp size={13} strokeWidth={2} aria-hidden />
|
||||||
|
{terrain.exaggeration.toFixed(1)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{!water && (
|
||||||
|
<>
|
||||||
|
<Divider vertical />
|
||||||
|
<Tooltip label={t('tours.terrain.colorByGrade')} placement="left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('tours.terrain.colorByGrade')}
|
||||||
|
aria-pressed={grade}
|
||||||
|
onClick={() => terrain.setColorMode(grade ? 'tour' : 'grade')}
|
||||||
|
className={`flex h-[34px] w-[34px] items-center justify-center rounded-full ${grade ? 'bg-accent-soft text-accent' : 'text-content-muted hover:bg-surface-hover'}`}
|
||||||
|
>
|
||||||
|
<Palette size={15} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One pill like the layer switcher: play, restart, overview · speed · relief · colour (not on water). */
|
/** One pill like the layer switcher: play, restart, overview · speed · relief · colour (not on water). */
|
||||||
function TerrainDock({
|
function TerrainDock({
|
||||||
terrain,
|
terrain,
|
||||||
water,
|
water,
|
||||||
|
vertical = false,
|
||||||
}: {
|
}: {
|
||||||
terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'>;
|
terrain: TerrainControls;
|
||||||
water?: boolean;
|
water?: boolean;
|
||||||
|
/** A column of icons on the map's trailing edge (the `side` fit). */
|
||||||
|
vertical?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto flex items-center gap-0.5 rounded-full p-1" style={GLASS}>
|
<div
|
||||||
|
className={`pointer-events-auto flex items-center gap-0.5 rounded-full p-1 ${vertical ? 'flex-col' : ''}`}
|
||||||
|
style={GLASS}
|
||||||
|
data-testid="terrain-dock"
|
||||||
|
>
|
||||||
{terrain.preloaded < 1 ? (
|
{terrain.preloaded < 1 ? (
|
||||||
<PreloadRing
|
<PreloadRing
|
||||||
share={terrain.preloaded}
|
share={terrain.preloaded}
|
||||||
@@ -241,6 +327,16 @@ function TerrainDock({
|
|||||||
<IconButton label={t('tours.terrain.overview')} onClick={terrain.overview}>
|
<IconButton label={t('tours.terrain.overview')} onClick={terrain.overview}>
|
||||||
<Scan size={15} strokeWidth={2} />
|
<Scan size={15} strokeWidth={2} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{vertical ? <DockColumnTail terrain={terrain} water={water} /> : <DockRowTail terrain={terrain} water={water} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row's half after play, restart and overview: the speeds, the relief slider and the grade switch. */
|
||||||
|
function DockRowTail({ terrain, water }: { terrain: TerrainControls; water?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className="flex items-center" role="group" aria-label={t('tours.terrain.speed')}>
|
<div className="flex items-center" role="group" aria-label={t('tours.terrain.speed')}>
|
||||||
{SPEEDS.map((speed) => (
|
{SPEEDS.map((speed) => (
|
||||||
@@ -275,7 +371,7 @@ function TerrainDock({
|
|||||||
</label>
|
</label>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{!water && <GradeSwitch terrain={terrain} />}
|
{!water && <GradeSwitch terrain={terrain} />}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,14 +432,42 @@ function PreloadRing({ share, label }: { share: number; label: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The grade mix as a pill like the dock; it unfolds upwards into the bands. */
|
/** The grade mix as a pill like the dock; it unfolds upwards into the bands. */
|
||||||
|
/** The highest point and the steepest grade, as the legend's soft pills. */
|
||||||
|
function LegendFacts({ stats, unit }: { stats: TerrainStats; unit: DistanceUnit }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{stats.summit && (
|
||||||
|
<SoftPill icon={<TrendingUp size={11} strokeWidth={2} className="flex-none" />}>
|
||||||
|
{formatElevation(stats.summit.elevationMeters, unit)}
|
||||||
|
</SoftPill>
|
||||||
|
)}
|
||||||
|
{stats.steepest && (
|
||||||
|
<SoftPill
|
||||||
|
tone={stats.steepest.grade >= 27 ? 'danger' : stats.steepest.grade >= 18 ? 'warning' : 'neutral'}
|
||||||
|
icon={<Gauge size={11} strokeWidth={2} className="flex-none" />}
|
||||||
|
>
|
||||||
|
{t('tours.terrain.steepestShort', { grade: Math.round(stats.steepest.grade) })}
|
||||||
|
</SoftPill>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `compact` (the map's side, on a narrow 3D map): the bar and the chevron
|
||||||
|
* only; the highest point and the steepest grade move into the opened card.
|
||||||
|
*/
|
||||||
function GradeLegend({
|
function GradeLegend({
|
||||||
stats,
|
stats,
|
||||||
unit,
|
unit,
|
||||||
onLayout,
|
onLayout,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
stats: TerrainStats;
|
stats: TerrainStats;
|
||||||
unit: DistanceUnit;
|
unit: DistanceUnit;
|
||||||
onLayout?: (layout: TerrainLegendLayout | null) => void;
|
onLayout?: (layout: TerrainLegendLayout | null) => void;
|
||||||
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -373,9 +497,14 @@ function GradeLegend({
|
|||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
id="tour-terrain-grades"
|
id="tour-terrain-grades"
|
||||||
className="absolute inset-x-0 bottom-full mb-2 rounded-2xl p-3"
|
className={`absolute rounded-2xl p-3 ${compact ? 'bottom-0 end-full me-2 w-[240px]' : 'inset-x-0 bottom-full mb-2'}`}
|
||||||
style={GLASS}
|
style={GLASS}
|
||||||
>
|
>
|
||||||
|
{compact && (
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||||
|
<LegendFacts stats={stats} unit={unit} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{GRADE_BANDS.map((band) => (
|
{GRADE_BANDS.map((band) => (
|
||||||
<li key={band.key} className="flex items-center gap-2 text-content-secondary" style={fs(11)}>
|
<li key={band.key} className="flex items-center gap-2 text-content-secondary" style={fs(11)}>
|
||||||
@@ -398,27 +527,29 @@ function GradeLegend({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls="tour-terrain-grades"
|
aria-controls="tour-terrain-grades"
|
||||||
aria-label={t('tours.terrain.gradeMix')}
|
aria-label={t('tours.terrain.gradeMix')}
|
||||||
className="flex h-[42px] items-center gap-2 rounded-full pe-2 ps-3"
|
className={
|
||||||
|
compact
|
||||||
|
? 'flex w-[42px] flex-col items-center gap-2 rounded-full pb-1 pt-3'
|
||||||
|
: 'flex h-[42px] items-center gap-2 rounded-full pe-2 ps-3'
|
||||||
|
}
|
||||||
style={GLASS}
|
style={GLASS}
|
||||||
>
|
>
|
||||||
<span className="flex h-1.5 w-14 overflow-hidden rounded-full bg-surface-hover" aria-hidden>
|
<span
|
||||||
|
className={`flex overflow-hidden rounded-full bg-surface-hover ${compact ? 'h-14 w-1.5 flex-col' : 'h-1.5 w-14'}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
{GRADE_BANDS.map((band) => (
|
{GRADE_BANDS.map((band) => (
|
||||||
<span key={band.key} style={{ width: width(band.key), background: band.paint }} />
|
<span
|
||||||
|
key={band.key}
|
||||||
|
style={
|
||||||
|
compact
|
||||||
|
? { height: width(band.key), background: band.paint }
|
||||||
|
: { width: width(band.key), background: band.paint }
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
{stats.summit && (
|
{!compact && <LegendFacts stats={stats} unit={unit} />}
|
||||||
<SoftPill icon={<TrendingUp size={11} strokeWidth={2} className="flex-none" />}>
|
|
||||||
{formatElevation(stats.summit.elevationMeters, unit)}
|
|
||||||
</SoftPill>
|
|
||||||
)}
|
|
||||||
{stats.steepest && (
|
|
||||||
<SoftPill
|
|
||||||
tone={stats.steepest.grade >= 27 ? 'danger' : stats.steepest.grade >= 18 ? 'warning' : 'neutral'}
|
|
||||||
icon={<Gauge size={11} strokeWidth={2} className="flex-none" />}
|
|
||||||
>
|
|
||||||
{t('tours.terrain.steepestShort', { grade: Math.round(stats.steepest.grade) })}
|
|
||||||
</SoftPill>
|
|
||||||
)}
|
|
||||||
<span className="flex h-[26px] w-[26px] items-center justify-center rounded-full text-content-muted">
|
<span className="flex h-[26px] w-[26px] items-center justify-center rounded-full text-content-muted">
|
||||||
{open ? <ChevronDown size={14} strokeWidth={2.2} /> : <ChevronUp size={14} strokeWidth={2.2} />}
|
{open ? <ChevronDown size={14} strokeWidth={2.2} /> : <ChevronUp size={14} strokeWidth={2.2} />}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// FE-TERRAIN-FIT-001 to FE-TERRAIN-FIT-002: the 3D map's bottom row steps down to fit between the panels.
|
// FE-TERRAIN-FIT-001 to FE-TERRAIN-FIT-003: the 3D map's bottom row steps down to fit between the panels.
|
||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { type ControlRowFit, useControlRowFit } from './useControlRowFit';
|
import { type ControlRowFit, useControlRowFit } from './useControlRowFit';
|
||||||
|
|
||||||
/** What the row's content needs at each step, in pixels. */
|
/** What the row's content needs at each step, in pixels. */
|
||||||
const NEEDS: Record<ControlRowFit, number> = { full: 700, compact: 480, stacked: 400 };
|
const NEEDS: Record<ControlRowFit, number> = { full: 700, compact: 480, stacked: 400, side: 260 };
|
||||||
|
|
||||||
function fakeRow(room: { width: number }, fit: () => ControlRowFit): HTMLDivElement {
|
function fakeRow(room: { width: number }, fit: () => ControlRowFit): HTMLDivElement {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
@@ -49,4 +49,21 @@ describe('useControlRowFit', () => {
|
|||||||
rerender();
|
rerender();
|
||||||
expect(result.current.fit).toBe('full');
|
expect(result.current.fit).toBe('full');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-TERRAIN-FIT-003: where even the stacked row overflows, the layer pill goes to the side, and comes back with the room', () => {
|
||||||
|
const room = { width: 300 };
|
||||||
|
const { result, rerender } = renderHook(() => useControlRowFit());
|
||||||
|
const row = fakeRow(room, () => result.current.fit);
|
||||||
|
act(() => result.current.attachRow(row));
|
||||||
|
rerender();
|
||||||
|
rerender();
|
||||||
|
expect(result.current.fit).toBe('side');
|
||||||
|
|
||||||
|
room.width = 399;
|
||||||
|
rerender();
|
||||||
|
expect(result.current.fit).toBe('side');
|
||||||
|
room.width = 400;
|
||||||
|
rerender();
|
||||||
|
expect(result.current.fit).toBe('stacked');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* How the 3D map's bottom row fits between the side panels: `full` with every
|
* How the 3D map's bottom row fits between the side panels: `full` with every
|
||||||
* label, `compact` with the layer pill on its icons, and `stacked` with the
|
* label, `compact` with the layer pill on its icons, `stacked` with the grade
|
||||||
* grade legend on a row of its own above the dock as well.
|
* legend on a row of its own above the dock as well, and `side` with the layer
|
||||||
|
* pill standing on the map's trailing edge, leaving the row to the dock (a map
|
||||||
|
* as narrow as the iPad's beside the tour pane).
|
||||||
*/
|
*/
|
||||||
export type ControlRowFit = 'full' | 'compact' | 'stacked';
|
export type ControlRowFit = 'full' | 'compact' | 'stacked' | 'side';
|
||||||
|
|
||||||
const STEPS: readonly ControlRowFit[] = ['full', 'compact', 'stacked'];
|
const STEPS: readonly ControlRowFit[] = ['full', 'compact', 'stacked', 'side'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Steps the row down while its content is wider than the room between the
|
* Steps the row down while its content is wider than the room between the
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ import { useSettingsStore } from '../../../store/settingsStore';
|
|||||||
import { formatDisplayDate } from '../../../utils/dateFormat';
|
import { formatDisplayDate } from '../../../utils/dateFormat';
|
||||||
import { formatDistance, formatElevation } from '../../../utils/units';
|
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||||
import { BarButton, WhiteBadge } from '../../Planner/planParts';
|
import { BarButton, WhiteBadge } from '../../Planner/planParts';
|
||||||
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell';
|
import { DialogButton, fs } from '../../shared/DialogShell';
|
||||||
import EmptyState from '../../shared/EmptyState';
|
import EmptyState from '../../shared/EmptyState';
|
||||||
import { Tooltip } from '../../shared/Tooltip';
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
import { RAIL_HEAD, RailTitle } from '../planner/TourPlannerPanels';
|
import { useRailHeadProps } from '../planner/railHead';
|
||||||
|
import { RailTitle } from '../planner/TourPlannerPanels';
|
||||||
import { TourDayRowAvatar } from '../TourDayRowFacts';
|
import { TourDayRowAvatar } from '../TourDayRowFacts';
|
||||||
import { TourListCard, TourMetricFields, TourNotice, TourSection } from '../tourParts';
|
import { TourListCard, TourMetricFields, TourNotice, TourSection } from '../tourParts';
|
||||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||||
@@ -40,11 +41,12 @@ import WandererMark from './WandererMark';
|
|||||||
* The trail itself shows on the map between the rails.
|
* The trail itself shows on the map between the rails.
|
||||||
*/
|
*/
|
||||||
export default function WandererBrowserRail({ browser }: { browser: WandererBrowser }) {
|
export default function WandererBrowserRail({ browser }: { browser: WandererBrowser }) {
|
||||||
|
const railHead = useRailHeadProps();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const inPreview = browser.selected !== null;
|
const inPreview = browser.selected !== null;
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full min-h-0 flex-col" aria-label={t('wanderer.browser.title')}>
|
<section className="flex h-full min-h-0 flex-col" aria-label={t('wanderer.browser.title')}>
|
||||||
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
|
<div {...railHead}>
|
||||||
<BarButton
|
<BarButton
|
||||||
label={inPreview ? t('wanderer.browser.backToList') : t('tours.planner.backToTours')}
|
label={inPreview ? t('wanderer.browser.backToList') : t('tours.planner.backToTours')}
|
||||||
onClick={inPreview ? browser.backToList : browser.close}
|
onClick={inPreview ? browser.backToList : browser.close}
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ describe('Skeleton', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('composed skeletons', () => {
|
describe('composed skeletons', () => {
|
||||||
it('FE-W4SKEL-006: SpotlightSkeleton renders the hero backdrop plus two bars', () => {
|
it('FE-W4SKEL-006: SpotlightSkeleton takes the box of the hero and renders the backdrop plus two bars', () => {
|
||||||
const { container } = render(<SpotlightSkeleton />)
|
const { container } = render(<SpotlightSkeleton />)
|
||||||
|
|
||||||
expect(container.querySelectorAll('.trek-skeleton')).toHaveLength(3)
|
expect(container.querySelectorAll('.trek-skeleton')).toHaveLength(3)
|
||||||
expect((container.firstElementChild as HTMLElement).style.minHeight).toBe('340px')
|
expect(container.firstElementChild).toHaveClass('hero-trip', 'hero-trip-pending')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-W4SKEL-007: TripCardSkeleton renders a cover plus two text bars', () => {
|
it('FE-W4SKEL-007: TripCardSkeleton renders a cover plus two text bars', () => {
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ export function Skeleton({
|
|||||||
|
|
||||||
// Trip card skeleton matching SpotlightCard layout
|
// Trip card skeleton matching SpotlightCard layout
|
||||||
export function SpotlightSkeleton(): React.ReactElement {
|
export function SpotlightSkeleton(): React.ReactElement {
|
||||||
|
// The dashboard hero's own box (.hero-trip: 520px on desktop, 340px on a
|
||||||
|
// phone, and its margin), so the hero takes the place it held while loading
|
||||||
|
// and nothing under it moves when it arrives (#3009).
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="hero-trip hero-trip-pending" aria-hidden>
|
||||||
className="relative rounded-3xl overflow-hidden mb-8 bg-surface-tertiary"
|
<div className="trek-skeleton absolute inset-0" />
|
||||||
style={{ minHeight: 340 }}
|
<div className="relative flex h-full flex-col justify-end p-6">
|
||||||
>
|
|
||||||
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
|
|
||||||
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
|
|
||||||
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
|
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
|
||||||
<Skeleton width={220} height={16} radius={4} />
|
<Skeleton width={220} height={16} radius={4} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -223,4 +223,20 @@ describe('Tooltip — with content', () => {
|
|||||||
expect(tip.querySelectorAll('li')).toHaveLength(2)
|
expect(tip.querySelectorAll('li')).toHaveLength(2)
|
||||||
expect(tip).not.toHaveTextContent('brought by')
|
expect(tip).not.toHaveTextContent('brought by')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-W4TIP-017: a tap opens nothing, a pointer that comes along later still does', () => {
|
||||||
|
render(<Tooltip label="Add a tour" delay={0}><button>+</button></Tooltip>)
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
|
||||||
|
// The browser follows a tap with a mouseenter and never sends the mouseleave.
|
||||||
|
fireEvent.touchStart(button)
|
||||||
|
fireEvent.mouseEnter(button)
|
||||||
|
act(() => { vi.advanceTimersByTime(300) })
|
||||||
|
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||||
|
|
||||||
|
act(() => { vi.advanceTimersByTime(2000) })
|
||||||
|
fireEvent.mouseEnter(button)
|
||||||
|
act(() => { vi.advanceTimersByTime(10) })
|
||||||
|
expect(screen.getByRole('tooltip')).toHaveTextContent('Add a tour')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,12 +19,22 @@ interface TooltipProps {
|
|||||||
children: React.ReactElement
|
children: React.ReactElement
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The emulated mouse events of a tap follow it within a few hundred milliseconds. */
|
||||||
|
const TAP_MOUSE_WINDOW_MS = 1000
|
||||||
|
|
||||||
|
function touchedRecently(touchedAt: number, now: number): boolean {
|
||||||
|
return touchedAt > 0 && now - touchedAt < TAP_MOUSE_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, shortcut, content, children }: TooltipProps) {
|
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, shortcut, content, children }: TooltipProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
|
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
|
||||||
const triggerRef = useRef<HTMLElement | null>(null)
|
const triggerRef = useRef<HTMLElement | null>(null)
|
||||||
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
||||||
const timerRef = useRef<number | null>(null)
|
const timerRef = useRef<number | null>(null)
|
||||||
|
// When a finger last touched the trigger. A tap makes the browser send a mouseenter
|
||||||
|
// after it and never the mouseleave, so the tip would open on a tap and stay.
|
||||||
|
const touchedAtRef = useRef(0)
|
||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
if (disabled || !label) return
|
if (disabled || !label) return
|
||||||
@@ -60,6 +70,7 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, sh
|
|||||||
type TriggerProps = {
|
type TriggerProps = {
|
||||||
ref?: React.Ref<HTMLElement>
|
ref?: React.Ref<HTMLElement>
|
||||||
onMouseEnter?: (e: React.MouseEvent) => void
|
onMouseEnter?: (e: React.MouseEvent) => void
|
||||||
|
onTouchStart?: (e: React.TouchEvent) => void
|
||||||
onMouseLeave?: (e: React.MouseEvent) => void
|
onMouseLeave?: (e: React.MouseEvent) => void
|
||||||
onFocus?: (e: React.FocusEvent) => void
|
onFocus?: (e: React.FocusEvent) => void
|
||||||
onBlur?: (e: React.FocusEvent) => void
|
onBlur?: (e: React.FocusEvent) => void
|
||||||
@@ -73,7 +84,9 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, sh
|
|||||||
if (typeof r === 'function') r(node)
|
if (typeof r === 'function') r(node)
|
||||||
else if (r && typeof r === 'object') (r as React.RefObject<HTMLElement | null>).current = node
|
else if (r && typeof r === 'object') (r as React.RefObject<HTMLElement | null>).current = node
|
||||||
},
|
},
|
||||||
onMouseEnter: (e: React.MouseEvent) => { show(); childProps.onMouseEnter?.(e) },
|
onTouchStart: (e: React.TouchEvent) => { touchedAtRef.current = Date.now(); hide(); childProps.onTouchStart?.(e) },
|
||||||
|
// A touch screen has no hover to hint at: only a real pointer opens the tip.
|
||||||
|
onMouseEnter: (e: React.MouseEvent) => { if (!touchedRecently(touchedAtRef.current, Date.now())) show(); childProps.onMouseEnter?.(e) },
|
||||||
onMouseLeave: (e: React.MouseEvent) => { hide(); childProps.onMouseLeave?.(e) },
|
onMouseLeave: (e: React.MouseEvent) => { hide(); childProps.onMouseLeave?.(e) },
|
||||||
// Keyboard focus only. A dialog hands focus back to the button that opened it
|
// Keyboard focus only. A dialog hands focus back to the button that opened it
|
||||||
// when it closes, and a tooltip over whatever comes next is noise, not help.
|
// when it closes, and a tooltip over whatever comes next is noise, not help.
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
// FE-HOOK-KBINSET-001 to FE-HOOK-KBINSET-004
|
// FE-HOOK-KBINSET-001 to FE-HOOK-KBINSET-006
|
||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
import { useKeyboardInset } from './useKeyboardInset';
|
import { _resetNativeKeyboard, useKeyboardInset } from './useKeyboardInset';
|
||||||
|
|
||||||
|
const native = vi.hoisted(() => ({
|
||||||
|
app: false,
|
||||||
|
listeners: new Map<string, (info: { keyboardHeight: number }) => void>(),
|
||||||
|
}));
|
||||||
|
vi.mock('../native/platform', () => ({ isNativeApp: () => native.app }));
|
||||||
|
vi.mock('@capacitor/keyboard', () => ({
|
||||||
|
Keyboard: {
|
||||||
|
addListener: (event: string, listener: (info: { keyboardHeight: number }) => void) => {
|
||||||
|
native.listeners.set(event, listener);
|
||||||
|
return Promise.resolve({ remove: () => Promise.resolve() });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
class FakeVisualViewport extends EventTarget {
|
class FakeVisualViewport extends EventTarget {
|
||||||
height = 800;
|
height = 800;
|
||||||
@@ -18,6 +32,9 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
_resetNativeKeyboard();
|
||||||
|
native.app = false;
|
||||||
|
native.listeners.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
function keyboard(height: number, offsetTop = 0) {
|
function keyboard(height: number, offsetTop = 0) {
|
||||||
@@ -57,4 +74,18 @@ describe('useKeyboardInset', () => {
|
|||||||
const { result } = renderHook(() => useKeyboardInset());
|
const { result } = renderHook(() => useKeyboardInset());
|
||||||
expect(result.current).toBe(0);
|
expect(result.current).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-HOOK-KBINSET-005: in the app, the keyboard the app reports counts, though the viewport did not shrink', () => {
|
||||||
|
native.app = true;
|
||||||
|
const { result } = renderHook(() => useKeyboardInset());
|
||||||
|
act(() => native.listeners.get('keyboardWillShow')?.({ keyboardHeight: 336.4 }));
|
||||||
|
expect(result.current).toBe(336);
|
||||||
|
act(() => native.listeners.get('keyboardWillHide')?.({ keyboardHeight: 0 }));
|
||||||
|
expect(result.current).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-HOOK-KBINSET-006: in a browser nothing listens for the app keyboard', () => {
|
||||||
|
renderHook(() => useKeyboardInset());
|
||||||
|
expect(native.listeners.size).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,29 +1,65 @@
|
|||||||
|
import { Keyboard } from '@capacitor/keyboard';
|
||||||
import { useSyncExternalStore } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { isNativeApp } from '../native/platform';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keyboard's height as the app reports it (@capacitor/keyboard). The app
|
||||||
|
* resizes the body for the keyboard (KeyboardResize.Body in capacitor.config),
|
||||||
|
* which leaves the visual viewport as it was and does nothing for a screen built
|
||||||
|
* of fixed layers, as the trip shell is, so there only the app can say how much
|
||||||
|
* the keyboard covers.
|
||||||
|
*/
|
||||||
|
let nativeHeight = 0;
|
||||||
|
let followingNative = false;
|
||||||
|
const nativeListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function setNativeHeight(height: number): void {
|
||||||
|
nativeHeight = Math.max(0, Math.round(height));
|
||||||
|
for (const listener of nativeListeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
function followNativeKeyboard(): void {
|
||||||
|
if (followingNative || !isNativeApp()) return;
|
||||||
|
followingNative = true;
|
||||||
|
void Keyboard.addListener('keyboardWillShow', ({ keyboardHeight }) => setNativeHeight(keyboardHeight));
|
||||||
|
void Keyboard.addListener('keyboardWillHide', () => setNativeHeight(0));
|
||||||
|
}
|
||||||
|
|
||||||
/** How far the on-screen keyboard reaches into the layout viewport, in px; 0 when none is up. */
|
/** How far the on-screen keyboard reaches into the layout viewport, in px; 0 when none is up. */
|
||||||
function readInset(): number {
|
function readInset(): number {
|
||||||
const viewport = window.visualViewport;
|
const viewport = window.visualViewport;
|
||||||
if (!viewport) return 0;
|
const fromViewport = viewport
|
||||||
return Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop));
|
? Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop))
|
||||||
|
: 0;
|
||||||
|
return Math.max(fromViewport, nativeHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribe(onChange: () => void): () => void {
|
function subscribe(onChange: () => void): () => void {
|
||||||
|
followNativeKeyboard();
|
||||||
|
nativeListeners.add(onChange);
|
||||||
const viewport = window.visualViewport;
|
const viewport = window.visualViewport;
|
||||||
if (!viewport) return () => undefined;
|
viewport?.addEventListener('resize', onChange);
|
||||||
viewport.addEventListener('resize', onChange);
|
viewport?.addEventListener('scroll', onChange);
|
||||||
viewport.addEventListener('scroll', onChange);
|
|
||||||
return () => {
|
return () => {
|
||||||
viewport.removeEventListener('resize', onChange);
|
nativeListeners.delete(onChange);
|
||||||
viewport.removeEventListener('scroll', onChange);
|
viewport?.removeEventListener('resize', onChange);
|
||||||
|
viewport?.removeEventListener('scroll', onChange);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The height the phone's keyboard covers at the bottom of the screen, read
|
* The height the phone's keyboard covers at the bottom of the screen, read
|
||||||
* off the visual viewport. A full-screen view pads its bottom by it, so a
|
* off the visual viewport, and in the app off the keyboard itself. A
|
||||||
* composer at the foot stays above the keys instead of under them: iOS
|
* full-screen view pads its bottom by it, so a composer at the foot stays above
|
||||||
* Safari lays the keyboard over the page without resizing it.
|
* the keys instead of under them: iOS Safari lays the keyboard over the page
|
||||||
|
* without resizing it, and the app resizes only the body.
|
||||||
*/
|
*/
|
||||||
export function useKeyboardInset(): number {
|
export function useKeyboardInset(): number {
|
||||||
return useSyncExternalStore(subscribe, readInset, () => 0);
|
return useSyncExternalStore(subscribe, readInset, () => 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test helper: forgets the keyboard the app reported and lets it be followed afresh. */
|
||||||
|
export function _resetNativeKeyboard(): void {
|
||||||
|
nativeHeight = 0;
|
||||||
|
followingNative = false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1290,7 +1290,6 @@ img[alt="TREK"] {
|
|||||||
|
|
||||||
/* ── Dark-Mode: Tailwind-Klassen ─────────────── */
|
/* ── Dark-Mode: Tailwind-Klassen ─────────────── */
|
||||||
.dark .bg-slate-50 { background-color: #131316 !important; }
|
.dark .bg-slate-50 { background-color: #131316 !important; }
|
||||||
.dark .bg-slate-100 { background-color: #1c1c21 !important; }
|
|
||||||
.dark .bg-white { background-color: #09090b !important; }
|
.dark .bg-white { background-color: #09090b !important; }
|
||||||
.dark .bg-gray-50 { background-color: #131316 !important; }
|
.dark .bg-gray-50 { background-color: #131316 !important; }
|
||||||
.dark .bg-gray-100 { background-color: #1c1c21 !important; }
|
.dark .bg-gray-100 { background-color: #1c1c21 !important; }
|
||||||
@@ -1307,10 +1306,7 @@ img[alt="TREK"] {
|
|||||||
.dark .bg-slate-100 { background-color: #27272a !important; }
|
.dark .bg-slate-100 { background-color: #27272a !important; }
|
||||||
.dark .bg-slate-900 { background-color: #e4e4e7 !important; color: #09090b !important; }
|
.dark .bg-slate-900 { background-color: #e4e4e7 !important; color: #09090b !important; }
|
||||||
.dark .hover\:bg-slate-700:hover { background-color: #d4d4d8 !important; color: #09090b !important; }
|
.dark .hover\:bg-slate-700:hover { background-color: #d4d4d8 !important; color: #09090b !important; }
|
||||||
/* Not inside the touch shell (.m-root): its fields state their own fill, a
|
/* Not in the touch shell (.m-root), whose fields set their own fill; :where() keeps the desktop's weight. */
|
||||||
transparent input inside a rounded wrapper above all, and this rule outweighs
|
|
||||||
every utility class, so it painted a dark box inside each of them. The
|
|
||||||
:where() keeps the desktop's weight exactly as it was. */
|
|
||||||
.dark input:not(:where(.m-root *)), .dark textarea:not(:where(.m-root *)), .dark select:not(:where(.m-root *)) {
|
.dark input:not(:where(.m-root *)), .dark textarea:not(:where(.m-root *)), .dark select:not(:where(.m-root *)) {
|
||||||
background-color: #1c1c21;
|
background-color: #1c1c21;
|
||||||
color: #f4f4f5;
|
color: #f4f4f5;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { maybeInstallTouchDragPolyfill } from './utils/touchDragPolyfill'
|
|||||||
import { startConnectivityProbe } from './sync/connectivity'
|
import { startConnectivityProbe } from './sync/connectivity'
|
||||||
import { startNativeApp } from './native/nativeApp'
|
import { startNativeApp } from './native/nativeApp'
|
||||||
import { requestPersistentStorage } from './sync/persistentStorage'
|
import { requestPersistentStorage } from './sync/persistentStorage'
|
||||||
|
import { isIosDevice } from './utils/iosDevice'
|
||||||
import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary'
|
import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary'
|
||||||
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers'
|
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers'
|
||||||
import { schedulePrecacheRepair } from './utils/serviceWorkerShell'
|
import { schedulePrecacheRepair } from './utils/serviceWorkerShell'
|
||||||
@@ -52,6 +53,9 @@ startConnectivityProbe()
|
|||||||
startNativeApp()
|
startNativeApp()
|
||||||
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
|
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
|
||||||
void requestPersistentStorage()
|
void requestPersistentStorage()
|
||||||
|
// A tile cache past the iOS budget slows every cold start of the home screen app (#3008).
|
||||||
|
// Loaded on iOS only, so the prefetcher stays out of everyone's first chunk.
|
||||||
|
if (isIosDevice()) void import('./sync/tilePrefetcher').then((m) => m.scheduleTileCacheTrim())
|
||||||
// Event handlers and async code never reach a boundary; this is where they land.
|
// Event handlers and async code never reach a boundary; this is where they land.
|
||||||
installGlobalErrorHandlers()
|
installGlobalErrorHandlers()
|
||||||
// A page the precache keeps under a stylesheet's or a script's name would be
|
// A page the precache keeps under a stylesheet's or a script's name would be
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { useEffect, useState, type ReactElement } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { lockBodyScroll } from '../../utils/bodyScrollLock';
|
||||||
|
|
||||||
|
function sheetRoot(): HTMLElement {
|
||||||
|
return document.getElementById('m-sheet-root') ?? document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One image full screen, for a picture shown small in a list (a chat
|
||||||
|
* attachment). A tap on the image zooms in, and the zoomed image pans with the
|
||||||
|
* finger as any scrolled page does; a tap beside it or the X closes the view,
|
||||||
|
* as Escape does with a keyboard. The page's own pinch zoom is off in the app,
|
||||||
|
* so the zoom is the viewer's.
|
||||||
|
*/
|
||||||
|
export default function MImageViewer({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [zoomed, setZoomed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => lockBodyScroll(), []);
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={alt}
|
||||||
|
className="m-root fixed inset-0 z-[var(--z-overlay)] bg-[color:var(--m-viewer-bg)]"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('collab.chat.closeImage')}
|
||||||
|
className="absolute end-4 top-[calc(var(--m-safe-top,12px)+4px)] z-10 flex h-10 w-10 items-center justify-center rounded-full bg-[color:var(--m-viewer-btn)] text-[color:var(--m-viewer-ink)]"
|
||||||
|
>
|
||||||
|
<X size={20} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={`h-full w-full ${zoomed ? 'overflow-auto' : 'flex items-center justify-center overflow-hidden p-4'}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setZoomed((z) => !z);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
zoomed ? 'block w-[250%] max-w-none cursor-zoom-out' : 'max-h-full max-w-full cursor-zoom-in object-contain'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
sheetRoot()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { act } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import MPushPrompt, { PUSH_PROMPT_DELAY_MS, PUSH_PROMPT_KEY } from './MPushPrompt';
|
||||||
|
|
||||||
|
// FE-MOB-PUSHPROMPT-001 onwards: the app asks for notifications by itself, once.
|
||||||
|
|
||||||
|
const env = vi.hoisted(() => ({
|
||||||
|
native: true,
|
||||||
|
endpoint: null as string | null,
|
||||||
|
offered: true,
|
||||||
|
on: false,
|
||||||
|
canSwitch: true,
|
||||||
|
toggle: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../../native/push', () => ({
|
||||||
|
nativePushActive: () => env.native,
|
||||||
|
nativePushEndpoint: () => env.endpoint,
|
||||||
|
}));
|
||||||
|
vi.mock('../../push/webPush', () => ({ pushChannelOffered: () => Promise.resolve(env.offered) }));
|
||||||
|
vi.mock('../../hooks/useWebPush', () => ({
|
||||||
|
useWebPush: () => ({ on: env.on, canSwitch: env.canSwitch, busy: false, toggle: env.toggle }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function renderAndWait() {
|
||||||
|
render(<MPushPrompt />);
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(PUSH_PROMPT_DELAY_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MPushPrompt', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
window.localStorage.removeItem(PUSH_PROMPT_KEY);
|
||||||
|
Object.assign(env, { native: true, endpoint: null, offered: true, on: false, canSwitch: true });
|
||||||
|
env.toggle.mockClear();
|
||||||
|
useAuthStore.setState({ isAuthenticated: true } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-MOB-PUSHPROMPT-001: a moment after signing in the app asks, and turning it on goes the settings card way', async () => {
|
||||||
|
await renderAndWait();
|
||||||
|
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
|
||||||
|
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).not.toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Turn on' }));
|
||||||
|
expect(env.toggle).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-MOB-PUSHPROMPT-002: later closes it, and it is not asked again', async () => {
|
||||||
|
await renderAndWait();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Not now' }));
|
||||||
|
expect(env.toggle).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
render(<MPushPrompt />);
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(PUSH_PROMPT_DELAY_MS);
|
||||||
|
});
|
||||||
|
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['in a browser', { native: false }],
|
||||||
|
['where the server offers no push', { offered: false }],
|
||||||
|
['on a device that is signed up already', { endpoint: 'https://push.liketrek.com/v1/apns/x' }],
|
||||||
|
])('FE-MOB-PUSHPROMPT-003: it does not ask %s', async (_case, over) => {
|
||||||
|
Object.assign(env, over);
|
||||||
|
await renderAndWait();
|
||||||
|
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
|
||||||
|
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-MOB-PUSHPROMPT-004: signed out, it waits', async () => {
|
||||||
|
useAuthStore.setState({ isAuthenticated: false } as never);
|
||||||
|
await renderAndWait();
|
||||||
|
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { BellRing } from 'lucide-react';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useWebPush } from '../../hooks/useWebPush';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { nativePushActive, nativePushEndpoint } from '../../native/push';
|
||||||
|
import { pushChannelOffered } from '../../push/webPush';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import MSheet from './MSheet';
|
||||||
|
|
||||||
|
/** Asked once per device; the settings' push card is where it is changed after. */
|
||||||
|
export const PUSH_PROMPT_KEY = 'trek.pushPromptShown';
|
||||||
|
/** Long enough that the app has opened and the user sees where they are first. */
|
||||||
|
export const PUSH_PROMPT_DELAY_MS = 2500;
|
||||||
|
|
||||||
|
function alreadyAsked(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(PUSH_PROMPT_KEY) != null;
|
||||||
|
} catch {
|
||||||
|
// Without storage it would ask on every launch: better not at all.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberAsked(): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(PUSH_PROMPT_KEY, new Date().toISOString());
|
||||||
|
} catch {
|
||||||
|
// Nothing to keep it in; alreadyAsked says yes then anyway.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app asks for notifications by itself, once, a moment after the user is
|
||||||
|
* signed in: iOS shows its own question only when an app asks, and nobody went
|
||||||
|
* looking for the switch in the settings. Only in the app, only where this
|
||||||
|
* server offers push, and only while this device is not signed up yet. Turning
|
||||||
|
* it on goes the way the settings' push card goes (useWebPush), which brings up
|
||||||
|
* the system's question.
|
||||||
|
*/
|
||||||
|
export default function MPushPrompt(): React.ReactElement | null {
|
||||||
|
const signedIn = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!signedIn || !nativePushActive() || alreadyAsked() || nativePushEndpoint()) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void pushChannelOffered().then((offered) => {
|
||||||
|
if (cancelled || !offered || nativePushEndpoint()) return;
|
||||||
|
rememberAsked();
|
||||||
|
setOpen(true);
|
||||||
|
});
|
||||||
|
}, PUSH_PROMPT_DELAY_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [signedIn]);
|
||||||
|
|
||||||
|
return open ? <PushPromptSheet onClose={() => setOpen(false)} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PushPromptSheet({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const push = useWebPush();
|
||||||
|
const turnOn = () => {
|
||||||
|
if (!push.on) push.toggle();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<MSheet open onClose={onClose} variant="bottom" ariaLabel={t('native.pushPrompt.title')}>
|
||||||
|
<div className="flex flex-col items-center px-[22px] pb-5 pt-6 text-center">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink"
|
||||||
|
>
|
||||||
|
<BellRing size={26} strokeWidth={1.9} />
|
||||||
|
</span>
|
||||||
|
<h2 className="mb-2 text-[length:calc(18px*var(--fs-scale-title,1))] font-bold text-m-ink">
|
||||||
|
{t('native.pushPrompt.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-5 text-[length:calc(14px*var(--fs-scale-body,1))] text-m-muted">
|
||||||
|
{t('native.pushPrompt.body')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={turnOn}
|
||||||
|
disabled={!push.canSwitch || push.busy}
|
||||||
|
className="mb-2 w-full rounded-full bg-m-act py-3 text-[length:calc(15px*var(--fs-scale-body,1))] font-semibold text-m-actfg transition-transform active:scale-[.98] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('native.pushPrompt.enable')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full rounded-full py-3 text-[length:calc(15px*var(--fs-scale-body,1))] font-medium text-m-muted"
|
||||||
|
>
|
||||||
|
{t('native.pushPrompt.later')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react';
|
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
|
||||||
|
import { edgeFadeClass, useEdgeFade } from './useEdgeFade';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The rail on the leading edge that takes the dock's place on a wide window
|
* The rail on the leading edge that takes the dock's place on a wide window
|
||||||
@@ -27,6 +28,9 @@ export function MRail({
|
|||||||
}) {
|
}) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
useEvenLines(listRef);
|
useEvenLines(listRef);
|
||||||
|
// On a short window the entries scroll; the cut edge fades, so the foot's entries read as more below.
|
||||||
|
const [list, setList] = useState<HTMLDivElement | null>(null);
|
||||||
|
const fade = useEdgeFade(list, 'y');
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
@@ -34,8 +38,11 @@ export function MRail({
|
|||||||
>
|
>
|
||||||
{head && <div className="mb-2 flex flex-none justify-center">{head}</div>}
|
{head && <div className="mb-2 flex flex-none justify-center">{head}</div>}
|
||||||
<div
|
<div
|
||||||
ref={listRef}
|
ref={(node) => {
|
||||||
className="flex min-h-0 w-full flex-1 flex-col items-center gap-1 overflow-y-auto px-1 [scrollbar-width:none]"
|
listRef.current = node;
|
||||||
|
setList(node);
|
||||||
|
}}
|
||||||
|
className={`flex min-h-0 w-full flex-1 flex-col items-center gap-1 overflow-y-auto px-1 [scrollbar-width:none] ${edgeFadeClass(fade, 'y')}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -90,6 +97,11 @@ export function MRailSpacer() {
|
|||||||
interface MRailButtonProps {
|
interface MRailButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/**
|
||||||
|
* Opens a sheet rather than a section (More): while that sheet is open the button wears
|
||||||
|
* the ring the phone dock's More wears, so the rail says where the sheet came from.
|
||||||
|
*/
|
||||||
|
expanded?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
/** The icon, drawn at 20px. */
|
/** The icon, drawn at 20px. */
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -101,19 +113,24 @@ interface MRailButtonProps {
|
|||||||
* and never shrunk: it wraps into a second, balanced line where its language
|
* and never shrunk: it wraps into a second, balanced line where its language
|
||||||
* needs one, and only a word longer than the rail is hyphenated.
|
* needs one, and only a word longer than the rail is hyphenated.
|
||||||
*/
|
*/
|
||||||
export function MRailButton({ label, active, onClick, children }: MRailButtonProps) {
|
export function MRailButton({ label, active, expanded, onClick, children }: MRailButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={label}
|
title={label}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
|
aria-expanded={expanded}
|
||||||
className="group flex w-full flex-none flex-col items-center gap-[3px] rounded-[18px] py-[5px] outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)]"
|
className="group flex w-full flex-none flex-col items-center gap-[3px] rounded-[18px] py-[5px] outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)]"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`flex h-10 w-10 items-center justify-center rounded-full transition-colors duration-150 ${
|
className={`flex h-10 w-10 items-center justify-center rounded-full transition-colors duration-150 ${
|
||||||
active ? 'bg-m-act text-m-actfg' : 'text-m-muted group-hover:bg-[color:var(--m-ic)] group-hover:text-m-ink'
|
active
|
||||||
|
? 'bg-m-act text-m-actfg'
|
||||||
|
: expanded
|
||||||
|
? 'bg-[color:var(--m-ic)] text-m-ink ring-[1.5px] ring-inset ring-[color:var(--m-act)]'
|
||||||
|
: 'text-m-muted group-hover:bg-[color:var(--m-ic)] group-hover:text-m-ink'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
|
import MToggle from './MToggle';
|
||||||
|
|
||||||
|
vi.mock('../../native/haptics', () => ({ selectionHaptic: vi.fn() }));
|
||||||
|
|
||||||
|
describe('MToggle', () => {
|
||||||
|
it('FE-MOB-TOGGLE-001: on, the knob takes the track ink so it stays visible on the near-white dark track', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { rerender } = render(<MToggle checked onChange={onChange} ariaLabel="Offline" />);
|
||||||
|
const knob = screen.getByRole('switch').firstElementChild as HTMLElement;
|
||||||
|
expect(knob.className).toContain('bg-[color:var(--m-actfg)]');
|
||||||
|
expect(knob.className).not.toContain('--m-knob');
|
||||||
|
|
||||||
|
rerender(<MToggle checked={false} onChange={onChange} ariaLabel="Offline" />);
|
||||||
|
expect((screen.getByRole('switch').firstElementChild as HTMLElement).className).toContain(
|
||||||
|
'bg-[color:var(--m-knob)]'
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('switch'));
|
||||||
|
expect(onChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,11 @@ interface MToggleProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** iOS-style switch: --m-act track when on, --m-trackoff when off, 18px knob. */
|
/**
|
||||||
|
* iOS-style switch: --m-act track when on, --m-trackoff when off, 18px knob. The knob on
|
||||||
|
* takes the track's ink (--m-actfg): in the dark scheme the track is near white, and a
|
||||||
|
* white knob on it left the whole switch one white pill.
|
||||||
|
*/
|
||||||
export default function MToggle({ checked, onChange, ariaLabel, disabled = false, className = '' }: MToggleProps) {
|
export default function MToggle({ checked, onChange, ariaLabel, disabled = false, className = '' }: MToggleProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -27,8 +31,10 @@ export default function MToggle({ checked, onChange, ariaLabel, disabled = false
|
|||||||
} ${className}`}
|
} ${className}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`block h-[18px] w-[18px] rounded-full bg-[color:var(--m-knob)] transition-transform duration-[180ms] ${
|
className={`block h-[18px] w-[18px] rounded-full transition-transform duration-[180ms] ${
|
||||||
checked ? 'translate-x-[18px] rtl:-translate-x-[18px]' : 'translate-x-0'
|
checked
|
||||||
|
? 'translate-x-[18px] bg-[color:var(--m-actfg)] rtl:-translate-x-[18px]'
|
||||||
|
: 'translate-x-0 bg-[color:var(--m-knob)]'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
|
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
import { useSharedImportStore } from '../../native/sharedFiles';
|
import { saveSharedLink, useSharedImportStore } from '../../native/sharedFiles';
|
||||||
import { tripRepo } from '../../repo/tripRepo';
|
import { tripRepo } from '../../repo/tripRepo';
|
||||||
|
import { useAddonStore } from '../../store/addonStore';
|
||||||
import NativeImportSheet from './NativeImportSheet';
|
import NativeImportSheet from './NativeImportSheet';
|
||||||
|
|
||||||
const navigate = vi.hoisted(() => vi.fn());
|
const navigate = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('../../native/sharedFiles', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../../native/sharedFiles')>()),
|
||||||
|
saveSharedLink: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock('react-router', async (importOriginal) => ({
|
vi.mock('react-router', async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import('react-router')>()),
|
...(await importOriginal<typeof import('react-router')>()),
|
||||||
useNavigate: () => navigate,
|
useNavigate: () => navigate,
|
||||||
@@ -14,7 +19,9 @@ beforeEach(() => {
|
|||||||
// A second spyOn returns the first spy with its calls; start every test clean.
|
// A second spyOn returns the first spy with its calls; start every test clean.
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
navigate.mockReset();
|
navigate.mockReset();
|
||||||
useSharedImportStore.setState({ file: null, tripId: null });
|
useSharedImportStore.setState({ file: null, tripId: null, link: null });
|
||||||
|
useAddonStore.setState({ addons: [{ id: 'collab', enabled: true }] as never, loaded: true });
|
||||||
|
vi.mocked(saveSharedLink).mockReset().mockResolvedValue();
|
||||||
vi.spyOn(tripRepo, 'list').mockResolvedValue({
|
vi.spyOn(tripRepo, 'list').mockResolvedValue({
|
||||||
trips: [
|
trips: [
|
||||||
{ id: 1, title: 'Past trip', start_date: '2020-01-01', end_date: '2020-01-05' },
|
{ id: 1, title: 'Past trip', start_date: '2020-01-01', end_date: '2020-01-05' },
|
||||||
@@ -24,6 +31,14 @@ beforeEach(() => {
|
|||||||
} as unknown as Awaited<ReturnType<typeof tripRepo.list>>);
|
} as unknown as Awaited<ReturnType<typeof tripRepo.list>>);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const page = () => new File(['<html></html>'], 'hotel.html', { type: 'text/html' });
|
||||||
|
const bareLink = () => new File(['https://hotel.example/stay'], 'link.url', { type: 'text/uri-list' });
|
||||||
|
const link = (presetTripId: number | null = null) => ({
|
||||||
|
url: 'https://hotel.example/stay',
|
||||||
|
title: 'Hotel Kanra',
|
||||||
|
presetTripId,
|
||||||
|
});
|
||||||
|
|
||||||
describe('NativeImportSheet', () => {
|
describe('NativeImportSheet', () => {
|
||||||
it('asks which trip a shared document goes to, upcoming trips first, and opens it there', async () => {
|
it('asks which trip a shared document goes to, upcoming trips first, and opens it there', async () => {
|
||||||
render(<NativeImportSheet />);
|
render(<NativeImportSheet />);
|
||||||
@@ -49,6 +64,58 @@ describe('NativeImportSheet', () => {
|
|||||||
expect(navigate).toHaveBeenCalledTimes(1);
|
expect(navigate).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('asks what a shared page is for, and keeps it among the links of the trip picked', async () => {
|
||||||
|
render(<NativeImportSheet />);
|
||||||
|
act(() => useSharedImportStore.getState().setFile(page(), link()));
|
||||||
|
expect(screen.getByText('What should TREK do with this link?')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /Japan/ })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Save to the trip's links/ }));
|
||||||
|
expect(screen.getByText('Save the link to which trip?')).toBeInTheDocument();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Japan/ }));
|
||||||
|
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/trips/2?tab=collab'));
|
||||||
|
expect(saveSharedLink).toHaveBeenCalledWith(2, link());
|
||||||
|
expect(useSharedImportStore.getState().file).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a booking from the page when that is asked for', async () => {
|
||||||
|
render(<NativeImportSheet />);
|
||||||
|
act(() => useSharedImportStore.getState().setFile(page(), link()));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Read a booking from it/ }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Japan/ }));
|
||||||
|
expect(useSharedImportStore.getState().tripId).toBe(2);
|
||||||
|
expect(navigate).toHaveBeenCalledWith('/trips/2');
|
||||||
|
expect(saveSharedLink).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a bare link in the trip the share sheet picked, with nothing to ask', async () => {
|
||||||
|
render(<NativeImportSheet />);
|
||||||
|
act(() => useSharedImportStore.getState().setFile(bareLink(), link(2)));
|
||||||
|
await waitFor(() => expect(saveSharedLink).toHaveBeenCalledWith(2, link(2)));
|
||||||
|
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/trips/2?tab=collab'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with Collab off, a page is read as a booking and a bare link is explained', async () => {
|
||||||
|
useAddonStore.setState({ addons: [], loaded: true });
|
||||||
|
render(<NativeImportSheet />);
|
||||||
|
act(() => useSharedImportStore.getState().setFile(page(), link()));
|
||||||
|
expect(screen.queryByText('What should TREK do with this link?')).not.toBeInTheDocument();
|
||||||
|
expect(await screen.findAllByRole('button', { name: /Japan|Past trip/ })).toHaveLength(2);
|
||||||
|
|
||||||
|
act(() => useSharedImportStore.getState().setFile(bareLink(), link()));
|
||||||
|
expect(screen.getByText('Turn on Collab to keep links in a trip.')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /Japan/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the link cannot be saved, and offers the trips again', async () => {
|
||||||
|
vi.mocked(saveSharedLink).mockRejectedValue(new Error('offline'));
|
||||||
|
render(<NativeImportSheet />);
|
||||||
|
act(() => useSharedImportStore.getState().setFile(bareLink(), link(2)));
|
||||||
|
expect(await screen.findByRole('button', { name: /Japan/ })).toBeEnabled();
|
||||||
|
expect(navigate).not.toHaveBeenCalled();
|
||||||
|
expect(useSharedImportStore.getState().file).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('stays closed with nothing shared', () => {
|
it('stays closed with nothing shared', () => {
|
||||||
render(<NativeImportSheet />);
|
render(<NativeImportSheet />);
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* NativeImportSheet: a booking document was shared to TREK (native/sharedFiles.ts),
|
* NativeImportSheet: something was shared to TREK (native/sharedFiles.ts), so this
|
||||||
* so this asks which trip it belongs to and opens that trip's booking import with
|
* asks which trip it belongs to and opens that trip's booking import with the file
|
||||||
* the file already chosen. Trips still ahead come first, the ones that ended after.
|
* already chosen. Trips still ahead come first, the ones that ended after.
|
||||||
|
*
|
||||||
|
* A web page or a link is asked about first: kept among the trip's links
|
||||||
|
* (Collaboration, Links), or read as a booking. A bare link, whose page the phone
|
||||||
|
* could not read, can only be kept; with Collaboration off, a page can only be read.
|
||||||
*/
|
*/
|
||||||
import { ChevronRight, FileText } from 'lucide-react';
|
import { ChevronRight, FileText, Link2, ScanText } from 'lucide-react';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useEffectEvent, useRef, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
import { useToast } from '../../components/shared/Toast';
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
import { useSharedImportStore } from '../../native/sharedFiles';
|
import { saveSharedLink, useSharedImportStore, type SharedLink } from '../../native/sharedFiles';
|
||||||
import { tripRepo } from '../../repo/tripRepo';
|
import { tripRepo } from '../../repo/tripRepo';
|
||||||
|
import { useAddonStore } from '../../store/addonStore';
|
||||||
|
import { offlineOr } from '../../utils/needsConnection';
|
||||||
import { TileHeader } from '../screens/trip/sheets/MTripSheetUi';
|
import { TileHeader } from '../screens/trip/sheets/MTripSheetUi';
|
||||||
import MSheet from './MSheet';
|
import MSheet from './MSheet';
|
||||||
|
|
||||||
@@ -19,18 +26,45 @@ interface TripRow {
|
|||||||
end_date?: string | null;
|
end_date?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NativeImportSheet(): React.ReactElement | null {
|
type LinkChoice = 'link' | 'booking';
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
/** What to offer for a shared link: the links when Collaboration is on, the import when there is a page to read. */
|
||||||
const { pathname } = useLocation();
|
function linkChoicesOf(file: File, collabOn: boolean): LinkChoice[] {
|
||||||
const file = useSharedImportStore((s) => s.file);
|
const choices: LinkChoice[] = [];
|
||||||
const tripId = useSharedImportStore((s) => s.tripId);
|
if (collabOn) choices.push('link');
|
||||||
const chooseTrip = useSharedImportStore((s) => s.chooseTrip);
|
if (file.type !== 'text/uri-list') choices.push('booking');
|
||||||
const setFile = useSharedImportStore((s) => s.setFile);
|
return choices;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StageInput {
|
||||||
|
file: File | null;
|
||||||
|
link: SharedLink | null;
|
||||||
|
collabOn: boolean;
|
||||||
|
picked: LinkChoice | null;
|
||||||
|
/** The share sheet's trip could not take the link: the trips are offered after all. */
|
||||||
|
presetFailed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the sheet stands for the shared item: what it asks, and what a tap on a trip does. */
|
||||||
|
function stageOf({ file, link, collabOn, picked, presetFailed }: StageInput) {
|
||||||
|
if (!file || !link) return { choice: 'booking' as LinkChoice | null, asking: false, nowhere: false, showTrips: true };
|
||||||
|
const choices = linkChoicesOf(file, collabOn);
|
||||||
|
const choice = picked ?? (choices.length === 1 ? choices[0] : null);
|
||||||
|
const asking = choice == null && choices.length > 1;
|
||||||
|
const nowhere = choices.length === 0;
|
||||||
|
// While the share sheet's trip takes the link there is nothing to choose.
|
||||||
|
const presetTakes = link.presetTripId != null && choice === 'link' && !presetFailed;
|
||||||
|
return { choice, asking, nowhere, showTrips: !asking && !nowhere && !presetTakes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sheetTitle(t: (key: string) => string, asking: boolean, choice: LinkChoice | null): string {
|
||||||
|
if (asking) return t('native.share.linkTitle');
|
||||||
|
return choice === 'link' ? t('native.share.linkTrip') : t('native.share.title');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The trips to choose from, those still ahead first. */
|
||||||
|
function useTripRows(open: boolean): TripRow[] {
|
||||||
const [trips, setTrips] = useState<TripRow[]>([]);
|
const [trips, setTrips] = useState<TripRow[]>([]);
|
||||||
|
|
||||||
const open = !!file && tripId == null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
tripRepo.list().then(
|
tripRepo.list().then(
|
||||||
@@ -46,6 +80,35 @@ export default function NativeImportSheet(): React.ReactElement | null {
|
|||||||
() => setTrips([])
|
() => setTrips([])
|
||||||
);
|
);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
return trips;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NativeImportSheet(): React.ReactElement | null {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const file = useSharedImportStore((s) => s.file);
|
||||||
|
const tripId = useSharedImportStore((s) => s.tripId);
|
||||||
|
const link = useSharedImportStore((s) => s.link);
|
||||||
|
const chooseTrip = useSharedImportStore((s) => s.chooseTrip);
|
||||||
|
const setFile = useSharedImportStore((s) => s.setFile);
|
||||||
|
const collabOn = useAddonStore((s) => s.isEnabled('collab'));
|
||||||
|
// Both belong to the item they were made for, so a newly shared one starts afresh.
|
||||||
|
const [pick, setPick] = useState<{ file: File; choice: LinkChoice } | null>(null);
|
||||||
|
const [failedFile, setFailedFile] = useState<File | null>(null);
|
||||||
|
// The save in flight, kept off the state: it guards a second tap, it is not shown.
|
||||||
|
const savingRef = useRef<File | null>(null);
|
||||||
|
const picked = pick && pick.file === file ? pick.choice : null;
|
||||||
|
const { choice, asking, nowhere, showTrips } = stageOf({
|
||||||
|
file,
|
||||||
|
link,
|
||||||
|
collabOn,
|
||||||
|
picked,
|
||||||
|
presetFailed: failedFile != null && failedFile === file,
|
||||||
|
});
|
||||||
|
const open = !!file && tripId == null;
|
||||||
|
const trips = useTripRows(open);
|
||||||
|
|
||||||
// Once a trip is picked (here, or in the share sheet before the app opened), go to
|
// Once a trip is picked (here, or in the share sheet before the app opened), go to
|
||||||
// it; its planner opens the booking import with the file (MTripShell).
|
// it; its planner opens the booking import with the file (MTripShell).
|
||||||
@@ -54,41 +117,133 @@ export default function NativeImportSheet(): React.ReactElement | null {
|
|||||||
void navigate(`/trips/${tripId}`);
|
void navigate(`/trips/${tripId}`);
|
||||||
}, [file, tripId, pathname, navigate]);
|
}, [file, tripId, pathname, navigate]);
|
||||||
|
|
||||||
const pick = (id: number) => chooseTrip(id);
|
const saveLink = (id: number) => {
|
||||||
|
if (!link || !file || savingRef.current === file) return;
|
||||||
|
savingRef.current = file;
|
||||||
|
saveSharedLink(id, link).then(
|
||||||
|
() => {
|
||||||
|
toast.success(t('native.share.linkSaved'));
|
||||||
|
setFile(null);
|
||||||
|
void navigate(`/trips/${id}?tab=collab`);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
toast.error(offlineOr(t, t('common.error')));
|
||||||
|
savingRef.current = null;
|
||||||
|
setFailedFile(file);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The trip the share sheet picked, used as soon as it is clear what the link is for.
|
||||||
|
const preset = link?.presetTripId ?? null;
|
||||||
|
const applyPreset = useEffectEvent((id: number, use: LinkChoice) => {
|
||||||
|
if (use === 'booking') chooseTrip(id);
|
||||||
|
else saveLink(id);
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (preset != null && choice != null && file) applyPreset(preset, choice);
|
||||||
|
}, [preset, choice, file]);
|
||||||
|
|
||||||
|
const pickTrip = (id: number) => (choice === 'link' ? saveLink(id) : chooseTrip(id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
|
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
|
||||||
<div className="px-[18px] pb-4 pt-4">
|
<div className="px-[18px] pb-4 pt-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<TileHeader
|
<TileHeader
|
||||||
icon={<FileText size={19} strokeWidth={1.8} />}
|
icon={link ? <Link2 size={19} strokeWidth={1.8} /> : <FileText size={19} strokeWidth={1.8} />}
|
||||||
title={t('native.share.title')}
|
title={sheetTitle(t, asking, choice)}
|
||||||
sub={file?.name}
|
sub={link ? link.title : file?.name}
|
||||||
onClose={() => setFile(null)}
|
onClose={() => setFile(null)}
|
||||||
closeLabel={t('common.close')}
|
closeLabel={t('common.close')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
|
{asking && file && <LinkQuestion onPick={(use) => setPick({ file, choice: use })} />}
|
||||||
{trips.map((trip) => (
|
{nowhere && (
|
||||||
<button
|
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
|
||||||
key={trip.id}
|
{t('native.share.linkUnsupported')}
|
||||||
type="button"
|
</p>
|
||||||
onClick={() => pick(trip.id)}
|
)}
|
||||||
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
|
{showTrips && <TripPicker trips={trips} onPick={pickTrip} />}
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
|
|
||||||
{trip.title}
|
|
||||||
</span>
|
|
||||||
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{trips.length === 0 && (
|
|
||||||
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
|
|
||||||
{t('native.share.noTrips')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</MSheet>
|
</MSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LinkQuestion({ onPick }: { onPick: (choice: LinkChoice) => void }): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<ChoiceRow
|
||||||
|
icon={<Link2 size={17} strokeWidth={2} />}
|
||||||
|
label={t('native.share.saveLink')}
|
||||||
|
hint={t('native.share.saveLinkHint')}
|
||||||
|
onClick={() => onPick('link')}
|
||||||
|
/>
|
||||||
|
<ChoiceRow
|
||||||
|
icon={<ScanText size={17} strokeWidth={2} />}
|
||||||
|
label={t('native.share.readBooking')}
|
||||||
|
hint={t('native.share.readBookingHint')}
|
||||||
|
onClick={() => onPick('booking')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TripPicker({ trips, onPick }: { trips: TripRow[]; onPick: (id: number) => void }): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
|
||||||
|
{trips.map((trip) => (
|
||||||
|
<button
|
||||||
|
key={trip.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(trip.id)}
|
||||||
|
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
|
||||||
|
{trip.title}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{trips.length === 0 && (
|
||||||
|
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">{t('native.share.noTrips')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoiceRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">{hint}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
--m-act: #101013;
|
--m-act: #101013;
|
||||||
--m-actfg: #fff;
|
--m-actfg: #fff;
|
||||||
--m-dim: rgba(16,16,19,.4);
|
--m-dim: rgba(16,16,19,.4);
|
||||||
|
/* A picture shown full screen (MImageViewer): dark in either scheme, as a photo viewer is. */
|
||||||
|
--m-viewer-bg: rgba(0,0,0,.92);
|
||||||
|
--m-viewer-ink: #fff;
|
||||||
|
--m-viewer-btn: rgba(255,255,255,.15);
|
||||||
--m-sheet: rgba(250,250,248,.94);
|
--m-sheet: rgba(250,250,248,.94);
|
||||||
--m-sheetop: #FAFAF8;
|
--m-sheetop: #FAFAF8;
|
||||||
--m-shbr: rgba(255,255,255,.95);
|
--m-shbr: rgba(255,255,255,.95);
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import type { Location, NavigationType } from 'react-router';
|
|||||||
*/
|
*/
|
||||||
export type RouteTransitionKind = 'forward' | 'back' | 'tab';
|
export type RouteTransitionKind = 'forward' | 'back' | 'tab';
|
||||||
|
|
||||||
const SETTLE_MS = 800;
|
// Short on purpose: while it waits the browser shows a frozen snapshot of the old
|
||||||
|
// page, so on a slow connection a longer wait reads as the app hanging.
|
||||||
|
const SETTLE_MS = 350;
|
||||||
let skipNext = false;
|
let skipNext = false;
|
||||||
let current: ViewTransition | null = null;
|
let current: ViewTransition | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { STATUS_SPEC } from './collectionsMobileModel'
|
|||||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||||
import MCollListNav from './MCollListNav'
|
import MCollListNav from './MCollListNav'
|
||||||
import { useIsSplit } from '../../../hooks/sizeClass'
|
import { useIsSplit } from '../../../hooks/sizeClass'
|
||||||
|
import { skipNextRouteTransition } from '../../routeTransition'
|
||||||
|
|
||||||
const HEADER_CIRCLE =
|
const HEADER_CIRCLE =
|
||||||
'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]'
|
'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]'
|
||||||
@@ -133,6 +134,9 @@ export default function MCollections() {
|
|||||||
|
|
||||||
const selectList = (id: number | typeof ALL_SAVED) => {
|
const selectList = (id: number | typeof ALL_SAVED) => {
|
||||||
setDrop(false)
|
setDrop(false)
|
||||||
|
// Another list is the same screen with other content, not a page opened
|
||||||
|
// over it: the URL changes, but the page must not slide in.
|
||||||
|
skipNextRouteTransition()
|
||||||
c.handleSelectList(id)
|
c.handleSelectList(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { useIsSplit } from '../../../hooks/sizeClass'
|
|
||||||
import {
|
import {
|
||||||
Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
|
Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
|
||||||
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Sparkles, Trash2, Users,
|
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Sparkles, Trash2, Users,
|
||||||
@@ -60,7 +59,6 @@ export default function MDashboard(): React.ReactElement {
|
|||||||
const unread = useInAppNotificationStore(s => s.unreadCount)
|
const unread = useInAppNotificationStore(s => s.unreadCount)
|
||||||
const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
|
const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const split = useIsSplit()
|
|
||||||
const { available: assistantAvailable, open: openAssistant } = useAssistantHomeLauncher()
|
const { available: assistantAvailable, open: openAssistant } = useAssistantHomeLauncher()
|
||||||
const [subOpen, setSubOpen] = useState(false)
|
const [subOpen, setSubOpen] = useState(false)
|
||||||
// The filter chips scroll when a language's words do not fit beside the two
|
// The filter chips scroll when a language's words do not fit beside the two
|
||||||
@@ -281,9 +279,12 @@ export default function MDashboard(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Beside each other from the split size class on, the way the desktop
|
{/* Beside each other from the split size class on, the way the desktop
|
||||||
dashboard holds them: the trips in the main column, the widgets in a
|
dashboard holds them: the trips in the main column, the widgets in a
|
||||||
column of their own, each in the order the user set. */}
|
column of their own, each in the order the user set. Narrower, both
|
||||||
<div className="split:grid split:grid-cols-[minmax(0,1fr)_minmax(300px,360px)] split:items-start split:gap-6">
|
columns dissolve (display: contents) into one stack ordered the same
|
||||||
<div className="min-w-0">
|
way, so crossing the split size never moves a widget to another
|
||||||
|
parent and its state (the converter's amount, its rates) stays. */}
|
||||||
|
<div className="flex flex-col split:grid split:grid-cols-[minmax(0,1fr)_minmax(300px,360px)] split:items-start split:gap-6">
|
||||||
|
<div className="contents min-w-0 split:block">
|
||||||
{spotlight && (
|
{spotlight && (
|
||||||
<MSpotlightCard
|
<MSpotlightCard
|
||||||
trip={spotlight}
|
trip={spotlight}
|
||||||
@@ -292,22 +293,18 @@ export default function MDashboard(): React.ReactElement {
|
|||||||
actions={actionsFor(spotlight, 'list')}
|
actions={actionsFor(spotlight, 'list')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(split ? dashOrder.filter(id => id === 'trips') : dashOrder).map(id => (
|
<div style={{ order: dashOrder.indexOf('trips') }}>{renderBlock('trips')}</div>
|
||||||
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
|
</div>
|
||||||
))}
|
<div className="contents min-w-0 split:sticky split:top-[calc(var(--m-safe-top,12px)+70px)] split:-mt-3 split:block split:max-h-[calc(100dvh-var(--m-safe-top,12px)-70px)] split:overflow-y-auto split:overscroll-contain split:pb-[calc(env(safe-area-inset-bottom,0px)+12px)] split:[scrollbar-width:none]">
|
||||||
|
{dashOrder.filter(id => id !== 'trips').map(id => (
|
||||||
|
<div key={id} style={{ order: dashOrder.indexOf(id) }}>{renderBlock(id)}</div>
|
||||||
|
))}
|
||||||
|
{widgetPlugins.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-col gap-3" style={{ order: dashOrder.length }}>
|
||||||
|
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(split || widgetPlugins.length > 0) && (
|
|
||||||
<div className="min-w-0 split:sticky split:top-[calc(var(--m-safe-top,12px)+82px)] split:-mt-[14px]">
|
|
||||||
{split && dashOrder.filter(id => id !== 'trips').map(id => (
|
|
||||||
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
|
|
||||||
))}
|
|
||||||
{widgetPlugins.length > 0 && (
|
|
||||||
<div className="mt-3 flex flex-col gap-3">
|
|
||||||
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'
|
||||||
import { useSearchParams } from 'react-router'
|
import { Outlet, useSearchParams } from 'react-router'
|
||||||
import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route, ListChecks } from 'lucide-react'
|
import { BookOpen, ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route, ListChecks } from 'lucide-react'
|
||||||
import JourneyLayeredMap from '../../../components/Journey/JourneyLayeredMap'
|
import JourneyLayeredMap from '../../../components/Journey/JourneyLayeredMap'
|
||||||
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
|
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
|
||||||
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
|
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
|
||||||
@@ -32,6 +32,8 @@ import MJourneyFeed from './MJourneyFeed'
|
|||||||
import MJourneySettingsSheet from './MJourneySettingsSheet'
|
import MJourneySettingsSheet from './MJourneySettingsSheet'
|
||||||
import MJourneyDayImportSheet from './MJourneyDayImportSheet'
|
import MJourneyDayImportSheet from './MJourneyDayImportSheet'
|
||||||
import MJourneyRecordingSheet from './MJourneyRecordingSheet'
|
import MJourneyRecordingSheet from './MJourneyRecordingSheet'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useTabHeadSlot } from '../trip/tabHeadSlot'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Journey detail — integrated map with the horizontal 280px card timeline
|
* Journey detail — integrated map with the horizontal 280px card timeline
|
||||||
@@ -42,8 +44,12 @@ import MJourneyRecordingSheet from './MJourneyRecordingSheet'
|
|||||||
* (`embedded`): the trip's back button and controls sit on top, so the screen
|
* (`embedded`): the trip's back button and controls sit on top, so the screen
|
||||||
* drops its own back button and leaves the trip's URL alone.
|
* drops its own back button and leaves the trip's URL alone.
|
||||||
*/
|
*/
|
||||||
/** A phone on its side: too short for the regular strip of cards and the map above it. */
|
/**
|
||||||
const SHORT_WINDOW = '(max-height: 500px)'
|
* A phone on its side, or a folded Duo on its side (about 540px high): too short
|
||||||
|
* for the regular strip of cards and the map above it. Higher than the shell's
|
||||||
|
* 500px for that Duo, whose regular cards left the map a band under the header.
|
||||||
|
*/
|
||||||
|
const SHORT_WINDOW = '(max-height: 600px)'
|
||||||
function subscribeShort(onChange: () => void) {
|
function subscribeShort(onChange: () => void) {
|
||||||
const mq = window.matchMedia(SHORT_WINDOW)
|
const mq = window.matchMedia(SHORT_WINDOW)
|
||||||
mq.addEventListener('change', onChange)
|
mq.addEventListener('change', onChange)
|
||||||
@@ -80,8 +86,11 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
|||||||
sidebarMapItems, tracks, mapLayers, entryTitleOf, mapPhotos, openMapPhotos, recordings, canTakeOverRecording, dayImport, dayImportDates, dawarichByDate, recordingPrep,
|
sidebarMapItems, tracks, mapLayers, entryTitleOf, mapPhotos, openMapPhotos, recordings, canTakeOverRecording, dayImport, dayImportDates, dawarichByDate, recordingPrep,
|
||||||
dismissSuggestion, restoreSuggestions, openAtEntryId,
|
dismissSuggestion, restoreSuggestions, openAtEntryId,
|
||||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
|
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
|
||||||
addPickedProviderPhotos, addEntryProviderPhotos,
|
addPickedProviderPhotos, addEntryProviderPhotos, writeAboutPlace,
|
||||||
} = useJourneyDetail({ journeyId, embedded })
|
} = useJourneyDetail({ journeyId, embedded })
|
||||||
|
// A trip tab: the header goes into the trip shell's top row (tabHeadSlot.ts).
|
||||||
|
const shellSlot = useTabHeadSlot()
|
||||||
|
const headSlot = embedded ? shellSlot : null
|
||||||
|
|
||||||
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
|
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
|
||||||
// upload button, so it has to know which tab is open — and it stops knowing
|
// upload button, so it has to know which tab is open — and it stops knowing
|
||||||
@@ -320,8 +329,9 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
|||||||
// keeps a single "more" affordance.
|
// keeps a single "more" affordance.
|
||||||
//
|
//
|
||||||
// There is no book export here any more: the book is a Studio document now,
|
// There is no book export here any more: the book is a Studio document now,
|
||||||
// and Studio is a desktop editor. A phone-sized PDF of a book laid out for
|
// and Studio needs the room of a desktop, an iPad on its side or an unfolded
|
||||||
// print was the old export's answer to a question nobody was asking.
|
// Duo (split), where the header offers it. A phone-sized PDF of a book laid
|
||||||
|
// out for print was the old export's answer to a question nobody was asking.
|
||||||
const toggleSkeletons = async (next: boolean) => {
|
const toggleSkeletons = async (next: boolean) => {
|
||||||
setHideSkeletons(next)
|
setHideSkeletons(next)
|
||||||
try {
|
try {
|
||||||
@@ -334,7 +344,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
|||||||
return (
|
return (
|
||||||
// h-dvh, not h-full: the shell stopped providing a definite height (#1809)
|
// h-dvh, not h-full: the shell stopped providing a definite height (#1809)
|
||||||
// and a map on a percentage of an auto-height parent collapses to zero.
|
// and a map on a percentage of an auto-height parent collapses to zero.
|
||||||
<div ref={rootRef} className="m-bleed relative h-dvh overflow-hidden">
|
<div ref={rootRef} className="m-bleed relative h-dvh overflow-hidden" style={headSlot ? IN_SHELL_ROW : undefined}>
|
||||||
{/* Integrated map — always mounted, the gallery overlays it. Beside the feed
|
{/* Integrated map — always mounted, the gallery overlays it. Beside the feed
|
||||||
it begins where the feed ends, so its controls and its framing stay in
|
it begins where the feed ends, so its controls and its framing stay in
|
||||||
the part that can be seen. */}
|
the part that can be seen. */}
|
||||||
@@ -348,6 +358,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
|||||||
hideLayerControl
|
hideLayerControl
|
||||||
controlsTop="calc(var(--m-safe-top, 12px) + 50px)"
|
controlsTop="calc(var(--m-safe-top, 12px) + 50px)"
|
||||||
onOpenEntry={handleMarkerClick}
|
onOpenEntry={handleMarkerClick}
|
||||||
|
onWriteAboutPlace={writeAboutPlace}
|
||||||
entryTitleOf={entryTitleOf}
|
entryTitleOf={entryTitleOf}
|
||||||
checkins={[]}
|
checkins={[]}
|
||||||
entries={sidebarMapItems}
|
entries={sidebarMapItems}
|
||||||
@@ -418,11 +429,12 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
|||||||
{feed && (
|
{feed && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 z-[6] overflow-y-auto overflow-x-hidden border-e border-[color:var(--m-rowbr)] bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[calc(var(--m-safe-top,12px)+60px)]"
|
className="absolute inset-y-0 z-[6] overflow-y-auto overflow-x-hidden border-e border-[color:var(--m-rowbr)] bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[calc(var(--m-safe-top,12px)+60px)]"
|
||||||
// Entries scrolled up fade out under the header's pills instead of showing through between them.
|
// Entries scrolled up fade out under the header's pills instead of showing through between them:
|
||||||
|
// gone down to the pills' lower edge (38px tall), whole again before the first day's head.
|
||||||
style={{
|
style={{
|
||||||
insetInlineStart: 'var(--m-rail-w, 0px)',
|
insetInlineStart: 'var(--m-rail-w, 0px)',
|
||||||
width: 'var(--m-pane-aside-w)',
|
width: 'var(--m-pane-aside-w)',
|
||||||
maskImage: 'linear-gradient(180deg, transparent calc(var(--m-safe-top, 12px) + 22px), #000 calc(var(--m-safe-top, 12px) + 58px))',
|
maskImage: 'linear-gradient(180deg, transparent calc(var(--m-safe-top, 12px) + 40px), #000 calc(var(--m-safe-top, 12px) + 60px))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MJourneyFeed
|
<MJourneyFeed
|
||||||
@@ -441,10 +453,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
|||||||
|
|
||||||
{/* Header: back — segment — upload / overflow menu. Beside the feed it keeps
|
{/* Header: back — segment — upload / overflow menu. Beside the feed it keeps
|
||||||
to the feed, as the trip's controls keep to its pane. */}
|
to the feed, as the trip's controls keep to its pane. */}
|
||||||
<div
|
<HeaderFrame slot={headSlot} feed={!!feed}>
|
||||||
className="m-bar-x absolute top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2"
|
|
||||||
style={feed ? { insetInlineEnd: 'calc(100% - var(--m-rail-w, 0px) - var(--m-pane-aside-w) + 16px)' } : undefined}
|
|
||||||
>
|
|
||||||
{embedded ? (
|
{embedded ? (
|
||||||
<span className="w-[38px] flex-none" aria-hidden />
|
<span className="w-[38px] flex-none" aria-hidden />
|
||||||
) : (
|
) : (
|
||||||
@@ -457,7 +466,10 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
|||||||
<ChevronLeft size={18} strokeWidth={2.2} className="rtl:-scale-x-100" />
|
<ChevronLeft size={18} strokeWidth={2.2} className="rtl:-scale-x-100" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]">
|
<div
|
||||||
|
data-view-switch
|
||||||
|
className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full wide:static wide:translate-x-0 bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setView('timeline')}
|
onClick={() => setView('timeline')}
|
||||||
@@ -493,6 +505,16 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
|||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{split && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate(`/journey/${current.id}/studio`)}
|
||||||
|
aria-label={t('journey.studio.openAria')}
|
||||||
|
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
|
||||||
|
>
|
||||||
|
<BookOpen size={17} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowActionMenu(true)}
|
onClick={() => setShowActionMenu(true)}
|
||||||
@@ -502,7 +524,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
|||||||
<MoreHorizontal size={17} strokeWidth={2} />
|
<MoreHorizontal size={17} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</HeaderFrame>
|
||||||
|
|
||||||
{/* The stays Dawarich recorded for these dates, as candidate entries. Over
|
{/* The stays Dawarich recorded for these dates, as candidate entries. Over
|
||||||
the map rather than in the header: it belongs to what is on the map,
|
the map rather than in the header: it belongs to what is on the map,
|
||||||
@@ -589,7 +611,8 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
|||||||
showMood={current.show_mood !== 0}
|
showMood={current.show_mood !== 0}
|
||||||
showWeather={current.show_weather !== 0}
|
showWeather={current.show_weather !== 0}
|
||||||
tone="mobile"
|
tone="mobile"
|
||||||
size={wide ? 'large' : short ? 'compact' : 'regular'}
|
// Short before wide: a folded Duo on its side is wide (over 500px high) and short.
|
||||||
|
size={short ? 'compact' : wide ? 'large' : 'regular'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -799,6 +822,37 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
|||||||
onClose={() => setLightbox(null)}
|
onClose={() => setLightbox(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* TREK Studio, the book designer, opens over the journey on a screen with room for
|
||||||
|
two panes, as on the desktop (its route is a child of this one). The phone keeps
|
||||||
|
the journey alone, whatever the address says. */}
|
||||||
|
{split && !embedded && <Outlet />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With the header in the shell's row, what the screen measures from its top
|
||||||
|
* starts right under that row instead of under a row of its own.
|
||||||
|
*/
|
||||||
|
const IN_SHELL_ROW = { '--m-safe-top': 'calc(env(safe-area-inset-top, 0px) + 6px)' } as CSSProperties
|
||||||
|
|
||||||
|
/** The header row: on the screen, or in the trip shell's top row when the journal is a trip tab. */
|
||||||
|
function HeaderFrame({ slot, feed, children }: { slot: HTMLElement | null; feed: boolean; children: ReactNode }) {
|
||||||
|
if (slot) {
|
||||||
|
return createPortal(
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 [&>span:first-child]:hidden [&>span:last-child]:ms-0 [&_[data-view-switch]]:static [&_[data-view-switch]]:translate-x-0">
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
slot
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="m-bar-x absolute top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2"
|
||||||
|
style={feed ? { insetInlineEnd: 'calc(100% - var(--m-rail-w, 0px) - var(--m-pane-aside-w) + 16px)' } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,12 +137,19 @@ function FeedCard({ entry, color, active, onTap, showMood, showWeather, t }: Fee
|
|||||||
) : (
|
) : (
|
||||||
<MapPin size={11} strokeWidth={2.2} className="flex-none" />
|
<MapPin size={11} strokeWidth={2.2} className="flex-none" />
|
||||||
)}
|
)}
|
||||||
<span dir="auto" className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold">
|
<span
|
||||||
|
dir="auto"
|
||||||
|
className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold"
|
||||||
|
>
|
||||||
{place}
|
{place}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{story && <span dir="auto" className="line-clamp-3 text-caption leading-[1.45] text-m-ink opacity-80">{story}</span>}
|
{story && (
|
||||||
|
<span dir="auto" className="line-clamp-3 text-caption leading-[1.45] text-m-ink opacity-80">
|
||||||
|
{story}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import MToggle from '../../components/MToggle'
|
|||||||
import { MSetCard, MSetEyebrow, MSetSegments, MSetRow } from './MSettingsUi'
|
import { MSetCard, MSetEyebrow, MSetSegments, MSetRow } from './MSettingsUi'
|
||||||
import MMobileNavCustomizer from './MMobileNavCustomizer'
|
import MMobileNavCustomizer from './MMobileNavCustomizer'
|
||||||
import MMobileDashOrder from './MMobileDashOrder'
|
import MMobileDashOrder from './MMobileDashOrder'
|
||||||
|
import { useIsWide } from '../../../hooks/sizeClass'
|
||||||
|
|
||||||
function SliderRow({ label, sub, value, onChange }: { label: string; sub?: string; value: number; onChange: (v: number) => void }) {
|
function SliderRow({ label, sub, value, onChange }: { label: string; sub?: string; value: number; onChange: (v: number) => void }) {
|
||||||
return (
|
return (
|
||||||
@@ -41,6 +42,8 @@ function SliderRow({ label, sub, value, onChange }: { label: string; sub?: strin
|
|||||||
*/
|
*/
|
||||||
export default function MSettingsAppearance() {
|
export default function MSettingsAppearance() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
// A tablet has the rail on its side, no bottom bar: only the dashboard's order is left to set.
|
||||||
|
const wide = useIsWide()
|
||||||
const { cfg, darkMode, isDark, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
|
const { cfg, darkMode, isDark, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
|
||||||
useAppearanceEditor()
|
useAppearanceEditor()
|
||||||
|
|
||||||
@@ -156,11 +159,18 @@ export default function MSettingsAppearance() {
|
|||||||
)}
|
)}
|
||||||
</MSetCard>
|
</MSetCard>
|
||||||
|
|
||||||
<MSetCard title={t('settings.appearance.mobile')} icon={Smartphone} className="mt-3">
|
<MSetCard
|
||||||
<MSetEyebrow className="mb-[6px]">{t('settings.appearance.mobileNav')}</MSetEyebrow>
|
title={wide ? t('settings.appearance.dashOrder') : t('settings.appearance.mobile')}
|
||||||
<MMobileNavCustomizer value={cfg.mobileNav} onChange={(mn) => update({ mobileNav: mn })} />
|
icon={wide ? LayoutDashboard : Smartphone}
|
||||||
|
className="mt-3"
|
||||||
<MSetEyebrow className="mb-[6px] mt-[14px]">{t('settings.appearance.dashOrder')}</MSetEyebrow>
|
>
|
||||||
|
{!wide && (
|
||||||
|
<>
|
||||||
|
<MSetEyebrow className="mb-[6px]">{t('settings.appearance.mobileNav')}</MSetEyebrow>
|
||||||
|
<MMobileNavCustomizer value={cfg.mobileNav} onChange={(mn) => update({ mobileNav: mn })} />
|
||||||
|
<MSetEyebrow className="mb-[6px] mt-[14px]">{t('settings.appearance.dashOrder')}</MSetEyebrow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<p className="-mt-[2px] mb-2 font-geist text-[0.625rem] leading-relaxed text-m-muted">{t('settings.appearance.dashOrder.hint')}</p>
|
<p className="-mt-[2px] mb-2 font-geist text-[0.625rem] leading-relaxed text-m-muted">{t('settings.appearance.dashOrder.hint')}</p>
|
||||||
<MMobileDashOrder cfg={cfg} onChange={(order) => update({ dashboard: { ...cfg.dashboard, mobileOrder: order } })} />
|
<MMobileDashOrder cfg={cfg} onChange={(order) => update({ dashboard: { ...cfg.dashboard, mobileOrder: order } })} />
|
||||||
</MSetCard>
|
</MSetCard>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from '../../../i18n'
|
import { useTranslation } from '../../../i18n'
|
||||||
import MDancingTrek from '../../components/MDancingTrek'
|
import MDancingTrek from '../../components/MDancingTrek'
|
||||||
import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeats'
|
import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeats'
|
||||||
@@ -10,6 +11,36 @@ import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeat
|
|||||||
* progress dots track which beat is on. Under reduced motion it holds a single
|
* progress dots track which beat is on. Under reduced motion it holds a single
|
||||||
* frame (the real "loading photos" one) with no transitions.
|
* frame (the real "loading photos" one) with no transitions.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* How long a trip may take to open before the splash comes up. The page
|
||||||
|
* transition holds the page the trip was opened from for up to 350ms
|
||||||
|
* (mobile/routeTransition.ts), so a trip that loads in that time slides in
|
||||||
|
* whole, the way the dashboard slides back in on the way out. A slower one gets
|
||||||
|
* the splash right after, so a slow connection never sees the app stand still.
|
||||||
|
*/
|
||||||
|
export const TRIP_SPLASH_AFTER_MS = 400
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While a trip opens. At first nothing but the screen's own background, marked
|
||||||
|
* as a route fallback, which the page transition waits on; the splash only once
|
||||||
|
* the load takes longer than TRIP_SPLASH_AFTER_MS.
|
||||||
|
*/
|
||||||
|
export function MTripOpening({ title }: { title: string }) {
|
||||||
|
const [slow, setSlow] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => setSlow(true), TRIP_SPLASH_AFTER_MS)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [])
|
||||||
|
if (slow) return <MTripLoadingSplash title={title} />
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-route-fallback=""
|
||||||
|
aria-busy="true"
|
||||||
|
className="fixed inset-0 z-50 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function MTripLoadingSplash({ title }: { title: string }) {
|
export default function MTripLoadingSplash({ title }: { title: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: true })
|
const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: true })
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface MTripRailProps {
|
|||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
/** Opens the More sheet: trip settings, export, the assistant. */
|
/** Opens the More sheet: trip settings, export, the assistant. */
|
||||||
onMore: () => void;
|
onMore: () => void;
|
||||||
|
/** Whether the More sheet is open, which the More button shows with a ring. */
|
||||||
|
moreOpen?: boolean;
|
||||||
labelOf: (id: string) => string;
|
labelOf: (id: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ const DOCK_ICONS = new Map(DOCK_PRIORITY.map((tab) => [tab.id, tab.icon]));
|
|||||||
* collaboration and plugin pages sit in it too, each with its name, and More
|
* collaboration and plugin pages sit in it too, each with its name, and More
|
||||||
* keeps only the trip's actions.
|
* keeps only the trip's actions.
|
||||||
*/
|
*/
|
||||||
export default function MTripRail({ tabs, active, onSelect, onMore, labelOf }: MTripRailProps) {
|
export default function MTripRail({ tabs, active, onSelect, onMore, moreOpen = false, labelOf }: MTripRailProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<MRail label={t('mobileTrip.sections')}>
|
<MRail label={t('mobileTrip.sections')}>
|
||||||
@@ -41,7 +43,7 @@ export default function MTripRail({ tabs, active, onSelect, onMore, labelOf }: M
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<MRailSpacer />
|
<MRailSpacer />
|
||||||
<MRailButton label={t('mobileTrip.more')} active={false} onClick={onMore}>
|
<MRailButton label={t('mobileTrip.more')} active={false} expanded={moreOpen} onClick={onMore}>
|
||||||
<MoreHorizontal size={20} strokeWidth={2.2} />
|
<MoreHorizontal size={20} strokeWidth={2.2} />
|
||||||
</MRailButton>
|
</MRailButton>
|
||||||
</MRail>
|
</MRail>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type ComponentType } from
|
|||||||
import { findFocusDayId, findTodayDayId } from '../../../components/Planner/today'
|
import { findFocusDayId, findTodayDayId } from '../../../components/Planner/today'
|
||||||
import { takeResumeDay } from '../../../utils/resumeRoute'
|
import { takeResumeDay } from '../../../utils/resumeRoute'
|
||||||
import {
|
import {
|
||||||
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, PanelLeftClose, PanelLeftOpen, Plane, Route, SlidersHorizontal, Trash2, Upload,
|
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, PanelLeftClose, PanelLeftOpen, Plane, Route, SlidersHorizontal, Trash2, Upload,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner'
|
import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner'
|
||||||
import { pickDockTabs } from './dockTabs'
|
import { pickDockTabs } from './dockTabs'
|
||||||
@@ -16,7 +16,7 @@ import MPlacesBrowser from './places/MPlacesBrowser'
|
|||||||
import MTripTabPanel from './tabs/MTripTabPanel'
|
import MTripTabPanel from './tabs/MTripTabPanel'
|
||||||
import MTripSheets from './sheets/MTripSheets'
|
import MTripSheets from './sheets/MTripSheets'
|
||||||
import MReceiptScanButton from './tabs/MReceiptScanButton'
|
import MReceiptScanButton from './tabs/MReceiptScanButton'
|
||||||
import MTripLoadingSplash from './MTripLoadingSplash'
|
import { MTripOpening } from './MTripLoadingSplash'
|
||||||
import { usePluginDayTints, dayTintBackground } from '../../../components/Plugins/PluginDaySchedule'
|
import { usePluginDayTints, dayTintBackground } from '../../../components/Plugins/PluginDaySchedule'
|
||||||
import { stageOf } from '../../../components/Roadtrip/roadtripRowModel'
|
import { stageOf } from '../../../components/Roadtrip/roadtripRowModel'
|
||||||
import { badgeLabel, distanceBadge } from './roadtrip/stageBadges'
|
import { badgeLabel, distanceBadge } from './roadtrip/stageBadges'
|
||||||
@@ -29,12 +29,13 @@ import { useDayPreviewStore } from '../../dayPreviewStore'
|
|||||||
import { useIsSplit, useIsWide } from '../../../hooks/sizeClass'
|
import { useIsSplit, useIsWide } from '../../../hooks/sizeClass'
|
||||||
import MTripRail from './MTripRail'
|
import MTripRail from './MTripRail'
|
||||||
import {
|
import {
|
||||||
CollabSegment, CompactToggle, DayRailSteps, DocSyncButton, GlassSegment, HeaderActions, PrimaryPill, ROW_AFTER_BACK, ROW_CENTRED,
|
CollabSegment, CompactToggle, DockMoreButton, DocSyncButton, GlassSegment, HeaderActions, PrimaryPill, ROW_AFTER_BACK, ROW_CENTRED,
|
||||||
} from './MTripShellParts'
|
} from './MTripShellParts'
|
||||||
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade'
|
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade'
|
||||||
import { useShortcuts } from '../../keyboard/useShortcuts'
|
import { useShortcuts } from '../../keyboard/useShortcuts'
|
||||||
import { tripCreateIntent } from '../../../components/Layout/useCreateAction'
|
import { tripCreateIntent } from '../../../components/Layout/useCreateAction'
|
||||||
import MDockScrim from '../../components/MDockScrim'
|
import MDockScrim from '../../components/MDockScrim'
|
||||||
|
import { setTabHeadSlot } from './tabHeadSlot'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile trip screen frame. Owns the chrome the design shares across every
|
* Mobile trip screen frame. Owns the chrome the design shares across every
|
||||||
@@ -328,9 +329,11 @@ export default function MTripShell({
|
|||||||
|
|
||||||
const trTab = planner.activeTab
|
const trTab = planner.activeTab
|
||||||
|
|
||||||
|
// The tour editor is a tab on a large screen only; a phone, or a window narrowed
|
||||||
|
// to one, keeps its tours in the places browser.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (trTab === 'tour-planner') planner.handleTabChange('plan')
|
if (trTab === 'tour-planner' && !wide) planner.handleTabChange('plan')
|
||||||
}, [trTab, planner.handleTabChange])
|
}, [trTab, wide, planner.handleTabChange])
|
||||||
|
|
||||||
const setTrTab = (tabId: string) => {
|
const setTrTab = (tabId: string) => {
|
||||||
planner.handleTabChange(tabId)
|
planner.handleTabChange(tabId)
|
||||||
@@ -448,9 +451,11 @@ export default function MTripShell({
|
|||||||
// With the plan beside the map, the map shows the day the plan shows: its
|
// With the plan beside the map, the map shows the day the plan shows: its
|
||||||
// places framed, its route drawn, the other days' pins put away, exactly as
|
// places framed, its route drawn, the other days' pins put away, exactly as
|
||||||
// a phone does when it switches over to the map.
|
// a phone does when it switches over to the map.
|
||||||
// Not before the splash has gone: the map is not there to frame until then.
|
// Not before the trip has opened: the map is not there to frame until then.
|
||||||
const mapReady = !planner.isLoading && planner.splashDone
|
// The store can still hold the trip opened before, for the frame before loadTrip resets it.
|
||||||
const followDayId = mapReady && split && trTab === 'plan' && view === 'plan' && mode !== 'browse' ? planner.selectedDayId : null
|
const ownTrip = trip && String(trip.id) === String(tripId) ? trip : null
|
||||||
|
const opened = !planner.isLoading && !!ownTrip
|
||||||
|
const followDayId = opened && split && trTab === 'plan' && view === 'plan' && mode !== 'browse' ? planner.selectedDayId : null
|
||||||
const { autoShowRoute, setExpandedDayIds } = planner
|
const { autoShowRoute, setExpandedDayIds } = planner
|
||||||
const selectDayRef = useRef(planner.handleSelectDay)
|
const selectDayRef = useRef(planner.handleSelectDay)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -519,9 +524,9 @@ export default function MTripShell({
|
|||||||
openDocSyncSignal,
|
openDocSyncSignal,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Splash — same gate as the desktop page, in the mobile design language.
|
// No minimum splash as on the desktop: the page transition hides the wait (MTripOpening).
|
||||||
if (planner.isLoading || !planner.splashDone) {
|
if (!opened && (planner.isLoading || trip)) {
|
||||||
return <MTripLoadingSplash title={trip?.title || ''} />
|
return <MTripOpening title={ownTrip?.title || ''} />
|
||||||
}
|
}
|
||||||
if (!trip) return null
|
if (!trip) return null
|
||||||
|
|
||||||
@@ -641,7 +646,6 @@ export default function MTripShell({
|
|||||||
{/* The glass is the outer box, the scroller sits inside its padding: that way
|
{/* The glass is the outer box, the scroller sits inside its padding: that way
|
||||||
the fade at an edge that runs on takes the chips, not the glass's rim. */}
|
the fade at an edge that runs on takes the chips, not the glass's rim. */}
|
||||||
<div className="relative flex min-w-0 flex-1 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
|
<div className="relative flex min-w-0 flex-1 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
|
||||||
<DayRailSteps rail={dayRail} fade={dayFade} />
|
|
||||||
<div ref={setDayRail} className={`relative flex min-w-0 flex-1 items-center gap-[2px] overflow-x-auto rounded-full wide:[--m-fade-reach:52px] ${edgeFadeClass(dayFade)}`}>
|
<div ref={setDayRail} className={`relative flex min-w-0 flex-1 items-center gap-[2px] overflow-x-auto rounded-full wide:[--m-fade-reach:52px] ${edgeFadeClass(dayFade)}`}>
|
||||||
{dayPill && (
|
{dayPill && (
|
||||||
<span
|
<span
|
||||||
@@ -862,6 +866,8 @@ export default function MTripShell({
|
|||||||
not in the dock, so nothing was lit up there either, and the screen
|
not in the dock, so nothing was lit up there either, and the screen
|
||||||
gave no clue which plugin was open. Same treatment as the others, from
|
gave no clue which plugin was open. Same treatment as the others, from
|
||||||
the tab entry the planner already builds (id, label, icon). */}
|
the tab entry the planner already builds (id, label, icon). */}
|
||||||
|
{/* The tours tab and the journal put their own controls here instead (tabHeadSlot.ts). */}
|
||||||
|
{(trTab === 'tour-planner' || trTab === 'journal') && <div ref={setTabHeadSlot} className="pointer-events-auto absolute inset-x-[52px] top-1/2 flex -translate-y-1/2 items-center gap-2" />}
|
||||||
{trTab.startsWith('plugin:') && (() => {
|
{trTab.startsWith('plugin:') && (() => {
|
||||||
const tab = planner.TRIP_TABS.find(x => x.id === trTab)
|
const tab = planner.TRIP_TABS.find(x => x.id === trTab)
|
||||||
if (!tab) return null
|
if (!tab) return null
|
||||||
@@ -901,6 +907,7 @@ export default function MTripShell({
|
|||||||
active={trTab}
|
active={trTab}
|
||||||
onSelect={setTrTab}
|
onSelect={setTrTab}
|
||||||
onMore={() => openSheet('mehr')}
|
onMore={() => openSheet('mehr')}
|
||||||
|
moreOpen={sheet?.id === 'mehr'}
|
||||||
labelOf={tabLabel}
|
labelOf={tabLabel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -927,17 +934,12 @@ export default function MTripShell({
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<button
|
<DockMoreButton
|
||||||
type="button"
|
open={sheet?.id === 'mehr'}
|
||||||
onClick={() => openSheet('mehr')}
|
standsFor={!dockTabs.some(tab => tab.id === trTab)}
|
||||||
aria-label={t('mobileTrip.more')}
|
onOpen={() => openSheet('mehr')}
|
||||||
aria-expanded={sheet?.id === 'mehr'}
|
label={t('mobileTrip.more')}
|
||||||
className={`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
|
/>
|
||||||
sheet?.id === 'mehr' ? 'bg-m-act text-m-actfg' : 'text-m-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MoreHorizontal size={19} strokeWidth={2.2} />
|
|
||||||
</button>
|
|
||||||
</nav>}
|
</nav>}
|
||||||
|
|
||||||
<Sheets planner={planner} shell={shell} />
|
<Sheets planner={planner} shell={shell} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronLeft, ChevronRight, FolderSync, List, Plus, Rows3 } from 'lucide-react';
|
import { FolderSync, List, MoreHorizontal, Plus, Rows3 } from 'lucide-react';
|
||||||
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
import { canManageDocSync } from '../../../components/Files/docsync/useDocSync';
|
import { canManageDocSync } from '../../../components/Files/docsync/useDocSync';
|
||||||
import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered';
|
import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered';
|
||||||
@@ -6,7 +6,7 @@ import { useIsWide } from '../../../hooks/sizeClass';
|
|||||||
import { useAuthStore } from '../../../store/authStore';
|
import { useAuthStore } from '../../../store/authStore';
|
||||||
import type { Trip } from '../../../types';
|
import type { Trip } from '../../../types';
|
||||||
import MIconBtn from '../../components/MIconBtn';
|
import MIconBtn from '../../components/MIconBtn';
|
||||||
import { edgeFadeClass, useEdgeFade, type EdgeFade } from '../../components/useEdgeFade';
|
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade';
|
||||||
import type { MTripCollabTab } from './MTripShell';
|
import type { MTripCollabTab } from './MTripShell';
|
||||||
import { collabBoardFor, collabSideBySide } from './tabs/collabPanes';
|
import { collabBoardFor, collabSideBySide } from './tabs/collabPanes';
|
||||||
|
|
||||||
@@ -119,67 +119,6 @@ export function HeaderActions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The day strip's way on past an edge, on a wide window: a round chevron over
|
|
||||||
* the faded end that pages the strip along. The fade alone was too quiet on a
|
|
||||||
* large screen, where a day cut in half at the end ("Wed") read as a mistake
|
|
||||||
* rather than as more to come. The days themselves stay where a screen reader
|
|
||||||
* and the keyboard reach them, so the chevron is for the eye and the finger only.
|
|
||||||
*/
|
|
||||||
export function DayRailSteps({ rail, fade }: { rail: HTMLElement | null; fade: EdgeFade }) {
|
|
||||||
const wide = useIsWide();
|
|
||||||
if (!wide || !rail) return null;
|
|
||||||
// A page along: the day the edge cuts comes to the other edge, whole, so the
|
|
||||||
// next tap continues from there. Read in reading order, so a right-to-left
|
|
||||||
// strip pages the same way round.
|
|
||||||
const step = (toward: 'start' | 'end') => {
|
|
||||||
const rtl = getComputedStyle(rail).direction === 'rtl';
|
|
||||||
const box = rail.getBoundingClientRect();
|
|
||||||
const chips = Array.from(rail.querySelectorAll<HTMLElement>('button'));
|
|
||||||
const pastEnd = (r: DOMRect) => (rtl ? r.left < box.left - 1 : r.right > box.right + 1);
|
|
||||||
const pastStart = (r: DOMRect) => (rtl ? r.right > box.right + 1 : r.left < box.left - 1);
|
|
||||||
const target =
|
|
||||||
toward === 'end'
|
|
||||||
? chips.find((chip) => pastEnd(chip.getBoundingClientRect()))
|
|
||||||
: chips.filter((chip) => pastStart(chip.getBoundingClientRect())).pop();
|
|
||||||
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
|
|
||||||
// 'nearest' on the block axis: the shell root is fixed, anything else scrolls the page behind it.
|
|
||||||
target?.scrollIntoView({
|
|
||||||
inline: toward === 'end' ? 'start' : 'end',
|
|
||||||
block: 'nearest',
|
|
||||||
behavior: reduced ? 'auto' : 'smooth',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const btn =
|
|
||||||
'absolute top-1/2 z-[1] flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheetop)] text-m-ink shadow-[0_4px_12px_-4px_rgba(0,0,0,.35)]';
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{fade.start && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={() => step('start')}
|
|
||||||
className={`${btn} start-[4px]`}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={15} strokeWidth={2.4} className="rtl:-scale-x-100" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{fade.end && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={() => step('end')}
|
|
||||||
className={`${btn} end-[4px]`}
|
|
||||||
>
|
|
||||||
<ChevronRight size={15} strokeWidth={2.4} className="rtl:-scale-x-100" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CollabSegmentProps {
|
interface CollabSegmentProps {
|
||||||
active: MTripCollabTab;
|
active: MTripCollabTab;
|
||||||
onPick: (tab: MTripCollabTab) => void;
|
onPick: (tab: MTripCollabTab) => void;
|
||||||
@@ -284,3 +223,37 @@ export function CompactToggle({ active, onToggle, label }: { active: boolean; on
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phone dock's More. It stands for its sections (chat, notes, files and the
|
||||||
|
* rest) while one is on show, and for itself while open: marked as a ring, as
|
||||||
|
* the main dock's More is, not filled like a tab it would then read as.
|
||||||
|
*/
|
||||||
|
export function DockMoreButton({
|
||||||
|
open,
|
||||||
|
standsFor,
|
||||||
|
onOpen,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
standsFor: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
aria-label={label}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-current={!open && standsFor ? 'page' : undefined}
|
||||||
|
className={`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
|
||||||
|
open || standsFor
|
||||||
|
? 'bg-[color:var(--m-ic)] text-m-ink ring-[1.5px] ring-inset ring-[color:var(--m-act)]'
|
||||||
|
: 'text-m-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={19} strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -345,12 +345,14 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
|||||||
// the overview stack) follows on its own. The band used to float a further 38px up to
|
// the overview stack) follows on its own. The band used to float a further 38px up to
|
||||||
// leave the corner under it to the map credit; the phone map carries no visible credit
|
// leave the corner under it to the map credit; the phone map carries no visible credit
|
||||||
// any more (see mobile.css), so that row would now only be a gap over the dock.
|
// any more (see mobile.css), so that row would now only be a gap over the dock.
|
||||||
|
// A wide window has no dock (the rail stands on the side), so there the floor is
|
||||||
|
// the safe area alone and the controls sit down in the corners.
|
||||||
//
|
//
|
||||||
// The metrics are classes rather than an inline --bottom-nav-h, and the lift is its
|
// The metrics are classes rather than an inline --bottom-nav-h, and the lift is its
|
||||||
// OWN variable folded into them: the compass band below is identified by being the
|
// OWN variable folded into them: the compass band below is identified by being the
|
||||||
// one element that sets that name inline, and a second one would make that ambiguous.
|
// one element that sets that name inline, and a second one would make that ambiguous.
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--m-map-floor:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))] [--bottom-nav-h:var(--m-map-floor)]"
|
className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--m-map-floor:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))] wide:[--m-map-floor:calc(env(safe-area-inset-bottom,0px)+8px+var(--m-stage-lift,0px))] [--bottom-nav-h:var(--m-map-floor)]"
|
||||||
// A stage bar used to stand over the dock for the whole of the road trip tab, and the
|
// A stage bar used to stand over the dock for the whole of the road trip tab, and the
|
||||||
// floor cleared its 61px plus a gap the year round. It is gone, so the only thing
|
// floor cleared its 61px plus a gap the year round. It is gone, so the only thing
|
||||||
// left in that slot is the bar the picker raises while other ways of driving a leg
|
// left in that slot is the bar the picker raises while other ways of driving a leg
|
||||||
|
|||||||
@@ -544,7 +544,8 @@ function TimelineHeader({ tl, dayLabel, openLabel, weatherLabel, onOpenDay, onOp
|
|||||||
className={`${GLASS_CHIP} min-w-[4.5rem] shrink gap-[5px] px-2.5`}
|
className={`${GLASS_CHIP} min-w-[4.5rem] shrink gap-[5px] px-2.5`}
|
||||||
>
|
>
|
||||||
<HotelChipIcon variant={chip.variant} />
|
<HotelChipIcon variant={chip.variant} />
|
||||||
<span className="min-w-0 truncate">{chip.name}</span>
|
{/* Its own direction, so a Latin name in an Arabic header is cut at its end, not its start. */}
|
||||||
|
<span dir="auto" className="min-w-0 truncate">{chip.name}</span>
|
||||||
{chip.time && <span className="flex-none tabular-nums text-m-muted">{chip.time.slice(0, 5)}</span>}
|
{chip.time && <span className="flex-none tabular-nums text-m-muted">{chip.time.slice(0, 5)}</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useMRtCorridor } from './useMRtCorridor'
|
|||||||
import { useMRtAlternatives } from './useMRtAlternatives'
|
import { useMRtAlternatives } from './useMRtAlternatives'
|
||||||
import MRtCorridorBar from './MRtCorridorBar'
|
import MRtCorridorBar from './MRtCorridorBar'
|
||||||
import MRtAlternativesBar from './MRtAlternativesBar'
|
import MRtAlternativesBar from './MRtAlternativesBar'
|
||||||
|
import { MRtTrackEntry } from './MRtTrackSheet'
|
||||||
|
import MRtRouteImport from './MRtRouteImportSheet'
|
||||||
import { RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
|
import { RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
|
||||||
import MBadge from '../../../components/MBadge'
|
import MBadge from '../../../components/MBadge'
|
||||||
import MDancingTrek from '../../../components/MDancingTrek'
|
import MDancingTrek from '../../../components/MDancingTrek'
|
||||||
@@ -289,6 +291,12 @@ function RoadtripStagePage({ planner, shell, dayId: pageDayId, alts, chrome }: O
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<MRtTrackEntry planner={planner} dayId={stage.dayId} />
|
||||||
|
<MRtRouteImport
|
||||||
|
tripId={planner.tripId}
|
||||||
|
dayId={stage.dayId > 0 ? stage.dayId : undefined}
|
||||||
|
canEdit={canEditPlaces && planner.can('day_edit', planner.trip)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{rt.upNext && <UpNext planner={planner} shell={shell} rt={rt} stageDayId={stage.dayId} onOpen={openStop} />}
|
{rt.upNext && <UpNext planner={planner} shell={shell} rt={rt} stageDayId={stage.dayId} onOpen={openStop} />}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user