mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-10 14:20:56 +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
|
||||
|
||||
# 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:-0.0.0}"
|
||||
|
||||
|
||||
@@ -121,6 +121,21 @@ jobs:
|
||||
working-directory: client
|
||||
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
|
||||
# against the watchOS simulator SDK and the rest against the iOS one.
|
||||
- name: Build for the simulator
|
||||
@@ -136,7 +151,10 @@ jobs:
|
||||
# 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
|
||||
# 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:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -193,7 +211,9 @@ jobs:
|
||||
# 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
|
||||
# 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:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
@@ -220,6 +240,21 @@ jobs:
|
||||
xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS
|
||||
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
|
||||
working-directory: client/ios/App
|
||||
run: |
|
||||
|
||||
@@ -1236,10 +1236,12 @@ jobs:
|
||||
bin="$RUNNER_TEMP/bin"
|
||||
mkdir -p "$bin"
|
||||
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 -
|
||||
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 -
|
||||
tar -xzf kubeconform.tgz -C "$bin" kubeconform
|
||||
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
|
||||
# deps are esbuild's binary check, redundant next to its optional platform
|
||||
# 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 && \
|
||||
apt-get upgrade -y --no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
|
||||
libkitinerary-bin libsqlite3-0 && \
|
||||
npm ci --workspace=server --omit=dev --ignore-scripts && \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.1.0
|
||||
version: 5.1.1
|
||||
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.
|
||||
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
// 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());
|
||||
// Android 15's widget picker draws the widgets live from a sample trip.
|
||||
TrekWidgetUpdater.publishPreviews(this);
|
||||
|
||||
@@ -10,6 +10,10 @@ import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
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.JSONObject;
|
||||
|
||||
@@ -18,6 +22,7 @@ final class TrekServerStore {
|
||||
|
||||
private static final String PREFS = "trek_shell";
|
||||
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_RESTART = "restart";
|
||||
private static final String CONFIG_DIR = "trek-server-config";
|
||||
@@ -28,16 +33,39 @@ final class TrekServerStore {
|
||||
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) {
|
||||
setServer(context, url, null);
|
||||
}
|
||||
|
||||
static void setServer(Context context, String url, Set<String> signInHosts) {
|
||||
SharedPreferences.Editor editor = prefs(context).edit();
|
||||
if (url == null) {
|
||||
editor.remove(KEY_URL);
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
static void showShellOnce(Context context) {
|
||||
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("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP));
|
||||
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;
|
||||
if (listener != null) listener.onFile(file);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import com.getcapacitor.JSArray;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.PermissionState;
|
||||
import com.getcapacitor.Plugin;
|
||||
@@ -17,6 +19,8 @@ import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import com.getcapacitor.annotation.Permission;
|
||||
import com.getcapacitor.annotation.PermissionCallback;
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** The native half of client/src/native/trekShell.ts. */
|
||||
@@ -29,6 +33,8 @@ import org.json.JSONObject;
|
||||
)
|
||||
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. */
|
||||
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");
|
||||
return;
|
||||
}
|
||||
TrekServerStore.setUrl(getContext(), url);
|
||||
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
|
||||
call.resolve();
|
||||
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
|
||||
public void resetServer(PluginCall call) {
|
||||
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
|
||||
// the identity provider carries over. The redirect back to the app's
|
||||
// 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
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.webkit.WebResourceError;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebView;
|
||||
import com.getcapacitor.Bridge;
|
||||
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
|
||||
@@ -18,10 +21,35 @@ import com.getcapacitor.BridgeWebViewClient;
|
||||
class TrekWebViewClient extends BridgeWebViewClient {
|
||||
|
||||
private final Runnable onUnreachable;
|
||||
private final Set<String> signInHosts;
|
||||
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) {
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
|
||||
super(bridge);
|
||||
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). */
|
||||
|
||||
@@ -46,6 +46,7 @@ object ShareHandoff {
|
||||
fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching {
|
||||
val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType)
|
||||
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.json").writeText(meta.toString())
|
||||
true
|
||||
|
||||
@@ -26,6 +26,11 @@ data class ShareItem(
|
||||
val title: String,
|
||||
/** A host, for a page. */
|
||||
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 }
|
||||
|
||||
@@ -90,11 +95,16 @@ object ShareLoader {
|
||||
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 {
|
||||
val link = firstLink(text)
|
||||
if (link != null) {
|
||||
runCatching { return page(link, subject) }
|
||||
return linkOnly(link, subject)
|
||||
}
|
||||
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)))
|
||||
@@ -121,12 +131,19 @@ object ShareLoader {
|
||||
val html = String(data, Charsets.UTF_8)
|
||||
val host = runCatching { Uri.parse(link).host }.getOrNull()
|
||||
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 {
|
||||
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. */
|
||||
internal fun classify(name: String, mime: String): Pair<ShareItem.Kind, String> {
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
|
||||
@@ -49,6 +49,16 @@ class NativeLogicTest {
|
||||
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
|
||||
fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() {
|
||||
val today = LocalDate.of(2026, 11, 7)
|
||||
|
||||
@@ -25,6 +25,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
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,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
completionHandler([.banner, .list, .sound])
|
||||
|
||||
@@ -120,6 +120,10 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -110,6 +110,11 @@ enum TrekSharedFiles {
|
||||
"data": data.base64EncodedString(),
|
||||
]
|
||||
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
|
||||
onReceive?(file)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
navigationProxy = proxy
|
||||
webView.navigationDelegate = proxy
|
||||
}
|
||||
guardBridgeMessages()
|
||||
bridge?.registerPluginInstance(TrekShellPlugin())
|
||||
// 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
|
||||
@@ -51,6 +52,22 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
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
|
||||
/// 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.
|
||||
@@ -137,14 +154,43 @@ enum TrekKeyCommands {
|
||||
enum TrekServerStore {
|
||||
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? {
|
||||
get { UserDefaults.standard.string(forKey: key) }
|
||||
set {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
return support.appendingPathComponent("offline-start-page.html")
|
||||
@@ -391,6 +437,23 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
(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) {
|
||||
if Self.isReplaced(error) { return }
|
||||
inner.webView?(webView, didFail: navigation, withError: error)
|
||||
@@ -401,3 +464,26 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
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
|
||||
}
|
||||
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()
|
||||
restartBridge()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import CryptoKit
|
||||
import Foundation
|
||||
import UIKit
|
||||
import WatchConnectivity
|
||||
import WebKit
|
||||
|
||||
/// The phone's end of WatchConnectivity (feature "watch"). The UI hands over the
|
||||
/// watch's snapshot (client/src/native/watch.ts through TrekShell.setWatchSnapshot);
|
||||
@@ -93,6 +94,11 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
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
|
||||
|
||||
/// The ticks queued so far, oldest first, and the queue emptied.
|
||||
@@ -110,6 +116,25 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
|
||||
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]) {
|
||||
queue.async {
|
||||
// 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] = [:]) {
|
||||
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) {
|
||||
if let action = message["action"] as? [String: Any] {
|
||||
receive(action) { replyHandler(["queued": true]) }
|
||||
return
|
||||
}
|
||||
queue.async {
|
||||
if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) {
|
||||
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 {
|
||||
var meta: [String: Any] = ["name": item.name, "mimeType": item.mimeType]
|
||||
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 }
|
||||
return write(dataAccount, item.data) && write(metaAccount, metaData)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ struct ShareItem {
|
||||
var title: String
|
||||
/// A host, for a page.
|
||||
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 {
|
||||
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 trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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
|
||||
return try checked(ShareItem(kind: .text, name: "shared-text.txt", mimeType: "text/plain",
|
||||
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 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",
|
||||
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 {
|
||||
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)
|
||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
(http.mimeType ?? "").contains("html") else { throw ShareLoadError.unsupported }
|
||||
let html = String(decoding: data, as: UTF8.self)
|
||||
let title = pageTitle(html) ?? url.host ?? "Page"
|
||||
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
|
||||
data: data, title: title, source: url.host))
|
||||
if let fetched = try? await URLSession.shared.data(for: request),
|
||||
let http = fetched.1 as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
(http.mimeType ?? "").contains("html"), !fetched.0.isEmpty, fetched.0.count <= maxBytes {
|
||||
let data = fetched.0
|
||||
let html = String(decoding: data, as: UTF8.self)
|
||||
let title = pageTitle(html) ?? url.host ?? "Page"
|
||||
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 {
|
||||
|
||||
@@ -100,9 +100,27 @@ final class WatchLink: NSObject, WCSessionDelegate {
|
||||
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) {
|
||||
guard WCSession.default.activationState == .activated else { return }
|
||||
WCSession.default.transferUserInfo(["action": action.message])
|
||||
let session = WCSession.default
|
||||
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).
|
||||
|
||||
@@ -66,12 +66,12 @@ wait_ready() {
|
||||
|
||||
TOKEN=0
|
||||
# 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() {
|
||||
local udid=$1 file=$2 screen=$3 scenario=$4 clock=$5 lang=$6
|
||||
local locale=${lang//-/_} attempt
|
||||
for attempt in 0 2 5 10; do
|
||||
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s"; sleep "$attempt"; }
|
||||
local locale=${lang//-/_} attempt failed=
|
||||
for attempt in 0 2 5 10 20; do
|
||||
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s ($failed)"; sleep "$attempt"; }
|
||||
TOKEN=$((TOKEN + 1))
|
||||
xcrun simctl terminate "$udid" "$BUNDLE" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
@@ -79,11 +79,12 @@ shot() {
|
||||
[ "$clock" = - ] || extra=(-TrekNow "$clock")
|
||||
xcrun simctl launch "$udid" "$BUNDLE" \
|
||||
-AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" \
|
||||
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || continue
|
||||
wait_ready "$udid" "$TOKEN" || continue
|
||||
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || { failed=launch; continue; }
|
||||
wait_ready "$udid" "$TOKEN" || { failed='not ready'; continue; }
|
||||
# The map's tiles come over the network after the screen is up.
|
||||
[ "$screen" = place ] && sleep 6
|
||||
xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 && return 0
|
||||
failed=screenshot
|
||||
done
|
||||
echo "gave up on $(basename "$file")"
|
||||
}
|
||||
|
||||
+96
-15
@@ -1,3 +1,4 @@
|
||||
import { App } from '@capacitor/app';
|
||||
import { Capacitor, CapacitorHttp } from '@capacitor/core';
|
||||
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
|
||||
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 logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light 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 { 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 { TrekShell } from '../src/native/trekShell';
|
||||
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.
|
||||
const get: ProbeGet = async (url) => {
|
||||
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 {
|
||||
@@ -61,6 +71,8 @@ function failureMessage(reason: ProbeFailure): string {
|
||||
return t('native.connect.errorUnreachable');
|
||||
case 'notTrek':
|
||||
return t('native.connect.errorNotTrek');
|
||||
case 'authGate':
|
||||
return t('native.gate.titleUnknown');
|
||||
case 'tooOld':
|
||||
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 submit = el('button', { type: 'submit', className: 'btn' }, label);
|
||||
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);
|
||||
field.classList.add('bad');
|
||||
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;
|
||||
submit.disabled = false;
|
||||
label.textContent = t('native.connect.submit');
|
||||
@@ -268,6 +286,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
event.preventDefault();
|
||||
void tap(ImpactStyle.Medium);
|
||||
error.textContent = '';
|
||||
gatePanel.hidden = true;
|
||||
gatePanel.replaceChildren();
|
||||
input.disabled = true;
|
||||
submit.disabled = true;
|
||||
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);
|
||||
return;
|
||||
}
|
||||
fail(result.reason);
|
||||
fail(result.reason, result.gate, result.url);
|
||||
});
|
||||
|
||||
show(
|
||||
@@ -297,6 +317,43 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
function feature(path: string, text: string): HTMLElement {
|
||||
return el(
|
||||
@@ -429,12 +486,14 @@ function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): {
|
||||
|
||||
async function loadOffer(list: CloudPlans): Promise<Offer> {
|
||||
const available = await iapReady;
|
||||
// Closed: a store purchase would be refused at the cloud, so only a test build may buy.
|
||||
const opensSoon = available && list.closed && !(await iap.isSandbox().catch(() => false));
|
||||
const canBuy = available && !opensSoon;
|
||||
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon };
|
||||
// Closed: a real purchase is refused at the cloud, so only a test build may buy on iOS;
|
||||
// Android keeps the button for the licence testers (see cloudOrders.ts).
|
||||
const sandbox = list.closed && !onPlay ? await iap.isSandbox().catch(() => false) : false;
|
||||
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')]);
|
||||
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 {
|
||||
@@ -526,7 +585,10 @@ function planFooter(offer: Offer, plans: CloudPlan[]): Node[] {
|
||||
void notify(NotificationType.Error);
|
||||
restore.disabled = false;
|
||||
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:
|
||||
@@ -838,7 +900,9 @@ function showClaim(
|
||||
slugNote.textContent = t('native.claim.taken');
|
||||
slugNote.className = 'hint bad';
|
||||
} 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. */
|
||||
async function connected(url: string): Promise<void> {
|
||||
async function connected(url: string, signInHosts: string[] = []): Promise<void> {
|
||||
void notify(NotificationType.Success);
|
||||
const host = (() => {
|
||||
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 })))
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
await TrekShell.setServer({ url });
|
||||
await TrekShell.setServer(signInHosts.length ? { url, signInHosts } : { url });
|
||||
}
|
||||
|
||||
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> {
|
||||
void paintStatusBar();
|
||||
followBackGesture();
|
||||
let url: string | null;
|
||||
try {
|
||||
({ url } = await TrekShell.getServer());
|
||||
|
||||
@@ -103,6 +103,19 @@
|
||||
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
|
||||
--grid: rgba(255, 255, 255, 0.055);
|
||||
--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);
|
||||
--veil-top: rgba(9, 9, 11, 0.4);
|
||||
--veil-mid: rgba(9, 9, 11, 0.72);
|
||||
@@ -135,6 +148,17 @@
|
||||
--ok: #16a34a;
|
||||
--grid: rgba(17, 24, 39, 0.075);
|
||||
--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);
|
||||
--veil-top: rgba(255, 255, 255, 0.12);
|
||||
--veil-mid: rgba(255, 255, 255, 0.74);
|
||||
@@ -398,16 +422,16 @@ body::after {
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
animation: floaty 6s ease-in-out infinite;
|
||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
||||
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
|
||||
drop-shadow(0 6px 16px var(--ink-a22));
|
||||
}
|
||||
|
||||
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
|
||||
.hero-logo {
|
||||
display: block;
|
||||
margin-bottom: 22px;
|
||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
||||
filter: drop-shadow(0 14px 36px var(--wash-1-a90))
|
||||
drop-shadow(0 6px 16px var(--ink-a22));
|
||||
animation: logoRise 0.7s var(--ease) both;
|
||||
}
|
||||
.hero-logo img {
|
||||
@@ -564,7 +588,7 @@ body::after {
|
||||
border-color: var(--edge);
|
||||
box-shadow:
|
||||
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 {
|
||||
background: var(--surface-2);
|
||||
@@ -689,7 +713,7 @@ a.linkbtn {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
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:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.25s;
|
||||
@@ -701,11 +725,11 @@ a.linkbtn {
|
||||
}
|
||||
.field input:focus {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
@keyframes shake {
|
||||
@@ -729,11 +753,48 @@ a.linkbtn {
|
||||
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 {
|
||||
width: 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-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
@@ -788,7 +849,7 @@ a.linkbtn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ok) 16%, transparent);
|
||||
background: var(--ok-a16);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 8px;
|
||||
@@ -889,11 +950,11 @@ a.linkbtn {
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ok) 22%, transparent);
|
||||
background: var(--ok-a22);
|
||||
color: var(--ok);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -909,7 +970,7 @@ a.linkbtn {
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
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:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s,
|
||||
@@ -917,7 +978,7 @@ a.linkbtn {
|
||||
}
|
||||
.plan.featured {
|
||||
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);
|
||||
}
|
||||
.plan:active {
|
||||
@@ -1025,7 +1086,7 @@ a.linkbtn {
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
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:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.25s;
|
||||
@@ -1046,11 +1107,11 @@ a.linkbtn {
|
||||
}
|
||||
.slug-field:focus-within {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
.slug-suffix {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.1.0",
|
||||
"version": "5.1.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 375,
|
||||
"@typescript-eslint/no-misused-promises": 845,
|
||||
"@typescript-eslint/no-misused-promises": 843,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
"@typescript-eslint/no-explicit-any": 144,
|
||||
|
||||
@@ -669,6 +669,12 @@
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.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",
|
||||
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
|
||||
@@ -828,6 +834,12 @@
|
||||
"desktop": ["src/components/shared/Toast.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",
|
||||
"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/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/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/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.",
|
||||
@@ -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/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/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/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/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.",
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"components/Map/DawarichTrailPill.tsx": 1,
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 6,
|
||||
"components/Map/MapViewGL.tsx": 8,
|
||||
"components/Map/MapView.tsx": 5,
|
||||
"components/Map/MapViewGL.tsx": 7,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
"components/Map/PoiCategoryPill.tsx": 1,
|
||||
@@ -34,6 +34,7 @@
|
||||
"components/Map/locationMarkerMapbox.ts": 2,
|
||||
"components/Map/nightPauseMarker.ts": 4,
|
||||
"components/Map/ratingBadge.ts": 1,
|
||||
"components/Map/switcherRow.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 7,
|
||||
"components/PDF/tripMapImage.ts": 1,
|
||||
"components/Packing/PackingListPanelBagCard.tsx": 1,
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||
"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.tsx": 2127,
|
||||
"src/components/Map/MapViewGL.tsx": 2126,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapPluginMarkers.tsx": 2,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 8,
|
||||
"components/Map/MapViewGL.tsx": 11,
|
||||
"components/Map/MapView.tsx": 7,
|
||||
"components/Map/MapViewGL.tsx": 10,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
"components/Map/ReservationOverlay.tsx": 5,
|
||||
@@ -71,6 +71,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Map/switcherRow.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 51,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
|
||||
@@ -36,6 +36,7 @@ import NewVersionNotice from './components/Layout/NewVersionNotice'
|
||||
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
|
||||
import AppLockGate from './components/Layout/AppLockGate'
|
||||
import NativeImportSheet from './mobile/components/NativeImportSheet'
|
||||
import MPushPrompt from './mobile/components/MPushPrompt'
|
||||
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
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:app-lock" fallback={null}><AppLockGate /></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>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
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();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-008: nothing renders while the fetch is still in flight', async () => {
|
||||
render(<CollectionsWidget onOpen={() => {}} />);
|
||||
it('FE-COMP-COLWIDGET-008: only a placeholder shows while the fetch is still in flight', async () => {
|
||||
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
expect(screen.queryByText('No saved places yet')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('No saved places yet')).toBeInTheDocument());
|
||||
// Nothing remembered yet: the empty hint's shape, hidden from assistive tech, holds the place.
|
||||
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 { useNavigate } from 'react-router'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { entityGradient } from '../../utils/gradients'
|
||||
import { useCollectionLists } from './useCollectionLists'
|
||||
import { readShownCollections, rememberShownCollections } from './collectionsWidgetMemory'
|
||||
|
||||
/**
|
||||
* 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 navigate = useNavigate()
|
||||
const { lists, loading } = useCollectionLists()
|
||||
const [expected] = useState(readShownCollections)
|
||||
useEffect(() => {
|
||||
if (!loading) rememberShownCollections(Math.min(lists.length, 6))
|
||||
}, [loading, lists.length])
|
||||
|
||||
return (
|
||||
<div className="tool">
|
||||
@@ -25,7 +30,15 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</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-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 { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -13,7 +13,7 @@ const RATES = [
|
||||
const updateSetting = vi.fn<(key: string, value: unknown) => Promise<void>>();
|
||||
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) => ({
|
||||
isLoaded,
|
||||
updateSetting: updateSetting as unknown as typeof s.updateSetting,
|
||||
@@ -133,4 +133,33 @@ describe('useCurrencyConverter', () => {
|
||||
});
|
||||
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(() => {});
|
||||
};
|
||||
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 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();
|
||||
const controller = new AbortController();
|
||||
inFlight.current = controller;
|
||||
@@ -37,18 +45,22 @@ export function useCurrencyConverter() {
|
||||
if (signal.aborted) return;
|
||||
if (!Array.isArray(d)) {
|
||||
setRates(null);
|
||||
setFailedBase(from);
|
||||
return;
|
||||
}
|
||||
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
|
||||
const map: Record<string, number> = { [from]: 1 };
|
||||
for (const r of d) map[r.quote] = r.rate;
|
||||
setRates(map);
|
||||
setFailedBase(null);
|
||||
setRates({ base: from, map });
|
||||
})
|
||||
.catch(() => {
|
||||
// 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(() => {
|
||||
fetchRates();
|
||||
@@ -78,13 +90,32 @@ export function useCurrencyConverter() {
|
||||
});
|
||||
}, [isLoaded, updateSetting]);
|
||||
|
||||
const currencies: string[] = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES;
|
||||
const rate = rates?.[to] ?? null;
|
||||
const current = rates?.base === from ? rates.map : 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 swap = () => {
|
||||
setFrom(to);
|
||||
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 JourneyMap, { type JourneyMapHandle } from './JourneyMap';
|
||||
import JourneyMapAuto from './JourneyMapAuto';
|
||||
@@ -27,6 +28,13 @@ interface Props extends MapProps {
|
||||
/** Leads from a picked place or stay to its entry. */
|
||||
onOpenEntry?: (entryId: string) => void;
|
||||
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,
|
||||
onOpenEntry,
|
||||
entryTitleOf,
|
||||
onWriteAboutPlace,
|
||||
photos,
|
||||
tracks,
|
||||
height = 220,
|
||||
@@ -55,6 +64,13 @@ export default function JourneyLayeredMap({
|
||||
setPicked({ marks, key: marks.map((m) => m.key).join(',') });
|
||||
}, []);
|
||||
const close = useCallback(() => setPicked(null), []);
|
||||
const writeAbout = useCallback(
|
||||
(place: JourneyMapPlace) => {
|
||||
setPicked(null);
|
||||
onWriteAboutPlace?.(place);
|
||||
},
|
||||
[onWriteAboutPlace]
|
||||
);
|
||||
const openEntry = useCallback(
|
||||
(entryId: string) => {
|
||||
setPicked(null);
|
||||
@@ -99,6 +115,7 @@ export default function JourneyLayeredMap({
|
||||
marks={card.marks}
|
||||
onClose={close}
|
||||
onOpenEntry={onOpenEntry ? openEntry : undefined}
|
||||
onWriteAbout={offeredIf(onWriteAboutPlace, writeAbout)}
|
||||
entryTitleOf={entryTitleOf}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -86,4 +86,23 @@ describe('JourneyMapPickCard', () => {
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
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 { 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 { useTranslation } from '../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../Planner/planParts';
|
||||
@@ -14,6 +14,8 @@ interface Props {
|
||||
onOpenEntry?: (entryId: string) => void;
|
||||
/** The title of an entry the reader can see, for the stay that belongs to it. */
|
||||
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 {
|
||||
@@ -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();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -56,9 +66,22 @@ function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEnt
|
||||
{place.entry_id !== null ? (
|
||||
<OpenEntry entryId={place.entry_id} onOpenEntry={onOpenEntry} />
|
||||
) : (
|
||||
<p className="text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('journey.map.pick.notWritten')}
|
||||
</p>
|
||||
<>
|
||||
<p className="text-content-muted" style={fs(11.5, 'body')}>
|
||||
{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>
|
||||
);
|
||||
@@ -106,7 +129,7 @@ function markName(mark: JourneyMapMark): string {
|
||||
* one spot come as a list first, each with what kind it is, so overlapping
|
||||
* 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 [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} />
|
||||
</button>
|
||||
</div>
|
||||
{picked?.kind === 'plan' && <PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} />}
|
||||
{picked?.kind === 'plan' && (
|
||||
<PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} onWriteAbout={onWriteAbout} />
|
||||
)}
|
||||
{picked?.kind === 'stay' && (
|
||||
<StayDetail stay={picked.stay} onOpenEntry={onOpenEntry} entryTitleOf={entryTitleOf} />
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import JourneyDayScrubber from './JourneyDayScrubber'
|
||||
import { dayColorOf } from './journeyCard'
|
||||
import { useJourneyCarouselSync } from './useJourneyCarouselSync'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import type { JourneyMapPlace, JourneyTrack } from '@trek/shared'
|
||||
|
||||
interface MapEntry {
|
||||
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. */
|
||||
layers?: JourneyMapLayersView | 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. */
|
||||
photos?: MapPhoto[]
|
||||
onPhotoClick?: (photoIds: string[]) => void
|
||||
@@ -64,6 +66,7 @@ export default function MobileMapTimeline({
|
||||
initialEntryId,
|
||||
layers,
|
||||
entryTitleOf,
|
||||
onWriteAboutPlace,
|
||||
photos,
|
||||
onPhotoClick,
|
||||
controlsTop,
|
||||
@@ -118,6 +121,7 @@ export default function MobileMapTimeline({
|
||||
hideLayerControl
|
||||
controlsTop={controlsTop}
|
||||
onOpenEntry={handleMarkerClick}
|
||||
onWriteAboutPlace={onWriteAboutPlace}
|
||||
entryTitleOf={entryTitleOf}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
@@ -160,6 +164,7 @@ export default function MobileMapTimeline({
|
||||
hideLayerControl
|
||||
controlsTop={controlsTop}
|
||||
onOpenEntry={handleMarkerClick}
|
||||
onWriteAboutPlace={onWriteAboutPlace}
|
||||
entryTitleOf={entryTitleOf}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { TrekShell } from '../../native/trekShell';
|
||||
import { applyWatchActions, buildWatchSnapshot, publishWatch, takeWatchActions, watchActive } from '../../native/watch';
|
||||
import { onWatchListsChange } from '../../native/watchListChanges';
|
||||
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
|
||||
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
|
||||
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
|
||||
@@ -229,8 +230,11 @@ export default function NativeWidgetsSync(): null {
|
||||
if (isActive) void sync().then(drainWatch);
|
||||
});
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
stopListWatch?.();
|
||||
window.clearInterval(timer);
|
||||
void listener.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('')
|
||||
})
|
||||
})
|
||||
|
||||
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 type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
||||
import { PisteKeyButton } from '../Tours/winter/PisteKey'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { ComponentType, CSSProperties } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||
@@ -11,6 +11,7 @@ import { Tooltip } from '../shared/Tooltip'
|
||||
import { FROSTED_SHELL, segmentStyle } from './mapControlShadow'
|
||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
|
||||
import { useLabelFit } from './useLabelFit'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
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. */
|
||||
export const MAP_LAYER_SWITCHER_INSET = 20
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -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.
|
||||
* `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
|
||||
* 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({
|
||||
active,
|
||||
@@ -271,7 +293,8 @@ export function TourMapLayerSwitcher({
|
||||
ferrataLayer,
|
||||
placeTool,
|
||||
layers,
|
||||
compact = false,
|
||||
compact: compactProp,
|
||||
vertical = false,
|
||||
}: {
|
||||
active: TourBaseLayer
|
||||
onChange: (layer: TourBaseLayer) => void
|
||||
@@ -285,15 +308,23 @@ export function TourMapLayerSwitcher({
|
||||
/** Offers the "set points" switch, while a tour is being edited. */
|
||||
placeTool?: MapPlaceTool
|
||||
compact?: boolean
|
||||
vertical?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { attachFit, compact, layout } = useSwitcherLayout(compactProp, vertical)
|
||||
const bases = layers?.bases
|
||||
const overlays = layers?.overlays
|
||||
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
|
||||
const health = layers?.baseHealth
|
||||
|
||||
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 }) => {
|
||||
const selected = active === id
|
||||
const label = t(labelKey)
|
||||
@@ -321,7 +352,7 @@ export function TourMapLayerSwitcher({
|
||||
{health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')}
|
||||
</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} />}
|
||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} 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 { useSettingsStore } from '../../store/settingsStore'
|
||||
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 type { ViewportPadding } from '../../utils/mapViewport'
|
||||
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
|
||||
smaller gap its shadow ran into the sidebar edge and the two read as one
|
||||
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} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
|
||||
|
||||
@@ -40,7 +40,10 @@ vi.mock('../../hooks/useNetworkMode', () => ({
|
||||
useNetworkMode: () => ({ offline: env.offline, forced: false, setForced: () => {} }),
|
||||
}));
|
||||
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) => ({
|
||||
...(await importOriginal<typeof import('../../native/platform')>()),
|
||||
isNativeIos: () => false,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { OFM_POSITRON } from '../../constants/mapDefaults'
|
||||
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
|
||||
// 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.
|
||||
const nativeStore = tileCacheActive()
|
||||
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 glProvider = glFallback ? 'maplibre-gl' : configuredGl
|
||||
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 { reportGlViewport } from './glViewport'
|
||||
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 { useMarkerThumbs } from './useMarkerThumbs'
|
||||
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
|
||||
covers. */}
|
||||
<div style={{
|
||||
position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, zIndex: 1000, pointerEvents: 'none',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
...switcherRowStyle(leftWidth, rightWidth),
|
||||
bottom: isMobile && hasDayDetail
|
||||
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* change, and what is left behind on teardown.
|
||||
*/
|
||||
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 { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
|
||||
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
|
||||
// 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() }))
|
||||
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('@maplibre/maplibre-gl-leaflet', () => {
|
||||
const maplibreGL = vi.fn(() => {
|
||||
@@ -47,6 +49,8 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => {
|
||||
const gl = {
|
||||
setStyle: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
off: vi.fn(),
|
||||
isStyleLoaded: vi.fn(() => false),
|
||||
getStyle: vi.fn(() => ({
|
||||
layers: [
|
||||
@@ -73,6 +77,8 @@ type Spy = ReturnType<typeof vi.fn>
|
||||
interface MockGl {
|
||||
setStyle: Spy
|
||||
on: Spy
|
||||
once: Spy
|
||||
off: Spy
|
||||
isStyleLoaded: Spy
|
||||
getStyle: Spy
|
||||
setLayoutProperty: Spy
|
||||
@@ -124,6 +130,42 @@ describe('VectorBasemap', () => {
|
||||
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 () => {
|
||||
const { unmount } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
|
||||
|
||||
@@ -11,6 +11,8 @@ import { hasWebGL, reportWebGLFailure } from '../../utils/webgl'
|
||||
import { isGpuInitFailure } from './glSupport'
|
||||
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
|
||||
import { offlineStyle } from '../../native/offlineTiles'
|
||||
import { isNativeIos } from '../../native/platform'
|
||||
import { nativeRasterTemplate } from '../../native/tileCache'
|
||||
|
||||
/** The Leaflet layer maplibre-gl-leaflet hands back. */
|
||||
export type GlLeafletLayer = InstanceType<typeof MaplibreGL>
|
||||
@@ -107,13 +109,43 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
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,
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
} 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
|
||||
// still fail once the page has spent its context budget, and that has to reach
|
||||
// the render or the raster tiles never appear.
|
||||
const [glUsable, setGlUsable] = useState(hasWebGL)
|
||||
const [glUsable, setGlUsable] = useState(vectorBasemapDraws)
|
||||
|
||||
useEffect(() => {
|
||||
if (!glUsable) return
|
||||
@@ -201,6 +233,14 @@ export function VectorBasemap({ style }: { style: string }) {
|
||||
return
|
||||
}
|
||||
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 () => {
|
||||
@@ -222,7 +262,7 @@ export function VectorBasemap({ style }: { style: string }) {
|
||||
return (
|
||||
<TileLayer
|
||||
key="webgl-fallback"
|
||||
url={RASTER_FALLBACK_TILE_URL}
|
||||
url={nativeRasterTemplate(RASTER_FALLBACK_TILE_URL)}
|
||||
attribution={attributionForTile(RASTER_FALLBACK_TILE_URL)}
|
||||
maxZoom={RASTER_FALLBACK_MAX_ZOOM}
|
||||
keepBuffer={8}
|
||||
@@ -256,7 +296,7 @@ export async function attachVectorBasemap(
|
||||
): Promise<void> {
|
||||
// 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).
|
||||
if (!hasWebGL()) {
|
||||
if (!vectorBasemapDraws()) {
|
||||
if (!cancelled()) ref.current = attachRasterFallback(map)
|
||||
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', () => {
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -569,7 +569,9 @@ describe('ArtikelZeile — weight and bag', () => {
|
||||
)
|
||||
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 }))
|
||||
})
|
||||
@@ -587,7 +589,9 @@ describe('ArtikelZeile — weight and bag', () => {
|
||||
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 }))
|
||||
})
|
||||
@@ -596,7 +600,9 @@ describe('ArtikelZeile — weight and bag', () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
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))
|
||||
})
|
||||
@@ -609,6 +615,7 @@ describe('ArtikelZeile — weight and bag', () => {
|
||||
const weight = container.querySelector<HTMLInputElement>('input[placeholder="—"]')!
|
||||
expect(weight.readOnly).toBe(true)
|
||||
fireEvent.change(weight, { target: { value: '450' } })
|
||||
fireEvent.blur(weight)
|
||||
fireEvent.click(bagButton(container))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: /Carry-on/ })).toBeNull())
|
||||
@@ -929,6 +936,7 @@ describe('ArtikelZeile — overflow menu', () => {
|
||||
|
||||
const weight = fieldOf(container, 'Total weight')
|
||||
fireEvent.change(weight, { target: { value: '900' } })
|
||||
fireEvent.blur(weight)
|
||||
await waitFor(() => expect(bodies[0]).toMatchObject({ weight_grams: 900 }))
|
||||
|
||||
// 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.change(fieldOf(container, 'Total weight'), { target: { value: '900' } })
|
||||
fireEvent.blur(fieldOf(container, 'Total weight'))
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined))
|
||||
|
||||
@@ -995,6 +1004,7 @@ describe('ArtikelZeile — overflow menu', () => {
|
||||
fireEvent.click(overflowTrigger(container))
|
||||
|
||||
fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '' } })
|
||||
fireEvent.blur(fieldOf(container, 'Total weight'))
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ weight_grams: null }))
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ import PackingShareControl from './PackingShareControl'
|
||||
import { PopoverItem } from './PackingPopover'
|
||||
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { WeightInput } from './PackingListPanelWeightInput'
|
||||
import { bringersOf, packingBringerNames } from './packingBringers'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { BringerAvatar, BringersCard, MoreBringers } from './PackingBringersCard'
|
||||
@@ -109,6 +109,10 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
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 selectedBag = bags.find(b => b.id === item.bag_id)
|
||||
const quantity = item.quantity || 1
|
||||
@@ -251,17 +255,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
{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) }}>
|
||||
<NumericInput
|
||||
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 }}
|
||||
/>
|
||||
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={36} onSave={saveWeight} />
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
|
||||
</div>
|
||||
<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' }}>
|
||||
<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' }}>
|
||||
<NumericInput
|
||||
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 }}
|
||||
/>
|
||||
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={42} onSave={saveWeight} />
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
|
||||
</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 { 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 CustomSelect from '../shared/CustomSelect'
|
||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useGoogleRouteImport } from './useGoogleRouteImport'
|
||||
|
||||
export default function GoogleRouteImport({ tripId, dayId }: { 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('')
|
||||
const { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit } = useGoogleRouteImport(tripId, dayId)
|
||||
const menu = useContextMenu()
|
||||
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 <>
|
||||
<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"
|
||||
@@ -84,7 +41,7 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<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')}
|
||||
</DialogButton>
|
||||
</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"
|
||||
style={fs(13, 'body')} /> : <>
|
||||
<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} />
|
||||
</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">
|
||||
|
||||
@@ -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 '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
import '../../styles/studio-floating.css'
|
||||
import './bookFontFaces'
|
||||
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]);
|
||||
expect(columns).toEqual([
|
||||
'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)',
|
||||
]);
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useSettingsStore } from '../../../store/settingsStore'
|
||||
import { useTourFerrataCrossingStore } from '../../../store/tourFerrataCrossingStore'
|
||||
import { formatDistance } from '../../../utils/units'
|
||||
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 { useToast } from '../../shared/Toast'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
@@ -74,7 +75,6 @@ import { SkiPlannerSummary } from '../ski/SkiRouteSummary'
|
||||
import { walkTimeFields } from '../walk/walkTimeFields'
|
||||
|
||||
/** 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. */
|
||||
export function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) {
|
||||
@@ -136,10 +136,11 @@ function NeutralRail({
|
||||
canEdit: boolean
|
||||
onOpenWanderer?: () => void
|
||||
}) {
|
||||
const railHead = useRailHeadProps({ bare: true })
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<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
|
||||
type="button"
|
||||
disabled={!canEdit}
|
||||
@@ -207,6 +208,7 @@ function GpxViewRail({
|
||||
paddleChecklist?: PaddleChecklist
|
||||
skiClassification?: SkiClassification
|
||||
}) {
|
||||
const railHead = useRailHeadProps()
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit)
|
||||
const { readOnlyGpxTour } = planner
|
||||
@@ -226,7 +228,7 @@ function GpxViewRail({
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
label={t('tours.planner.backToTours')}
|
||||
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). */
|
||||
tourPlaceIds?: ReadonlySet<number>
|
||||
} & TourPermissionProps) {
|
||||
const railHead = useRailHeadProps()
|
||||
const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
|
||||
const { t } = useTranslation()
|
||||
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
||||
@@ -364,7 +367,7 @@ export function TourPlannerRail({ planner,
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<ArrowLeft size={16} strokeWidth={2} />
|
||||
</BarButton>
|
||||
@@ -673,6 +676,7 @@ function stagesFold(tour: TourListItem, rail: ReturnType<typeof useTourStageRail
|
||||
|
||||
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour,
|
||||
gpxImport, onTakenOver, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
|
||||
const railHead = useRailHeadProps()
|
||||
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -734,7 +738,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
|
||||
return (
|
||||
<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')} />
|
||||
{canEdit && <TourAddMenu planner={planner} gpxImport={gpxImport} onTakenOver={onTakenOver} disabled={isSaving} />}
|
||||
</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
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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 TourTerrainMap, { type TourTerrainMapProps } from './TourTerrainMap';
|
||||
|
||||
@@ -257,6 +258,17 @@ describe('TourTerrainMap', () => {
|
||||
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 () => {
|
||||
const onAdd = vi.fn();
|
||||
const { rerender } = await renderTerrain({ waypoints: { points: [], selectedId: null, onSelect: vi.fn(), onAdd } });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FerrataBbox } from '@trek/shared';
|
||||
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 { useIsWide } from '../../../hooks/sizeClass';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
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 stats = useMemo(() => terrainStats(samples), [samples]);
|
||||
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
|
||||
const touchShell = useIsWide();
|
||||
const legend =
|
||||
terrain.canReplay && !props.water ? (
|
||||
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} />
|
||||
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} compact={fit === 'side'} />
|
||||
) : null;
|
||||
const side = fit === 'side';
|
||||
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(() => {
|
||||
if (controlsHeight !== null) onControlsHeightChange?.(controlsHeight);
|
||||
@@ -112,35 +127,38 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
||||
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} />
|
||||
{legendAbove && legend}
|
||||
<div ref={attachRow} className="flex w-full items-end gap-3" data-fit={fit} data-testid="terrain-control-row">
|
||||
<div className="flex-none">
|
||||
<TourMapLayerSwitcher
|
||||
active={baseLayer}
|
||||
onChange={onBaseLayerChange}
|
||||
terrainActive
|
||||
onToggleTerrain={onExit}
|
||||
ferrataLayer={props.ferrataLayer}
|
||||
placeTool={props.placeTool}
|
||||
layers={props.tourLayers}
|
||||
compact={fit !== 'full'}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
|
||||
<span />
|
||||
{terrain.canReplay ? (
|
||||
<TerrainDock terrain={terrain} water={props.water} />
|
||||
) : (
|
||||
<div
|
||||
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
|
||||
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 className="flex-none">{layerSwitcher}</div>
|
||||
{!side && (
|
||||
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
|
||||
<span />
|
||||
{terrain.canReplay ? (
|
||||
<TerrainDock terrain={terrain} water={props.water} />
|
||||
) : touchShell ? (
|
||||
// A tablet's map is short on room, and its user learns the gestures by touch: no how-to line.
|
||||
<span />
|
||||
) : (
|
||||
<div
|
||||
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
|
||||
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>
|
||||
@@ -206,21 +224,89 @@ function IconButton({
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />;
|
||||
function Divider({ vertical = false }: { vertical?: boolean }) {
|
||||
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). */
|
||||
function TerrainDock({
|
||||
terrain,
|
||||
water,
|
||||
vertical = false,
|
||||
}: {
|
||||
terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'>;
|
||||
terrain: TerrainControls;
|
||||
water?: boolean;
|
||||
/** A column of icons on the map's trailing edge (the `side` fit). */
|
||||
vertical?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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 ? (
|
||||
<PreloadRing
|
||||
share={terrain.preloaded}
|
||||
@@ -241,6 +327,16 @@ function TerrainDock({
|
||||
<IconButton label={t('tours.terrain.overview')} onClick={terrain.overview}>
|
||||
<Scan size={15} strokeWidth={2} />
|
||||
</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 />
|
||||
<div className="flex items-center" role="group" aria-label={t('tours.terrain.speed')}>
|
||||
{SPEEDS.map((speed) => (
|
||||
@@ -275,7 +371,7 @@ function TerrainDock({
|
||||
</label>
|
||||
</Tooltip>
|
||||
{!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 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({
|
||||
stats,
|
||||
unit,
|
||||
onLayout,
|
||||
compact = false,
|
||||
}: {
|
||||
stats: TerrainStats;
|
||||
unit: DistanceUnit;
|
||||
onLayout?: (layout: TerrainLegendLayout | null) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -373,9 +497,14 @@ function GradeLegend({
|
||||
<div
|
||||
ref={panelRef}
|
||||
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}
|
||||
>
|
||||
{compact && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
<LegendFacts stats={stats} unit={unit} />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex flex-col gap-1">
|
||||
{GRADE_BANDS.map((band) => (
|
||||
<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-controls="tour-terrain-grades"
|
||||
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}
|
||||
>
|
||||
<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) => (
|
||||
<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>
|
||||
{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 && <LegendFacts stats={stats} unit={unit} />}
|
||||
<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} />}
|
||||
</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 { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type ControlRowFit, useControlRowFit } from './useControlRowFit';
|
||||
|
||||
/** 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 {
|
||||
const row = document.createElement('div');
|
||||
@@ -49,4 +49,21 @@ describe('useControlRowFit', () => {
|
||||
rerender();
|
||||
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
|
||||
* label, `compact` with the layer pill on its icons, and `stacked` with the
|
||||
* grade legend on a row of its own above the dock as well.
|
||||
* label, `compact` with the layer pill on its icons, `stacked` with the grade
|
||||
* 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
|
||||
|
||||
@@ -24,10 +24,11 @@ import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDisplayDate } from '../../../utils/dateFormat';
|
||||
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||
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 { 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 { TourListCard, TourMetricFields, TourNotice, TourSection } from '../tourParts';
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
@@ -40,11 +41,12 @@ import WandererMark from './WandererMark';
|
||||
* The trail itself shows on the map between the rails.
|
||||
*/
|
||||
export default function WandererBrowserRail({ browser }: { browser: WandererBrowser }) {
|
||||
const railHead = useRailHeadProps();
|
||||
const { t } = useTranslation();
|
||||
const inPreview = browser.selected !== null;
|
||||
return (
|
||||
<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
|
||||
label={inPreview ? t('wanderer.browser.backToList') : t('tours.planner.backToTours')}
|
||||
onClick={inPreview ? browser.backToList : browser.close}
|
||||
|
||||
@@ -44,11 +44,11 @@ describe('Skeleton', () => {
|
||||
})
|
||||
|
||||
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 />)
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -26,13 +26,13 @@ export function Skeleton({
|
||||
|
||||
// Trip card skeleton matching SpotlightCard layout
|
||||
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 (
|
||||
<div
|
||||
className="relative rounded-3xl overflow-hidden mb-8 bg-surface-tertiary"
|
||||
style={{ minHeight: 340 }}
|
||||
>
|
||||
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
|
||||
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
|
||||
<div className="hero-trip hero-trip-pending" aria-hidden>
|
||||
<div className="trek-skeleton absolute inset-0" />
|
||||
<div className="relative flex h-full flex-col justify-end p-6">
|
||||
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
|
||||
<Skeleton width={220} height={16} radius={4} />
|
||||
</div>
|
||||
|
||||
@@ -223,4 +223,20 @@ describe('Tooltip — with content', () => {
|
||||
expect(tip.querySelectorAll('li')).toHaveLength(2)
|
||||
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
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement | 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 = () => {
|
||||
if (disabled || !label) return
|
||||
@@ -60,6 +70,7 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, sh
|
||||
type TriggerProps = {
|
||||
ref?: React.Ref<HTMLElement>
|
||||
onMouseEnter?: (e: React.MouseEvent) => void
|
||||
onTouchStart?: (e: React.TouchEvent) => void
|
||||
onMouseLeave?: (e: React.MouseEvent) => void
|
||||
onFocus?: (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)
|
||||
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) },
|
||||
// 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.
|
||||
|
||||
@@ -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 { 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 {
|
||||
height = 800;
|
||||
@@ -18,6 +32,9 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
_resetNativeKeyboard();
|
||||
native.app = false;
|
||||
native.listeners.clear();
|
||||
});
|
||||
|
||||
function keyboard(height: number, offsetTop = 0) {
|
||||
@@ -57,4 +74,18 @@ describe('useKeyboardInset', () => {
|
||||
const { result } = renderHook(() => useKeyboardInset());
|
||||
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 { 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. */
|
||||
function readInset(): number {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return 0;
|
||||
return Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop));
|
||||
const fromViewport = viewport
|
||||
? Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop))
|
||||
: 0;
|
||||
return Math.max(fromViewport, nativeHeight);
|
||||
}
|
||||
|
||||
function subscribe(onChange: () => void): () => void {
|
||||
followNativeKeyboard();
|
||||
nativeListeners.add(onChange);
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return () => undefined;
|
||||
viewport.addEventListener('resize', onChange);
|
||||
viewport.addEventListener('scroll', onChange);
|
||||
viewport?.addEventListener('resize', onChange);
|
||||
viewport?.addEventListener('scroll', onChange);
|
||||
return () => {
|
||||
viewport.removeEventListener('resize', onChange);
|
||||
viewport.removeEventListener('scroll', onChange);
|
||||
nativeListeners.delete(onChange);
|
||||
viewport?.removeEventListener('resize', onChange);
|
||||
viewport?.removeEventListener('scroll', onChange);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* composer at the foot stays above the keys instead of under them: iOS
|
||||
* Safari lays the keyboard over the page without resizing it.
|
||||
* off the visual viewport, and in the app off the keyboard itself. A
|
||||
* full-screen view pads its bottom by it, so a composer at the foot stays above
|
||||
* 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 {
|
||||
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 .bg-slate-50 { background-color: #131316 !important; }
|
||||
.dark .bg-slate-100 { background-color: #1c1c21 !important; }
|
||||
.dark .bg-white { background-color: #09090b !important; }
|
||||
.dark .bg-gray-50 { background-color: #131316 !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-900 { background-color: #e4e4e7 !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
|
||||
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. */
|
||||
/* Not in the touch shell (.m-root), whose fields set their own fill; :where() keeps the desktop's weight. */
|
||||
.dark input:not(:where(.m-root *)), .dark textarea:not(:where(.m-root *)), .dark select:not(:where(.m-root *)) {
|
||||
background-color: #1c1c21;
|
||||
color: #f4f4f5;
|
||||
|
||||
@@ -42,6 +42,7 @@ import { maybeInstallTouchDragPolyfill } from './utils/touchDragPolyfill'
|
||||
import { startConnectivityProbe } from './sync/connectivity'
|
||||
import { startNativeApp } from './native/nativeApp'
|
||||
import { requestPersistentStorage } from './sync/persistentStorage'
|
||||
import { isIosDevice } from './utils/iosDevice'
|
||||
import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary'
|
||||
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers'
|
||||
import { schedulePrecacheRepair } from './utils/serviceWorkerShell'
|
||||
@@ -52,6 +53,9 @@ startConnectivityProbe()
|
||||
startNativeApp()
|
||||
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
|
||||
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.
|
||||
installGlobalErrorHandlers()
|
||||
// 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
|
||||
@@ -27,6 +28,9 @@ export function MRail({
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
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 (
|
||||
<nav
|
||||
aria-label={label}
|
||||
@@ -34,8 +38,11 @@ export function MRail({
|
||||
>
|
||||
{head && <div className="mb-2 flex flex-none justify-center">{head}</div>}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex min-h-0 w-full flex-1 flex-col items-center gap-1 overflow-y-auto px-1 [scrollbar-width:none]"
|
||||
ref={(node) => {
|
||||
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}
|
||||
</div>
|
||||
@@ -90,6 +97,11 @@ export function MRailSpacer() {
|
||||
interface MRailButtonProps {
|
||||
label: string;
|
||||
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;
|
||||
/** The icon, drawn at 20px. */
|
||||
children: ReactNode;
|
||||
@@ -101,19 +113,24 @@ interface MRailButtonProps {
|
||||
* 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.
|
||||
*/
|
||||
export function MRailButton({ label, active, onClick, children }: MRailButtonProps) {
|
||||
export function MRailButton({ label, active, expanded, onClick, children }: MRailButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
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)]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
return (
|
||||
<button
|
||||
@@ -27,8 +31,10 @@ export default function MToggle({ checked, onChange, ariaLabel, disabled = false
|
||||
} ${className}`}
|
||||
>
|
||||
<span
|
||||
className={`block h-[18px] w-[18px] rounded-full bg-[color:var(--m-knob)] transition-transform duration-[180ms] ${
|
||||
checked ? 'translate-x-[18px] rtl:-translate-x-[18px]' : 'translate-x-0'
|
||||
className={`block h-[18px] w-[18px] rounded-full transition-transform duration-[180ms] ${
|
||||
checked
|
||||
? 'translate-x-[18px] bg-[color:var(--m-actfg)] rtl:-translate-x-[18px]'
|
||||
: 'translate-x-0 bg-[color:var(--m-knob)]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useSharedImportStore } from '../../native/sharedFiles';
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { saveSharedLink, useSharedImportStore } from '../../native/sharedFiles';
|
||||
import { tripRepo } from '../../repo/tripRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import NativeImportSheet from './NativeImportSheet';
|
||||
|
||||
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) => ({
|
||||
...(await importOriginal<typeof import('react-router')>()),
|
||||
useNavigate: () => navigate,
|
||||
@@ -14,7 +19,9 @@ beforeEach(() => {
|
||||
// A second spyOn returns the first spy with its calls; start every test clean.
|
||||
vi.restoreAllMocks();
|
||||
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({
|
||||
trips: [
|
||||
{ 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>>);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('asks which trip a shared document goes to, upcoming trips first, and opens it there', async () => {
|
||||
render(<NativeImportSheet />);
|
||||
@@ -49,6 +64,58 @@ describe('NativeImportSheet', () => {
|
||||
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', () => {
|
||||
render(<NativeImportSheet />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
/**
|
||||
* NativeImportSheet: a booking document was shared to TREK (native/sharedFiles.ts),
|
||||
* so this asks which trip it belongs to and opens that trip's booking import with
|
||||
* the file already chosen. Trips still ahead come first, the ones that ended after.
|
||||
* NativeImportSheet: something was shared to TREK (native/sharedFiles.ts), so this
|
||||
* asks which trip it belongs to and opens that trip's booking import with the file
|
||||
* 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 React, { useEffect, useState } from 'react';
|
||||
import { ChevronRight, FileText, Link2, ScanText } from 'lucide-react';
|
||||
import React, { useEffect, useEffectEvent, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useToast } from '../../components/shared/Toast';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSharedImportStore } from '../../native/sharedFiles';
|
||||
import { saveSharedLink, useSharedImportStore, type SharedLink } from '../../native/sharedFiles';
|
||||
import { tripRepo } from '../../repo/tripRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { offlineOr } from '../../utils/needsConnection';
|
||||
import { TileHeader } from '../screens/trip/sheets/MTripSheetUi';
|
||||
import MSheet from './MSheet';
|
||||
|
||||
@@ -19,18 +26,45 @@ interface TripRow {
|
||||
end_date?: string | null;
|
||||
}
|
||||
|
||||
export default function NativeImportSheet(): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const file = useSharedImportStore((s) => s.file);
|
||||
const tripId = useSharedImportStore((s) => s.tripId);
|
||||
const chooseTrip = useSharedImportStore((s) => s.chooseTrip);
|
||||
const setFile = useSharedImportStore((s) => s.setFile);
|
||||
type LinkChoice = 'link' | 'booking';
|
||||
|
||||
/** What to offer for a shared link: the links when Collaboration is on, the import when there is a page to read. */
|
||||
function linkChoicesOf(file: File, collabOn: boolean): LinkChoice[] {
|
||||
const choices: LinkChoice[] = [];
|
||||
if (collabOn) choices.push('link');
|
||||
if (file.type !== 'text/uri-list') choices.push('booking');
|
||||
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 open = !!file && tripId == null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
tripRepo.list().then(
|
||||
@@ -46,6 +80,35 @@ export default function NativeImportSheet(): React.ReactElement | null {
|
||||
() => setTrips([])
|
||||
);
|
||||
}, [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
|
||||
// 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}`);
|
||||
}, [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 (
|
||||
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
|
||||
<div className="px-[18px] pb-4 pt-4">
|
||||
<div className="mb-3">
|
||||
<TileHeader
|
||||
icon={<FileText size={19} strokeWidth={1.8} />}
|
||||
title={t('native.share.title')}
|
||||
sub={file?.name}
|
||||
icon={link ? <Link2 size={19} strokeWidth={1.8} /> : <FileText size={19} strokeWidth={1.8} />}
|
||||
title={sheetTitle(t, asking, choice)}
|
||||
sub={link ? link.title : file?.name}
|
||||
onClose={() => setFile(null)}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
|
||||
{trips.map((trip) => (
|
||||
<button
|
||||
key={trip.id}
|
||||
type="button"
|
||||
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]"
|
||||
>
|
||||
<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>
|
||||
{asking && file && <LinkQuestion onPick={(use) => setPick({ file, choice: use })} />}
|
||||
{nowhere && (
|
||||
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
|
||||
{t('native.share.linkUnsupported')}
|
||||
</p>
|
||||
)}
|
||||
{showTrips && <TripPicker trips={trips} onPick={pickTrip} />}
|
||||
</div>
|
||||
</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-actfg: #fff;
|
||||
--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-sheetop: #FAFAF8;
|
||||
--m-shbr: rgba(255,255,255,.95);
|
||||
|
||||
@@ -18,7 +18,9 @@ import type { Location, NavigationType } from 'react-router';
|
||||
*/
|
||||
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 current: ViewTransition | null = null;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { STATUS_SPEC } from './collectionsMobileModel'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import MCollListNav from './MCollListNav'
|
||||
import { useIsSplit } from '../../../hooks/sizeClass'
|
||||
import { skipNextRouteTransition } from '../../routeTransition'
|
||||
|
||||
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)]'
|
||||
@@ -133,6 +134,9 @@ export default function MCollections() {
|
||||
|
||||
const selectList = (id: number | typeof ALL_SAVED) => {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useIsSplit } from '../../../hooks/sizeClass'
|
||||
import {
|
||||
Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
|
||||
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 fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const split = useIsSplit()
|
||||
const { available: assistantAvailable, open: openAssistant } = useAssistantHomeLauncher()
|
||||
const [subOpen, setSubOpen] = useState(false)
|
||||
// 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
|
||||
dashboard holds them: the trips in the main column, the widgets in a
|
||||
column of their own, each in the order the user set. */}
|
||||
<div className="split:grid split:grid-cols-[minmax(0,1fr)_minmax(300px,360px)] split:items-start split:gap-6">
|
||||
<div className="min-w-0">
|
||||
column of their own, each in the order the user set. Narrower, both
|
||||
columns dissolve (display: contents) into one stack ordered the same
|
||||
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 && (
|
||||
<MSpotlightCard
|
||||
trip={spotlight}
|
||||
@@ -292,22 +293,18 @@ export default function MDashboard(): React.ReactElement {
|
||||
actions={actionsFor(spotlight, 'list')}
|
||||
/>
|
||||
)}
|
||||
{(split ? dashOrder.filter(id => id === 'trips') : dashOrder).map(id => (
|
||||
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
|
||||
))}
|
||||
<div style={{ order: dashOrder.indexOf('trips') }}>{renderBlock('trips')}</div>
|
||||
</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>
|
||||
{(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>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route, ListChecks } from 'lucide-react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'
|
||||
import { Outlet, useSearchParams } from 'react-router'
|
||||
import { BookOpen, ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route, ListChecks } from 'lucide-react'
|
||||
import JourneyLayeredMap from '../../../components/Journey/JourneyLayeredMap'
|
||||
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
|
||||
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
|
||||
@@ -32,6 +32,8 @@ import MJourneyFeed from './MJourneyFeed'
|
||||
import MJourneySettingsSheet from './MJourneySettingsSheet'
|
||||
import MJourneyDayImportSheet from './MJourneyDayImportSheet'
|
||||
import MJourneyRecordingSheet from './MJourneyRecordingSheet'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTabHeadSlot } from '../trip/tabHeadSlot'
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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) {
|
||||
const mq = window.matchMedia(SHORT_WINDOW)
|
||||
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,
|
||||
dismissSuggestion, restoreSuggestions, openAtEntryId,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
|
||||
addPickedProviderPhotos, addEntryProviderPhotos,
|
||||
addPickedProviderPhotos, addEntryProviderPhotos, writeAboutPlace,
|
||||
} = 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
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// print was the old export's answer to a question nobody was asking.
|
||||
// and Studio needs the room of a desktop, an iPad on its side or an unfolded
|
||||
// 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) => {
|
||||
setHideSkeletons(next)
|
||||
try {
|
||||
@@ -334,7 +344,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
return (
|
||||
// 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.
|
||||
<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
|
||||
it begins where the feed ends, so its controls and its framing stay in
|
||||
the part that can be seen. */}
|
||||
@@ -348,6 +358,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
hideLayerControl
|
||||
controlsTop="calc(var(--m-safe-top, 12px) + 50px)"
|
||||
onOpenEntry={handleMarkerClick}
|
||||
onWriteAboutPlace={writeAboutPlace}
|
||||
entryTitleOf={entryTitleOf}
|
||||
checkins={[]}
|
||||
entries={sidebarMapItems}
|
||||
@@ -418,11 +429,12 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
{feed && (
|
||||
<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)]"
|
||||
// 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={{
|
||||
insetInlineStart: 'var(--m-rail-w, 0px)',
|
||||
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
|
||||
@@ -441,10 +453,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
|
||||
{/* Header: back — segment — upload / overflow menu. Beside the feed it keeps
|
||||
to the feed, as the trip's controls keep to its pane. */}
|
||||
<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}
|
||||
>
|
||||
<HeaderFrame slot={headSlot} feed={!!feed}>
|
||||
{embedded ? (
|
||||
<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" />
|
||||
</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
|
||||
type="button"
|
||||
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>
|
||||
)}
|
||||
{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
|
||||
type="button"
|
||||
onClick={() => setShowActionMenu(true)}
|
||||
@@ -502,7 +524,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
<MoreHorizontal size={17} strokeWidth={2} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</HeaderFrame>
|
||||
|
||||
{/* 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,
|
||||
@@ -589,7 +611,8 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
showMood={current.show_mood !== 0}
|
||||
showWeather={current.show_weather !== 0}
|
||||
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>
|
||||
))}
|
||||
@@ -799,6 +822,37 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" />
|
||||
)}
|
||||
<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}
|
||||
</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>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import MToggle from '../../components/MToggle'
|
||||
import { MSetCard, MSetEyebrow, MSetSegments, MSetRow } from './MSettingsUi'
|
||||
import MMobileNavCustomizer from './MMobileNavCustomizer'
|
||||
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 }) {
|
||||
return (
|
||||
@@ -41,6 +42,8 @@ function SliderRow({ label, sub, value, onChange }: { label: string; sub?: strin
|
||||
*/
|
||||
export default function MSettingsAppearance() {
|
||||
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 } =
|
||||
useAppearanceEditor()
|
||||
|
||||
@@ -156,11 +159,18 @@ export default function MSettingsAppearance() {
|
||||
)}
|
||||
</MSetCard>
|
||||
|
||||
<MSetCard title={t('settings.appearance.mobile')} icon={Smartphone} className="mt-3">
|
||||
<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>
|
||||
<MSetCard
|
||||
title={wide ? t('settings.appearance.dashOrder') : t('settings.appearance.mobile')}
|
||||
icon={wide ? LayoutDashboard : Smartphone}
|
||||
className="mt-3"
|
||||
>
|
||||
{!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>
|
||||
<MMobileDashOrder cfg={cfg} onChange={(order) => update({ dashboard: { ...cfg.dashboard, mobileOrder: order } })} />
|
||||
</MSetCard>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import MDancingTrek from '../../components/MDancingTrek'
|
||||
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
|
||||
* 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 }) {
|
||||
const { t } = useTranslation()
|
||||
const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: true })
|
||||
|
||||
@@ -16,6 +16,8 @@ interface MTripRailProps {
|
||||
onSelect: (id: string) => void;
|
||||
/** Opens the More sheet: trip settings, export, the assistant. */
|
||||
onMore: () => void;
|
||||
/** Whether the More sheet is open, which the More button shows with a ring. */
|
||||
moreOpen?: boolean;
|
||||
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
|
||||
* 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();
|
||||
return (
|
||||
<MRail label={t('mobileTrip.sections')}>
|
||||
@@ -41,7 +43,7 @@ export default function MTripRail({ tabs, active, onSelect, onMore, labelOf }: M
|
||||
);
|
||||
})}
|
||||
<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} />
|
||||
</MRailButton>
|
||||
</MRail>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type ComponentType } from
|
||||
import { findFocusDayId, findTodayDayId } from '../../../components/Planner/today'
|
||||
import { takeResumeDay } from '../../../utils/resumeRoute'
|
||||
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'
|
||||
import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner'
|
||||
import { pickDockTabs } from './dockTabs'
|
||||
@@ -16,7 +16,7 @@ import MPlacesBrowser from './places/MPlacesBrowser'
|
||||
import MTripTabPanel from './tabs/MTripTabPanel'
|
||||
import MTripSheets from './sheets/MTripSheets'
|
||||
import MReceiptScanButton from './tabs/MReceiptScanButton'
|
||||
import MTripLoadingSplash from './MTripLoadingSplash'
|
||||
import { MTripOpening } from './MTripLoadingSplash'
|
||||
import { usePluginDayTints, dayTintBackground } from '../../../components/Plugins/PluginDaySchedule'
|
||||
import { stageOf } from '../../../components/Roadtrip/roadtripRowModel'
|
||||
import { badgeLabel, distanceBadge } from './roadtrip/stageBadges'
|
||||
@@ -29,12 +29,13 @@ import { useDayPreviewStore } from '../../dayPreviewStore'
|
||||
import { useIsSplit, useIsWide } from '../../../hooks/sizeClass'
|
||||
import MTripRail from './MTripRail'
|
||||
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'
|
||||
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade'
|
||||
import { useShortcuts } from '../../keyboard/useShortcuts'
|
||||
import { tripCreateIntent } from '../../../components/Layout/useCreateAction'
|
||||
import MDockScrim from '../../components/MDockScrim'
|
||||
import { setTabHeadSlot } from './tabHeadSlot'
|
||||
|
||||
/**
|
||||
* Mobile trip screen frame. Owns the chrome the design shares across every
|
||||
@@ -328,9 +329,11 @@ export default function MTripShell({
|
||||
|
||||
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(() => {
|
||||
if (trTab === 'tour-planner') planner.handleTabChange('plan')
|
||||
}, [trTab, planner.handleTabChange])
|
||||
if (trTab === 'tour-planner' && !wide) planner.handleTabChange('plan')
|
||||
}, [trTab, wide, planner.handleTabChange])
|
||||
|
||||
const setTrTab = (tabId: string) => {
|
||||
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
|
||||
// places framed, its route drawn, the other days' pins put away, exactly as
|
||||
// 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.
|
||||
const mapReady = !planner.isLoading && planner.splashDone
|
||||
const followDayId = mapReady && split && trTab === 'plan' && view === 'plan' && mode !== 'browse' ? planner.selectedDayId : null
|
||||
// Not before the trip has opened: the map is not there to frame until then.
|
||||
// The store can still hold the trip opened before, for the frame before loadTrip resets it.
|
||||
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 selectDayRef = useRef(planner.handleSelectDay)
|
||||
useEffect(() => {
|
||||
@@ -519,9 +524,9 @@ export default function MTripShell({
|
||||
openDocSyncSignal,
|
||||
}
|
||||
|
||||
// Splash — same gate as the desktop page, in the mobile design language.
|
||||
if (planner.isLoading || !planner.splashDone) {
|
||||
return <MTripLoadingSplash title={trip?.title || ''} />
|
||||
// No minimum splash as on the desktop: the page transition hides the wait (MTripOpening).
|
||||
if (!opened && (planner.isLoading || trip)) {
|
||||
return <MTripOpening title={ownTrip?.title || ''} />
|
||||
}
|
||||
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 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]">
|
||||
<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)}`}>
|
||||
{dayPill && (
|
||||
<span
|
||||
@@ -862,6 +866,8 @@ export default function MTripShell({
|
||||
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
|
||||
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:') && (() => {
|
||||
const tab = planner.TRIP_TABS.find(x => x.id === trTab)
|
||||
if (!tab) return null
|
||||
@@ -901,6 +907,7 @@ export default function MTripShell({
|
||||
active={trTab}
|
||||
onSelect={setTrTab}
|
||||
onMore={() => openSheet('mehr')}
|
||||
moreOpen={sheet?.id === 'mehr'}
|
||||
labelOf={tabLabel}
|
||||
/>
|
||||
)}
|
||||
@@ -927,17 +934,12 @@ export default function MTripShell({
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSheet('mehr')}
|
||||
aria-label={t('mobileTrip.more')}
|
||||
aria-expanded={sheet?.id === 'mehr'}
|
||||
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>
|
||||
<DockMoreButton
|
||||
open={sheet?.id === 'mehr'}
|
||||
standsFor={!dockTabs.some(tab => tab.id === trTab)}
|
||||
onOpen={() => openSheet('mehr')}
|
||||
label={t('mobileTrip.more')}
|
||||
/>
|
||||
</nav>}
|
||||
|
||||
<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 { canManageDocSync } from '../../../components/Files/docsync/useDocSync';
|
||||
import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered';
|
||||
@@ -6,7 +6,7 @@ import { useIsWide } from '../../../hooks/sizeClass';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import type { Trip } from '../../../types';
|
||||
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 { 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 {
|
||||
active: MTripCollabTab;
|
||||
onPick: (tab: MTripCollabTab) => void;
|
||||
@@ -284,3 +223,37 @@ export function CompactToggle({ active, onToggle, label }: { active: boolean; on
|
||||
</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
|
||||
// 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.
|
||||
// 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
|
||||
// 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.
|
||||
<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
|
||||
// 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
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
<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>}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useMRtCorridor } from './useMRtCorridor'
|
||||
import { useMRtAlternatives } from './useMRtAlternatives'
|
||||
import MRtCorridorBar from './MRtCorridorBar'
|
||||
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 MBadge from '../../../components/MBadge'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
@@ -289,6 +291,12 @@ function RoadtripStagePage({ planner, shell, dayId: pageDayId, alts, chrome }: O
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{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