mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-11 14:50:57 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 322d5d5209 | |||
| 0346f9c13d | |||
| dfd87d413e | |||
| 27aab5c783 | |||
| a7cc1b9e57 |
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { 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 { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
|
||||
@@ -429,12 +430,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 +529,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 +844,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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -733,7 +757,7 @@ a.linkbtn {
|
||||
.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 +812,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 +913,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 +933,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 +941,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 +1049,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 +1070,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": 847,
|
||||
"@typescript-eslint/no-misused-promises": 845,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
"@typescript-eslint/no-explicit-any": 144,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 489,
|
||||
"@typescript-eslint/no-non-null-assertion": 1912,
|
||||
"@typescript-eslint/no-non-null-assertion": 1911,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -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,
|
||||
@@ -79,7 +80,6 @@
|
||||
"hooks/useAnchoredPosition.ts": 3,
|
||||
"hooks/useElementRect.ts": 3,
|
||||
"mobile/components/MSegmented.tsx": 1,
|
||||
"mobile/components/MSheet.tsx": 1,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 3,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
|
||||
"mobile/screens/trip/map/MMapArea.tsx": 3,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />)}
|
||||
|
||||
@@ -211,7 +211,15 @@ vi.mock('../../services/photoService', () => ({
|
||||
const shell = vi.hoisted(() => ({ phone: undefined as boolean | undefined }))
|
||||
vi.mock('../../mobile/useIsPhone', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../mobile/useIsPhone')>()
|
||||
return { ...actual, useIsPhone: () => shell.phone ?? actual.useIsPhone() }
|
||||
// The real hook runs every time, so a test that changes the answer never changes the
|
||||
// number of hooks a mounted map has (React throws on that).
|
||||
return {
|
||||
...actual,
|
||||
useIsPhone: () => {
|
||||
const real = actual.useIsPhone()
|
||||
return shell.phone ?? real
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./gcj02Crs', () => ({
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 800ms
|
||||
* (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, and the splash is
|
||||
* only for a slow connection.
|
||||
*/
|
||||
export const TRIP_SPLASH_AFTER_MS = 1000
|
||||
|
||||
/**
|
||||
* 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,12 +1,12 @@
|
||||
import { ChevronLeft, ChevronRight, FolderSync, List, Plus, Rows3 } from 'lucide-react';
|
||||
import { useLayoutEffect, useState, type ReactNode } from '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';
|
||||
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';
|
||||
|
||||
@@ -26,14 +26,21 @@ import { collabBoardFor, collabSideBySide } from './tabs/collabPanes';
|
||||
export function GlassSegment({ children, activeKey }: { children: ReactNode; activeKey?: string }) {
|
||||
const [region, setRegion] = useState<HTMLDivElement | null>(null);
|
||||
const [track, setTrack] = useState<HTMLDivElement | null>(null);
|
||||
// The element is also kept in a ref: the fit below writes to it, and state is not to be written.
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const attachTrack = useCallback((el: HTMLDivElement | null) => {
|
||||
trackRef.current = el;
|
||||
setTrack(el);
|
||||
}, []);
|
||||
const fade = useEdgeFade(track);
|
||||
useLayoutEffect(() => {
|
||||
if (!region || !track) return;
|
||||
const el = trackRef.current;
|
||||
if (!region || !el) return;
|
||||
const fit = () => {
|
||||
track.dataset.tight = 'false';
|
||||
track.dataset.tight = track.scrollWidth > track.clientWidth + 0.5 ? 'true' : 'false';
|
||||
const active = track.querySelector<HTMLElement>('[aria-pressed="true"]');
|
||||
if (active && track.scrollWidth > track.clientWidth + 0.5) {
|
||||
el.dataset.tight = 'false';
|
||||
el.dataset.tight = el.scrollWidth > el.clientWidth + 0.5 ? 'true' : 'false';
|
||||
const active = el.querySelector<HTMLElement>('[aria-pressed="true"]');
|
||||
if (active && el.scrollWidth > el.clientWidth + 0.5) {
|
||||
// 'nearest' on both axes: the shell root is fixed, anything else scrolls the page behind it.
|
||||
active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
@@ -49,7 +56,7 @@ export function GlassSegment({ children, activeKey }: { children: ReactNode; act
|
||||
className="pointer-events-none absolute inset-y-0 end-12 start-12 flex items-center justify-center"
|
||||
>
|
||||
<div className="pointer-events-auto flex min-w-0 max-w-full rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
|
||||
<div ref={setTrack} className={`m-seg flex min-w-0 overflow-x-auto rounded-full ${edgeFadeClass(fade)}`}>
|
||||
<div ref={attachTrack} className={`m-seg flex min-w-0 overflow-x-auto rounded-full ${edgeFadeClass(fade)}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,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;
|
||||
@@ -277,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} />}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { formatDurationShort, parseAvoid } from '../../../../components/Roadtrip
|
||||
import { valhallaAvailable } from '../../../../components/Map/valhallaRoute'
|
||||
import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../../../utils/units'
|
||||
import { Eyebrow, TileHeader } from '../sheets/MTripSheetUi'
|
||||
import { useIsWide } from '../../../../hooks/sizeClass'
|
||||
import MRtLimitsEditor from './MRtLimitsEditor'
|
||||
import type { DistanceUnit } from '../../../../types'
|
||||
|
||||
/**
|
||||
@@ -104,6 +106,9 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const degradation = useRoadtripSettings(s => s.roadtrip_battery_degradation, tripId)
|
||||
const avoidRaw = useRoadtripSettings(s => s.roadtrip_avoid, tripId)
|
||||
const bookends = useHotelBookends(planner.saveRoadtripLimit, tripId)
|
||||
// An iPad or an unfolded Duo is the table the figures are set at: there the limits are a
|
||||
// form (MRtLimitsEditor). The phone and the folded Duo keep the read-out below.
|
||||
const large = useIsWide()
|
||||
|
||||
const electric = vehicleKind === 'electric'
|
||||
const VehicleIcon = electric ? Zap : vehicleKind === 'combustion' ? Fuel : Car
|
||||
@@ -225,26 +230,41 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
|
||||
</div>
|
||||
|
||||
{/* ── Limits: what every warning on the stage is measured against ── */}
|
||||
<div className="mt-2.5">
|
||||
<InfoCard label={t('roadtrip.limit.title')}>
|
||||
<div className="mt-1">
|
||||
{lines.map((line, i) => (
|
||||
<LimitLine
|
||||
key={line.key}
|
||||
label={line.label}
|
||||
value={line.value}
|
||||
fallback={line.fallback}
|
||||
first={i === 0}
|
||||
/>
|
||||
))}
|
||||
{large ? (
|
||||
<div className="mt-2.5">
|
||||
<MRtLimitsEditor
|
||||
tripId={tripId}
|
||||
// No save is the planner saying the reader may not edit days, or that the
|
||||
// trip's settings are not in yet: the form shows its figures, disabled.
|
||||
readOnly={!planner.saveRoadtripLimit}
|
||||
onSave={(key, value) => void planner.saveRoadtripLimit?.(key, value)}
|
||||
onResetDayBoundaries={planner.resetDayBoundaries}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2.5">
|
||||
<InfoCard label={t('roadtrip.limit.title')}>
|
||||
<div className="mt-1">
|
||||
{lines.map((line, i) => (
|
||||
<LimitLine
|
||||
key={line.key}
|
||||
label={line.label}
|
||||
value={line.value}
|
||||
fallback={line.fallback}
|
||||
first={i === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</InfoCard>
|
||||
</div>
|
||||
</InfoCard>
|
||||
</div>
|
||||
|
||||
{/* Under the figures it speaks of, and above the switch it does not. */}
|
||||
<p className="mt-2.5 font-geist text-[0.6875rem] leading-snug text-m-faint">
|
||||
{t('mobileTrip.rtDesktopNote')}
|
||||
</p>
|
||||
{/* Under the figures it speaks of, and above the switch it does not. */}
|
||||
<p className="mt-2.5 font-geist text-[0.6875rem] leading-snug text-m-faint">
|
||||
{t('mobileTrip.rtDesktopNote')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── The one switch: whether a day starts and ends at the stay ── */}
|
||||
<div className="mt-3">
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { valhallaAvailable } from '../../../../components/Map/valhallaRoute';
|
||||
import { dayWindow } from '../../../../components/Roadtrip/dayWindow';
|
||||
import { parseAvoid, serializeAvoid, type VehicleKind } from '../../../../components/Roadtrip/roadtripModel';
|
||||
import { rangeFromSpec, type VehicleSpec } from '../../../../components/Roadtrip/vehicleRange';
|
||||
import CustomTimePicker from '../../../../components/shared/CustomTimePicker';
|
||||
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import type { RouteAvoidClass } from '../../../../types';
|
||||
import { convertDistance } from '../../../../utils/units';
|
||||
import MSegmented from '../../../components/MSegmented';
|
||||
import MToggle from '../../../components/MToggle';
|
||||
import { Eyebrow } from '../sheets/MTripSheetUi';
|
||||
|
||||
/**
|
||||
* The driving figures as a form, for an iPad or an unfolded Duo.
|
||||
*
|
||||
* The phone keeps them read-only (MRtInfoSheet says why: a form in the passenger seat breaks
|
||||
* a trip while it is happening). A large screen is the table the figures are set at, so
|
||||
* there the same sheet edits what the desktop's limits dialog edits most: the two driving
|
||||
* limits, the day's window and where it ends, the vehicle with its range and fill, and what
|
||||
* the road may avoid. The vehicle's own figures (tank, consumption, battery) stay on the
|
||||
* desktop; once they are stored the range is theirs and shows read-only here, as it does
|
||||
* there.
|
||||
*
|
||||
* Every change goes through the planner's `saveRoadtripLimit`, the desktop dialog's own
|
||||
* save, which checks the right to edit days itself. `readOnly` disables the controls for a
|
||||
* reader without that right, so nothing takes a value only to drop it.
|
||||
*/
|
||||
|
||||
/** Empty means no limit, and so does zero: both are stored as 0 and read as off. */
|
||||
function parseFigure(raw: string): number {
|
||||
const n = Number(raw.replace(',', '.'));
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
|
||||
}
|
||||
|
||||
/** One card of the form: eyebrow plus its rows. */
|
||||
function EditCard({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-[16px] bg-[color:var(--m-ic)] px-3.5 py-3">
|
||||
<Eyebrow>{label}</Eyebrow>
|
||||
<div className="mt-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A label and its control on one line, with the row rule above every row but the first. */
|
||||
function EditRow({ label, first, children }: { label: string; first?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex min-h-11 items-center justify-between gap-3 py-1.5 ${first ? '' : 'border-t border-[color:var(--m-rowbr)]'}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-body text-m-muted">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A number typed in and saved when the field is left or Enter is pressed, not per
|
||||
* keystroke: a settings write per digit would plan the trip against "4" on the way to "45".
|
||||
*/
|
||||
function FigureField({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
placeholder,
|
||||
disabled,
|
||||
testId,
|
||||
onCommit,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | undefined;
|
||||
suffix: string;
|
||||
placeholder: string;
|
||||
disabled: boolean;
|
||||
testId: string;
|
||||
onCommit: (value: number) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const shown = draft ?? (value ? String(value) : '');
|
||||
const commit = () => {
|
||||
if (draft === null) return;
|
||||
setDraft(null);
|
||||
const next = parseFigure(draft);
|
||||
if (next !== (value ?? 0)) onCommit(next);
|
||||
};
|
||||
return (
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
value={shown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setDraft(e.target.value.replace(/[^0-9.,]/g, ''))}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
className="h-9 w-[5.5rem] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 text-end text-body font-semibold tabular-nums text-m-ink outline-none placeholder:font-medium placeholder:text-m-faint disabled:opacity-60"
|
||||
/>
|
||||
<span className="w-7 text-caption text-m-faint">{suffix}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
const HINT = 'font-geist text-caption leading-snug text-m-faint';
|
||||
|
||||
interface CardProps {
|
||||
tripId: number;
|
||||
readOnly: boolean;
|
||||
onSave: (key: string, value: number | string | boolean) => void;
|
||||
}
|
||||
|
||||
/** How long one stretch and one day may be, in minutes. */
|
||||
function DrivingCard({ tripId, readOnly, onSave }: CardProps) {
|
||||
const { t } = useTranslation();
|
||||
const leg = useRoadtripSettings((all) => all.roadtrip_leg_minutes, tripId);
|
||||
const day = useRoadtripSettings((all) => all.roadtrip_day_minutes, tripId);
|
||||
const rows = [
|
||||
{ key: 'roadtrip_leg_minutes', label: t('roadtrip.limit.legLabel'), value: leg, testId: 'rt-edit-leg' },
|
||||
{ key: 'roadtrip_day_minutes', label: t('roadtrip.limit.dayLabel'), value: day, testId: 'rt-edit-day' },
|
||||
];
|
||||
return (
|
||||
<EditCard label={t('roadtrip.limit.sectionDriving')}>
|
||||
{rows.map((row, i) => (
|
||||
<EditRow key={row.key} label={row.label} first={i === 0}>
|
||||
<FigureField
|
||||
label={row.label}
|
||||
value={row.value}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
disabled={readOnly}
|
||||
testId={row.testId}
|
||||
onCommit={(v) => onSave(row.key, v)}
|
||||
/>
|
||||
</EditRow>
|
||||
))}
|
||||
<p className={`pb-1 ${HINT}`}>{t('roadtrip.limit.hint')}</p>
|
||||
</EditCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** A choice between a few words, faded and out of reach for a reader. */
|
||||
function Choice<T extends string>({
|
||||
readOnly,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
readOnly: boolean;
|
||||
value: T;
|
||||
options: { value: T; label: string }[];
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={readOnly ? 'pointer-events-none opacity-60' : ''} aria-disabled={readOnly || undefined}>
|
||||
<MSegmented value={value} onChange={onChange} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** When the day may start and end, and whether it ends on the road or at a stop. */
|
||||
function WindowCard({
|
||||
tripId,
|
||||
readOnly,
|
||||
onSave,
|
||||
onResetDayBoundaries,
|
||||
}: CardProps & {
|
||||
onResetDayBoundaries?: () => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const storedStart = useRoadtripSettings((all) => all.roadtrip_day_start, tripId) ?? '';
|
||||
const storedEnd = useRoadtripSettings((all) => all.roadtrip_day_end, tripId) ?? '';
|
||||
const endMode = useRoadtripSettings((all) => all.roadtrip_day_end_mode, tripId) === 'stop' ? 'stop' : 'route';
|
||||
// What is in the pickers, ahead of a time that is whole enough to save.
|
||||
const [times, setTimes] = useState<{ start?: string; end?: string }>({});
|
||||
const shown = { start: times.start ?? storedStart, end: times.end ?? storedEnd };
|
||||
const stored = { start: storedStart, end: storedEnd };
|
||||
const invalid = !!shown.start && !!shown.end && !dayWindow(shown.start, shown.end);
|
||||
const setTime = (field: 'start' | 'end', value: string) => {
|
||||
setTimes((prev) => ({ ...prev, [field]: value }));
|
||||
if (value !== '' && !TIME_RE.test(value)) return;
|
||||
if (value !== stored[field]) onSave(`roadtrip_day_${field}`, value);
|
||||
};
|
||||
return (
|
||||
<EditCard label={t('roadtrip.window.title')}>
|
||||
{(['start', 'end'] as const).map((field, i) => (
|
||||
<EditRow key={field} label={t(`roadtrip.window.${field}`)} first={i === 0}>
|
||||
<span className="w-[7rem] flex-none">
|
||||
<CustomTimePicker
|
||||
value={shown[field]}
|
||||
disabled={readOnly}
|
||||
aria-label={t(`roadtrip.window.${field}`)}
|
||||
aria-invalid={invalid || undefined}
|
||||
onChange={(value) => setTime(field, value)}
|
||||
/>
|
||||
</span>
|
||||
</EditRow>
|
||||
))}
|
||||
<p
|
||||
role={invalid ? 'alert' : undefined}
|
||||
className={`pb-2 ${invalid ? 'font-geist text-caption leading-snug text-danger' : HINT}`}
|
||||
>
|
||||
{t(invalid ? 'roadtrip.window.invalid' : 'roadtrip.window.hint')}
|
||||
</p>
|
||||
<div className="border-t border-[color:var(--m-rowbr)] pt-2.5">
|
||||
<div className="mb-2 text-body text-m-muted">{t('roadtrip.window.endAt')}</div>
|
||||
<Choice
|
||||
readOnly={readOnly}
|
||||
value={endMode}
|
||||
onChange={(mode) => onSave('roadtrip_day_end_mode', mode)}
|
||||
options={[
|
||||
{ value: 'route', label: t('roadtrip.window.onRoute') },
|
||||
{ value: 'stop', label: t('roadtrip.window.atStop') },
|
||||
]}
|
||||
/>
|
||||
<p className={`mt-2 ${HINT}`}>
|
||||
{t(endMode === 'stop' ? 'roadtrip.window.stopHint' : 'roadtrip.window.routeHint')}
|
||||
</p>
|
||||
{onResetDayBoundaries && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onResetDayBoundaries()}
|
||||
className="mt-2 min-h-9 text-start text-caption font-semibold text-m-ink underline-offset-2 active:underline"
|
||||
>
|
||||
{t('roadtrip.window.resetBoundaries')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</EditCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The vehicle, how far it goes, and how full a stop fills it. */
|
||||
function VehicleCard({ tripId, readOnly, onSave }: CardProps) {
|
||||
const { t } = useTranslation();
|
||||
const imperial = useSettingsStore((st) => st.settings.distance_unit) === 'imperial';
|
||||
const s = useRoadtripSettings((all) => all, tripId);
|
||||
const vehicle = s.roadtrip_vehicle ?? '';
|
||||
const vehicleKind: VehicleKind | null = vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null;
|
||||
const computedKm = rangeFromSpec(vehicleKind, {
|
||||
tankLitres: s.roadtrip_tank_litres,
|
||||
litresPer100: s.roadtrip_litres_per_100,
|
||||
batteryKwh: s.roadtrip_battery_kwh,
|
||||
kwhPer100: s.roadtrip_kwh_per_100,
|
||||
degradationPercent: s.roadtrip_battery_degradation,
|
||||
} satisfies VehicleSpec);
|
||||
const rangeKm = computedKm ?? s.roadtrip_range_km;
|
||||
// Kilometres in storage, the traveller's own unit on screen, as on the desktop.
|
||||
const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, imperial ? 'imperial' : 'metric')) : undefined;
|
||||
const rangeLabel = t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel');
|
||||
return (
|
||||
<EditCard label={t('roadtrip.limit.vehicleLabel')}>
|
||||
<div className="pb-1.5 pt-1">
|
||||
<Choice
|
||||
readOnly={readOnly}
|
||||
value={vehicleKind ?? 'any'}
|
||||
onChange={(kind) => onSave('roadtrip_vehicle', kind === 'any' ? '' : kind)}
|
||||
options={[
|
||||
{ value: 'any', label: t('roadtrip.limit.vehicleAny') },
|
||||
{ value: 'combustion', label: t('roadtrip.limit.vehicleCombustion') },
|
||||
{ value: 'electric', label: t('roadtrip.limit.vehicleElectric') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<EditRow label={rangeLabel}>
|
||||
<FigureField
|
||||
label={rangeLabel}
|
||||
value={rangeShown}
|
||||
suffix={imperial ? 'mi' : 'km'}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
// Worked out from the vehicle's own figures: those are set on the desktop, and a
|
||||
// second answer here would leave nobody knowing which one the warnings use.
|
||||
disabled={readOnly || computedKm !== null}
|
||||
testId="rt-edit-range"
|
||||
onCommit={(v) => onSave('roadtrip_range_km', Math.round(imperial ? v / 0.621371 : v))}
|
||||
/>
|
||||
</EditRow>
|
||||
<EditRow label={t('roadtrip.limit.fillLabel')}>
|
||||
<FigureField
|
||||
label={t('roadtrip.limit.fillLabel')}
|
||||
value={s.roadtrip_fill_percent}
|
||||
suffix="%"
|
||||
placeholder={t('roadtrip.limit.fillFull')}
|
||||
disabled={readOnly}
|
||||
testId="rt-edit-fill"
|
||||
onCommit={(v) => onSave('roadtrip_fill_percent', Math.min(100, v))}
|
||||
/>
|
||||
</EditRow>
|
||||
{computedKm !== null && <p className={`pb-1 ${HINT}`}>{t('roadtrip.limit.computed')}</p>}
|
||||
</EditCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the road may avoid: one stored list that each switch adds to or takes from. */
|
||||
function AvoidCard({ tripId, readOnly, onSave }: CardProps) {
|
||||
const { t } = useTranslation();
|
||||
const avoiding = parseAvoid(useRoadtripSettings((all) => all.roadtrip_avoid, tripId));
|
||||
// No second engine, no avoidance: the switches stay, disabled, and the hint says why.
|
||||
const canAvoid = valhallaAvailable();
|
||||
const rows: { cls: RouteAvoidClass; label: string }[] = [
|
||||
{ cls: 'toll', label: t('roadtrip.avoid.toll') },
|
||||
{ cls: 'motorway', label: t('roadtrip.avoid.motorway') },
|
||||
{ cls: 'ferry', label: t('roadtrip.avoid.ferry') },
|
||||
];
|
||||
const toggle = (cls: RouteAvoidClass) =>
|
||||
onSave(
|
||||
'roadtrip_avoid',
|
||||
serializeAvoid(avoiding.includes(cls) ? avoiding.filter((c) => c !== cls) : [...avoiding, cls])
|
||||
);
|
||||
return (
|
||||
<EditCard label={t('roadtrip.avoid.section')}>
|
||||
{rows.map(({ cls, label }, i) => (
|
||||
<EditRow key={cls} label={label} first={i === 0}>
|
||||
<MToggle
|
||||
checked={avoiding.includes(cls)}
|
||||
onChange={() => toggle(cls)}
|
||||
ariaLabel={label}
|
||||
disabled={readOnly || !canAvoid}
|
||||
/>
|
||||
</EditRow>
|
||||
))}
|
||||
<p className={`pb-1 ${HINT}`}>{t(canAvoid ? 'roadtrip.avoid.hint' : 'roadtrip.avoid.unavailable')}</p>
|
||||
</EditCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MRtLimitsEditor({
|
||||
onResetDayBoundaries,
|
||||
...card
|
||||
}: CardProps & {
|
||||
onResetDayBoundaries?: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5" data-testid="rt-limits-editor">
|
||||
<DrivingCard {...card} />
|
||||
<WindowCard {...card} onResetDayBoundaries={onResetDayBoundaries} />
|
||||
<VehicleCard {...card} />
|
||||
<AvoidCard {...card} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Link2 } from 'lucide-react';
|
||||
import { useGoogleRouteImport } from '../../../../components/Roadtrip/useGoogleRouteImport';
|
||||
import { useIsWide } from '../../../../hooks/sizeClass';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import MSheet from '../../../components/MSheet';
|
||||
import { Eyebrow, SHEET_PRIMARY, SHEET_SECONDARY, TileHeader } from '../sheets/MTripSheetUi';
|
||||
import { FIELD_CLS } from '../sheets/PlSheetChrome';
|
||||
|
||||
const NOTE = 'font-geist text-caption leading-snug';
|
||||
|
||||
/**
|
||||
* A route shared from Google Maps, appended to a day, on an iPad or an unfolded Duo.
|
||||
*
|
||||
* The desktop's import dialog (GoogleRouteImport) as the stage card's second offer under the
|
||||
* track one, over the same useGoogleRouteImport: a link is previewed, its stops listed in
|
||||
* order with the ones that did not resolve said so, and the resolved ones appended to the
|
||||
* day picked. Absent on the phone, where the stage stays a plan read on the road, and for a
|
||||
* reader who may not add places to days, since the import does both.
|
||||
*/
|
||||
export default function MRtRouteImport({
|
||||
tripId,
|
||||
dayId,
|
||||
canEdit,
|
||||
}: {
|
||||
tripId: number;
|
||||
dayId?: number;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const large = useIsWide();
|
||||
const flow = useGoogleRouteImport(tripId, dayId);
|
||||
if (!large || !canEdit) return null;
|
||||
const { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit } =
|
||||
flow;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={show}
|
||||
className="mt-2 flex min-h-10 w-full items-center gap-2 rounded-[13px] bg-[color:var(--m-ic)] px-3 py-2 text-start"
|
||||
>
|
||||
<Link2 size={15} strokeWidth={1.9} className="flex-none text-m-muted" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-m-ink">
|
||||
{t('roadtrip.import.title')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<MSheet open={open} onClose={close} variant="bottom" material="glass" ariaLabel={t('roadtrip.import.title')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Link2 size={19} strokeWidth={1.8} />}
|
||||
title={<span className="truncate">{t('roadtrip.import.title')}</span>}
|
||||
sub={t('roadtrip.import.note')}
|
||||
subWrap
|
||||
onClose={close}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-3 pt-[14px]">
|
||||
{!preview ? (
|
||||
<input
|
||||
type="url"
|
||||
dir="ltr"
|
||||
value={url}
|
||||
disabled={busy}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
aria-label="Google Maps URL"
|
||||
placeholder="https://maps.app.goo.gl/…"
|
||||
className={`${FIELD_CLS} disabled:opacity-50`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Eyebrow className="mb-[5px]">{t('roadtrip.import.day')}</Eyebrow>
|
||||
<select
|
||||
value={target}
|
||||
disabled={busy || saved}
|
||||
onChange={(e) => pickDay(e.target.value)}
|
||||
aria-label={t('roadtrip.import.day')}
|
||||
className={`${FIELD_CLS} appearance-none`}
|
||||
>
|
||||
{days.map((day) => (
|
||||
<option key={day.id} value={String(day.id)}>
|
||||
{day.title || `${t('roadtrip.import.day')} ${day.day_number}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ol
|
||||
className="m-0 mt-3 flex list-none flex-col gap-2.5 rounded-[16px] bg-[color:var(--m-ic)] p-3.5"
|
||||
data-testid="rt-import-stops"
|
||||
>
|
||||
{preview.stops.map((stop, index) => {
|
||||
const unresolved = stop.lat == null || stop.lng == null;
|
||||
return (
|
||||
<li key={index} className="flex items-baseline gap-3">
|
||||
<span className="grid h-5 w-5 flex-none place-items-center rounded-full bg-[color:var(--m-card)] text-caption font-semibold tabular-nums text-m-muted">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 break-words text-body text-m-ink">
|
||||
<span dir="auto">{stop.name}</span>
|
||||
{unresolved && (
|
||||
<span className={`block ${NOTE} text-m-faint`}>{t('roadtrip.import.unresolved')}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
{failure && (
|
||||
<p role="alert" className={`mt-2.5 ${NOTE} text-danger`}>
|
||||
{saved ? `${t('common.saved')}: ` : ''}
|
||||
{failure}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center justify-end gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<button type="button" disabled={busy} onClick={close} className={SHEET_SECONDARY}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" disabled={!canSubmit} onClick={submit} className={SHEET_PRIMARY}>
|
||||
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
|
||||
</button>
|
||||
</div>
|
||||
</MSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { AlertTriangle, Check, RotateCw, Spline } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { FollowTrackOutcome } from '../../../../components/Roadtrip/useFollowTrack';
|
||||
import { useIsWide } from '../../../../hooks/sizeClass';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import type { DistanceUnit } from '../../../../types';
|
||||
import { formatDistance } from '../../../../utils/units';
|
||||
import MSheet from '../../../components/MSheet';
|
||||
import type { MTripSheetsProps } from '../MTripShell';
|
||||
import { SHEET_PRIMARY, SHEET_SECONDARY, TileHeader } from '../sheets/MTripSheetUi';
|
||||
|
||||
/**
|
||||
* Which imported track a day drives along, on an iPad or an unfolded Duo.
|
||||
*
|
||||
* The desktop's track dialog (RoadtripTrackModal) as a sheet, over the same `followTrack`
|
||||
* of the planner: it lists the trip's tracks nearest to the day first, fits the drive to
|
||||
* the one picked with via points, and drops those vias again. The phone has no entry to
|
||||
* it; the stage there stays a plan read on the road, and reshaping a whole day's drive
|
||||
* is work for the larger screen. The sheet opens when the planner's `followTrack` has a
|
||||
* day, which only the large screen's stage card asks for.
|
||||
*/
|
||||
|
||||
/** Within this many kilometres a track counts as running along the day. */
|
||||
const ON_THIS_DAY_KM = 2;
|
||||
|
||||
/** The sentence for a finished fit: nothing to do, as close as asked, or as close as it got. */
|
||||
function outcomeKey(outcome: FollowTrackOutcome): string {
|
||||
if (outcome.vias === 0) return 'roadtrip.track.already';
|
||||
return outcome.capped ? 'roadtrip.track.capped' : 'roadtrip.track.done';
|
||||
}
|
||||
|
||||
const NOTE = 'font-geist text-caption leading-snug';
|
||||
|
||||
/**
|
||||
* The stage card's way into the sheet, on a large screen only: the track the day follows,
|
||||
* or the offer to follow one. Absent on the phone, for a day not stored yet, for a trip
|
||||
* without tracks (its only answer would be "no tracks") and for a reader who may not edit
|
||||
* days, since following a track writes via points onto the day.
|
||||
*/
|
||||
export function MRtTrackEntry({ planner, dayId }: { planner: MTripSheetsProps['planner']; dayId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const large = useIsWide();
|
||||
const follow = planner.followTrack;
|
||||
if (!large || dayId < 0 || !follow.available || !planner.can('day_edit', planner.trip)) return null;
|
||||
const name = follow.namesByDay[dayId];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => follow.open(dayId)}
|
||||
className="mt-2.5 flex min-h-10 w-full items-center gap-2 rounded-[13px] bg-[color:var(--m-ic)] px-3 py-2 text-start"
|
||||
>
|
||||
<Spline size={15} strokeWidth={1.9} className="flex-none text-m-muted" aria-hidden />
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-caption font-semibold text-m-ink">
|
||||
{name ? t('roadtrip.track.current', { name }) : t('roadtrip.track.hint')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MRtTrackSheet({ planner }: MTripSheetsProps) {
|
||||
const { t } = useTranslation();
|
||||
const unit: DistanceUnit = useSettingsStore((s) => s.settings.distance_unit) === 'imperial' ? 'imperial' : 'metric';
|
||||
const large = useIsWide();
|
||||
const follow = planner.followTrack;
|
||||
const { dayId, busy, round, error, outcome, tracks, viaCount, current } = follow;
|
||||
// The pick belongs to the day it was made on: a track picked on one day means nothing
|
||||
// on the next, so it is kept together with that day and read only while they match.
|
||||
const [pick, setPick] = useState<{ dayId: number; trackId: number } | null>(null);
|
||||
const picked = pick && pick.dayId === dayId ? pick.trackId : null;
|
||||
const chosen = tracks.find((track) => track.id === picked) ?? null;
|
||||
const open = large && dayId !== null;
|
||||
const dayNumber = planner.roadtripRoutes.days.find((d) => d.dayId === dayId)?.dayNumber ?? 0;
|
||||
|
||||
return (
|
||||
<MSheet
|
||||
open={open}
|
||||
onClose={follow.close}
|
||||
variant="bottom"
|
||||
material="glass"
|
||||
ariaLabel={t('roadtrip.track.title', { number: dayNumber })}
|
||||
>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Spline size={19} strokeWidth={1.8} />}
|
||||
title={<span className="truncate">{t('roadtrip.track.title', { number: dayNumber })}</span>}
|
||||
sub={current ? t('roadtrip.track.current', { name: current.name }) : undefined}
|
||||
subWrap
|
||||
onClose={follow.close}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-3 pt-[14px]">
|
||||
{tracks.length === 0 ? (
|
||||
<div className="rounded-[16px] bg-[color:var(--m-ic)] px-4 py-5 text-center">
|
||||
<div className="text-body font-semibold text-m-ink">{t('roadtrip.track.empty')}</div>
|
||||
<p className={`mt-1 ${NOTE} text-m-faint`}>{t('roadtrip.track.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul
|
||||
className="m-0 flex list-none flex-col gap-1 rounded-[16px] bg-[color:var(--m-ic)] p-1.5"
|
||||
data-testid="rt-track-list"
|
||||
>
|
||||
{tracks.map((track) => {
|
||||
const on = track.id === picked;
|
||||
return (
|
||||
<li key={track.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
aria-pressed={on}
|
||||
onClick={() => setPick(on || dayId === null ? null : { dayId, trackId: track.id })}
|
||||
className={`flex min-h-12 w-full items-center gap-3 rounded-[12px] px-3 py-2 text-start disabled:opacity-50 ${
|
||||
on ? 'bg-[color:var(--m-card)] ring-1 ring-[color:var(--m-cbr)]' : ''
|
||||
}`}
|
||||
>
|
||||
{/* The line's own colour as a short stroke: it is a line on the map. */}
|
||||
<span
|
||||
className="h-1 w-6 flex-none rounded-full"
|
||||
style={{ background: track.color || 'var(--m-faint)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span dir="auto" className="truncate text-body font-semibold text-m-ink">
|
||||
{track.name || t('roadtrip.track.untitled')}
|
||||
</span>
|
||||
<span className={`${NOTE} text-m-faint`}>
|
||||
{t('roadtrip.track.length', { distance: formatDistance(track.lengthKm, unit) })}
|
||||
{' · '}
|
||||
{track.gapKm <= ON_THIS_DAY_KM
|
||||
? t('roadtrip.track.onDay')
|
||||
: t('roadtrip.track.gap', { distance: formatDistance(track.gapKm, unit) })}
|
||||
</span>
|
||||
</span>
|
||||
{on && <Check size={16} className="flex-none text-m-ink" aria-hidden />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* What the pick will do, before it is done: a track becomes via points on the day. */}
|
||||
{chosen && <p className={`mt-2.5 ${NOTE} text-m-muted`}>{t('roadtrip.track.explain')}</p>}
|
||||
|
||||
{busy && (
|
||||
<p role="status" className={`mt-2.5 flex items-center gap-2 ${NOTE} text-m-muted`}>
|
||||
<RotateCw size={13} className="flex-none animate-spin" aria-hidden />
|
||||
{t('roadtrip.track.working', { round })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className={`mt-2.5 flex items-start gap-1.5 ${NOTE} text-danger`}>
|
||||
<AlertTriangle size={13} className="mt-0.5 flex-none" aria-hidden />
|
||||
{t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{outcome && !busy && !error && (
|
||||
<p role="status" className={`mt-2.5 flex items-start gap-1.5 ${NOTE} text-m-muted`}>
|
||||
<Check size={13} className="mt-0.5 flex-none" aria-hidden />
|
||||
{t(outcomeKey(outcome), { count: outcome.vias, distance: formatDistance(outcome.strayKm, unit) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none flex-wrap items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
{/* Whenever the day carries vias, whoever put them there: back to the router's way. */}
|
||||
{viaCount > 0 && (
|
||||
<button type="button" disabled={busy} onClick={() => void follow.clear()} className={SHEET_SECONDARY}>
|
||||
{t('roadtrip.track.clear', { count: viaCount })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!chosen || busy}
|
||||
onClick={() => {
|
||||
if (chosen) void follow.apply(chosen.id);
|
||||
}}
|
||||
className={`ms-auto ${SHEET_PRIMARY}`}
|
||||
>
|
||||
{t('roadtrip.track.action')}
|
||||
</button>
|
||||
</div>
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { buildPlace } from '../../../../../tests/helpers/factories'
|
||||
import { resetAllStores, seedStore } from '../../../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { MSelectedTourDetail } from './MTripSheets'
|
||||
import { _pinSizeClass } from '../../../../hooks/sizeClass'
|
||||
|
||||
const place = buildPlace({
|
||||
id: 42,
|
||||
@@ -58,6 +59,10 @@ function planner(overrides: Partial<TripPlanner> = {}): TripPlanner {
|
||||
} as unknown as TripPlanner
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
_pinSizeClass(null)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
@@ -108,4 +113,31 @@ describe('MSelectedTourDetail', () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(document.querySelector('#tour-elevation-42')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-TOURDETAIL-001: the phone reads the tour, with no way to rename it', () => {
|
||||
render(<MSelectedTourDetail planner={planner({ can: vi.fn<TripPlanner['can']>(() => true) })} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TOURDETAIL-002: an iPad edits the tour as the desktop card does, through the shared actions', async () => {
|
||||
_pinSizeClass('wide')
|
||||
const p = planner({ can: vi.fn<TripPlanner['can']>(() => true) })
|
||||
render(<MSelectedTourDetail planner={p} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
const field = screen.getByRole('textbox', { name: 'Name' })
|
||||
fireEvent.change(field, { target: { value: 'Ridge walk long' } })
|
||||
fireEvent.keyDown(field, { key: 'Enter' })
|
||||
|
||||
await vi.waitFor(() => expect(p.tripActions.updatePlace).toHaveBeenCalledWith(7, 42, { name: 'Ridge walk long' }))
|
||||
await vi.waitFor(() => expect(p.reloadTourPlaceIds).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-MOB-TOURDETAIL-003: on an iPad a member who may not edit places still only reads', () => {
|
||||
_pinSizeClass('split')
|
||||
render(<MSelectedTourDetail planner={planner()} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import TourDetailDialog from '../../../../components/Tours/TourDetailDialog'
|
||||
import { useBookingExpenseEditor } from '../../../../pages/tripPlanner/useBookingExpenseEditor'
|
||||
import { applyTripCoverUpdate } from '../../../../pages/tripPlanner/tripCover'
|
||||
import { latestReservation } from '../../../../pages/tripPlanner/transportEditorOpeners'
|
||||
import { useTourDetailActions } from '../../../../pages/tripPlanner/useTourDetailActions'
|
||||
import { useIsWide } from '../../../../hooks/sizeClass'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import MDayImpactList from '../../../components/MDayImpactList'
|
||||
import MDaySheet from './MDaySheet'
|
||||
@@ -29,6 +31,7 @@ import MRtStopSheet from '../roadtrip/MRtStopSheet'
|
||||
import MRtStaySheet from '../roadtrip/MRtStaySheet'
|
||||
import MRtKindSheet from '../roadtrip/MRtKindSheet'
|
||||
import MRtInfoSheet from '../roadtrip/MRtInfoSheet'
|
||||
import MRtTrackSheet from '../roadtrip/MRtTrackSheet'
|
||||
import MRtCorridorSheet from '../roadtrip/MRtCorridorSheet'
|
||||
import MRtDraftSheet from '../roadtrip/MRtDraftSheet'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
@@ -40,11 +43,18 @@ import { useLastOpen } from '../../../../components/shared/usePresence'
|
||||
* The one global mobile tour-detail owner, independent of which surface
|
||||
* selected it. The tour shows in a sheet like every other detail
|
||||
* (MTourDetailShell); it keeps the tour it showed last while it slides away.
|
||||
*
|
||||
* On the phone it reads, and plans the tour onto a day. On an iPad or an
|
||||
* unfolded Duo it edits what the desktop's tour card edits, through the same
|
||||
* useTourDetailActions: the name and colour, a copy saved as a new tour, the
|
||||
* recorded tracks, the packing list it points at and its files.
|
||||
*/
|
||||
export function MSelectedTourDetail({ planner }: Pick<MTripSheetsProps, 'planner'>) {
|
||||
const open = !!(planner.selectedTour && planner.selectedPlace)
|
||||
const tour = useLastOpen(planner.selectedTour, open)
|
||||
const place = useLastOpen(planner.selectedPlace, open)
|
||||
const actions = useTourDetailActions(planner)
|
||||
const canEdit = useIsWide() && planner.can('place_edit', planner.trip)
|
||||
if (!tour || !place) return null
|
||||
|
||||
return (
|
||||
@@ -58,9 +68,11 @@ export function MSelectedTourDetail({ planner }: Pick<MTripSheetsProps, 'planner
|
||||
selectedAssignmentId={planner.selectedAssignmentId}
|
||||
assignments={planner.assignments}
|
||||
files={planner.files}
|
||||
readOnly
|
||||
canEdit={false}
|
||||
{...(canEdit ? actions : {})}
|
||||
readOnly={!canEdit}
|
||||
canEdit={canEdit}
|
||||
canAssign={planner.can('day_edit', planner.trip)}
|
||||
onFileUpload={canEdit && planner.canUploadFiles ? (fd: FormData) => planner.tripActions.addFile(planner.tripId, fd) : undefined}
|
||||
onClose={() => planner.setSelectedPlaceId(null)}
|
||||
onAssignToDay={planner.handleAssignToDay}
|
||||
onRemoveAssignment={planner.handleRemoveAssignment}
|
||||
@@ -109,6 +121,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
<MRtStaySheet planner={planner} shell={shell} />
|
||||
<MRtKindSheet planner={planner} shell={shell} />
|
||||
<MRtInfoSheet planner={planner} shell={shell} />
|
||||
<MRtTrackSheet planner={planner} shell={shell} />
|
||||
{/* Before the draft sheet, not after: both sit at the same z, so the one mounted
|
||||
later paints on top, and taking a hit onto the trip opens the draft OVER the
|
||||
search it came from. */}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The place in the trip shell's top row that a tab fills with its own controls,
|
||||
* beside the back button: the tours tab its switch between the trip's tours and
|
||||
* the editor (and the map button where the window shows the pane or the map),
|
||||
* the journal its Journey/Gallery switch and its menu. Without it those sat on
|
||||
* a row of their own under the shell's, an empty band above every such tab.
|
||||
*
|
||||
* The shell hands the node in through a callback ref; the tab portals into it.
|
||||
* Kept outside React state so the shell does not render again for it.
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
let slot: HTMLElement | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** The callback ref of the shell's slot. */
|
||||
export function setTabHeadSlot(node: HTMLElement | null): void {
|
||||
if (node === slot) return;
|
||||
slot = node;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** The slot while the shell shows it, else null (the tab then keeps its controls on its own row). */
|
||||
export function useTabHeadSlot(): HTMLElement | null {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => slot,
|
||||
() => null
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { edgeFadeClass, useEdgeFade } from '../../../components/useEdgeFade'
|
||||
import CollabAssistantBadge from '../../../../components/Collab/CollabAssistantBadge'
|
||||
import { useCollabChatData } from '../../../../components/Collab/useCollabChatData'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
import { useKeyboardInset } from '../../../../hooks/useKeyboardInset'
|
||||
import MImageViewer from '../../../components/MImageViewer'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { pressHaptic } from '../../../../native/haptics'
|
||||
@@ -55,10 +57,18 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
|
||||
|
||||
const {
|
||||
messages, loading, hasMore, loadingMore, text, replyTo, setReplyTo, sending, images,
|
||||
scrollRef, textareaRef, checkAtBottom, handleLoadMore, handleTextChange, addImageFiles, handleSend,
|
||||
scrollRef, textareaRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, addImageFiles, handleSend,
|
||||
deleteMessage, reactToMessage,
|
||||
} = useCollabChatData({ tripId, t, toast, toastLoadMoreErrors: true, canSend: canEdit })
|
||||
// The trip shell is a fixed layer the app's keyboard does not shrink, so the
|
||||
// composer is lifted by the keyboard itself; reading the latest message, the
|
||||
// list follows it up.
|
||||
const keyboard = useKeyboardInset()
|
||||
useEffect(() => {
|
||||
if (keyboard > 0 && isAtBottom.current) scrollToBottom()
|
||||
}, [keyboard, isAtBottom, scrollToBottom])
|
||||
const [popover, setPopover] = useState<{ msg: ChatMessage; x: number; y: number } | null>(null)
|
||||
const [viewing, setViewing] = useState<{ src: string; alt: string } | null>(null)
|
||||
const [list, setList] = useState<HTMLDivElement | null>(null)
|
||||
const listFade = useEdgeFade(list, 'y')
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -80,7 +90,10 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col px-4 pb-[var(--bottom-nav-h,84px)] pt-[calc(var(--m-safe-top,12px)+58px)] wide:mx-auto wide:w-full wide:max-w-[808px] wide:px-6 wide:pb-[calc(var(--bottom-nav-h,0px)+12px)]">
|
||||
<div
|
||||
className="flex h-full flex-col px-4 pb-[var(--bottom-nav-h,84px)] pt-[calc(var(--m-safe-top,12px)+58px)] wide:mx-auto wide:w-full wide:max-w-[808px] wide:px-6 wide:pb-[calc(var(--bottom-nav-h,0px)+12px)]"
|
||||
style={keyboard > 0 ? { paddingBottom: keyboard + 8 } : undefined}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 size={22} strokeWidth={2} className="animate-spin text-m-faint" />
|
||||
@@ -147,6 +160,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
|
||||
t={t}
|
||||
onOpenActions={(x, y) => setPopover({ msg, x, y })}
|
||||
onReactBadgeTap={emoji => canEdit && handleReact(msg.id, emoji)}
|
||||
onOpenImage={setViewing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -219,6 +233,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewing && <MImageViewer src={viewing.src} alt={viewing.alt} onClose={() => setViewing(null)} />}
|
||||
{popover && (
|
||||
<MessageActionsPopover
|
||||
x={popover.x}
|
||||
@@ -237,7 +252,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h, canEdit, t, onOpenActions, onReactBadgeTap }: {
|
||||
function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h, canEdit, t, onOpenActions, onReactBadgeTap, onOpenImage }: {
|
||||
msg: ChatMessage
|
||||
own: boolean
|
||||
showHeader: boolean
|
||||
@@ -248,6 +263,7 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
|
||||
t: TripPlanner['t']
|
||||
onOpenActions: (x: number, y: number) => void
|
||||
onReactBadgeTap: (emoji: string) => void
|
||||
onOpenImage: (image: { src: string; alt: string }) => void
|
||||
}) {
|
||||
const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const firedRef = useRef(false)
|
||||
@@ -323,7 +339,7 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
{bubbleShown(msg, bigEmoji, hasReply) && <button
|
||||
type="button"
|
||||
aria-label={canEdit ? t('collab.chat.messageOptions') : undefined}
|
||||
onPointerDown={onPointerDown}
|
||||
@@ -348,12 +364,18 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
|
||||
</div>
|
||||
)}
|
||||
<span dir="auto" className="whitespace-pre-wrap break-words">{msg.text}</span>
|
||||
{msg.attachments?.map(att => (
|
||||
<img key={att.id} src={att.url} alt={att.original_name || ''} className="mt-2 max-h-52 w-full rounded-[10px] object-cover" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</button>}
|
||||
|
||||
{/* Below the bubble rather than in it: the bubble is the long-press target,
|
||||
and an image is tapped to be seen large (MImageViewer). */}
|
||||
<ChatImages
|
||||
attachments={msg.attachments}
|
||||
t={t}
|
||||
press={{ onPointerDown, onPointerMove, clearPress, fired: firedRef }}
|
||||
onOpen={onOpenImage}
|
||||
/>
|
||||
|
||||
{msg.reactions.length > 0 && (
|
||||
<div className="mt-[-4px] flex flex-wrap gap-[3px] px-1">
|
||||
@@ -381,6 +403,50 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
|
||||
)
|
||||
}
|
||||
|
||||
/** A message has a bubble unless it is a picture alone. */
|
||||
function bubbleShown(msg: ChatMessage, bigEmoji: boolean, hasReply: boolean): boolean {
|
||||
return bigEmoji || hasReply || !!msg.text?.trim()
|
||||
}
|
||||
|
||||
/** A message's pictures, each opened large on a tap; a long press opens the message's actions, as on the bubble. */
|
||||
function ChatImages({ attachments, t, press, onOpen }: {
|
||||
attachments: ChatMessage['attachments']
|
||||
t: TripPlanner['t']
|
||||
press: {
|
||||
onPointerDown: (e: React.PointerEvent) => void
|
||||
onPointerMove: (e: React.PointerEvent) => void
|
||||
clearPress: () => void
|
||||
fired: React.RefObject<boolean>
|
||||
}
|
||||
onOpen: (image: { src: string; alt: string }) => void
|
||||
}) {
|
||||
if (!attachments?.length) return null
|
||||
return (
|
||||
<>
|
||||
{attachments.map(att => {
|
||||
const alt = att.original_name || t('collab.chat.attachedImage')
|
||||
return (
|
||||
<button
|
||||
key={att.id}
|
||||
type="button"
|
||||
onPointerDown={press.onPointerDown}
|
||||
onPointerMove={press.onPointerMove}
|
||||
onPointerUp={press.clearPress}
|
||||
onPointerCancel={press.clearPress}
|
||||
onClick={() => {
|
||||
if (!press.fired.current) onOpen({ src: att.url, alt })
|
||||
}}
|
||||
aria-label={alt}
|
||||
className="mt-1 block w-full max-w-[260px] overflow-hidden rounded-[12px]"
|
||||
>
|
||||
<img src={att.url} alt="" className="max-h-52 w-full object-cover" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageActionsPopover({ x, y, canDeleteOwn, t, onReact, onReply, onDelete, onClose }: {
|
||||
x: number
|
||||
y: number
|
||||
|
||||
@@ -281,7 +281,7 @@ function NoteCardRow({ note, color, canEdit, onTap, onTogglePin, onDelete, t }:
|
||||
const websiteHref = safeExternalHref(note.website)
|
||||
|
||||
return (
|
||||
<div className="mt-2 overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] wide:mb-3 wide:mt-0 wide:break-inside-avoid">
|
||||
<div className="mt-2 overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] wide:mb-3 wide:mt-0 wide:inline-block wide:w-full wide:break-inside-avoid wide:align-top">
|
||||
<div className="flex items-center gap-[7px] px-3 py-[10px]" style={{ background: `${color}0d` }}>
|
||||
{!!note.pinned && <Pin size={11} strokeWidth={2.4} style={{ color }} className="flex-none" />}
|
||||
<button type="button" onClick={onTap} className="flex min-w-0 flex-1 items-center gap-[7px] text-start">
|
||||
|
||||
@@ -182,7 +182,7 @@ export function AddPaymentSheet({
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder={zeroAmountInput(currency)}
|
||||
aria-label={t('costs.amount')}
|
||||
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-[0.84375rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] placeholder:font-normal placeholder:text-m-faint"
|
||||
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-body font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] placeholder:font-normal placeholder:text-m-faint"
|
||||
/>
|
||||
<span className="flex-none font-geist text-caption font-bold text-m-faint">{currency}</span>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,10 @@ export function FileTile({ file, selected, canStar, onPick, onStar, onMenu, t }:
|
||||
)}
|
||||
</span>
|
||||
<span className="flex flex-1 flex-col gap-[6px] px-3 pb-[10px] pt-2">
|
||||
<span dir="auto" className="line-clamp-2 break-words text-[length:calc(12.5px*var(--fs-scale-body,1))] font-bold leading-[1.3] text-m-ink">
|
||||
<span
|
||||
dir="auto"
|
||||
className="line-clamp-2 break-words text-[length:calc(12.5px*var(--fs-scale-body,1))] font-bold leading-[1.3] text-m-ink"
|
||||
>
|
||||
{file.original_name}
|
||||
</span>
|
||||
<span className="mt-auto flex flex-wrap items-center gap-1">
|
||||
@@ -166,7 +169,10 @@ export function FileTile({ file, selected, canStar, onPick, onStar, onMenu, t }:
|
||||
function Badge({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
// A size, a date or a name, each in its own direction ("46.1 KB" stays so in Arabic).
|
||||
<span dir="auto" className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-muted">
|
||||
<span
|
||||
dir="auto"
|
||||
className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-muted"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@@ -181,6 +187,43 @@ interface FilePreviewPaneProps {
|
||||
onMenu: () => void;
|
||||
}
|
||||
|
||||
/** The pane's heading: the type's tile when nothing is shown in place, the name, and what the file is. */
|
||||
function FileHeading({ file, shownInPlace }: { file: TripFile; shownInPlace: boolean }) {
|
||||
const { locale } = useTranslation();
|
||||
const meta = getFileTypeMeta(file);
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
{!shownInPlace && (
|
||||
<span className="relative block h-[92px] w-[74px] flex-none overflow-hidden rounded-xl border border-[color:var(--m-rowbr)] bg-m-sheetop">
|
||||
<TypeArt file={file} />
|
||||
</span>
|
||||
)}
|
||||
<div className={`min-w-0 flex-1 ${shownInPlace ? '' : 'pt-1'}`}>
|
||||
<h3 className="break-words text-[length:calc(16px*var(--fs-scale-subtitle,1))] font-extrabold leading-[1.3] text-m-ink">
|
||||
<bdi>{file.original_name}</bdi>
|
||||
</h3>
|
||||
<div className="mt-[6px] flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
className="rounded-full px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-extrabold tracking-[.06em]"
|
||||
style={{ background: `${meta.color}1f`, color: meta.color }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
{isWalletPass(file.mime_type, file.original_name) && (
|
||||
<span className="flex items-center gap-[3px] rounded-full bg-m-act px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-actfg">
|
||||
<Wallet size={9} strokeWidth={2.4} aria-hidden="true" />
|
||||
{APPLE_WALLET}
|
||||
</span>
|
||||
)}
|
||||
{!!file.file_size && <Badge>{formatSize(file.file_size)}</Badge>}
|
||||
<Badge>{formatFileDate(file.created_at, locale)}</Badge>
|
||||
{file.uploaded_by_name && <Badge>{file.uploaded_by_name}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file picked in the grid, shown beside it once the window has room for
|
||||
* two panes, the way Files on an iPad previews a document: the picture or the
|
||||
@@ -191,8 +234,6 @@ interface FilePreviewPaneProps {
|
||||
*/
|
||||
export function FilePreviewPane({ file, planner, onOpen, onStar, onLinks, onMenu }: FilePreviewPaneProps) {
|
||||
const { t } = planner;
|
||||
const { locale } = useTranslation();
|
||||
const meta = getFileTypeMeta(file);
|
||||
const image = isImage(file.mime_type);
|
||||
const pdf = isPdf(file) && pdfInPage();
|
||||
const src = useSignedUrl(file.url, image || pdf);
|
||||
@@ -229,35 +270,7 @@ export function FilePreviewPane({ file, planner, onOpen, onStar, onLinks, onMenu
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
{!shownInPlace && (
|
||||
<span className="relative block h-[92px] w-[74px] flex-none overflow-hidden rounded-xl border border-[color:var(--m-rowbr)] bg-m-sheetop">
|
||||
<TypeArt file={file} />
|
||||
</span>
|
||||
)}
|
||||
<div className={`min-w-0 flex-1 ${shownInPlace ? '' : 'pt-1'}`}>
|
||||
<h3 className="break-words text-[length:calc(16px*var(--fs-scale-subtitle,1))] font-extrabold leading-[1.3] text-m-ink">
|
||||
<bdi>{file.original_name}</bdi>
|
||||
</h3>
|
||||
<div className="mt-[6px] flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
className="rounded-full px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-extrabold tracking-[.06em]"
|
||||
style={{ background: `${meta.color}1f`, color: meta.color }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
{isWalletPass(file.mime_type, file.original_name) && (
|
||||
<span className="flex items-center gap-[3px] rounded-full bg-m-act px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-actfg">
|
||||
<Wallet size={9} strokeWidth={2.4} aria-hidden="true" />
|
||||
{APPLE_WALLET}
|
||||
</span>
|
||||
)}
|
||||
{!!file.file_size && <Badge>{formatSize(file.file_size)}</Badge>}
|
||||
<Badge>{formatFileDate(file.created_at, locale)}</Badge>
|
||||
{file.uploaded_by_name && <Badge>{file.uploaded_by_name}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FileHeading file={file} shownInPlace={shownInPlace} />
|
||||
|
||||
{(linked.places.length > 0 || linked.reservations.length > 0 || file.note_id != null) && (
|
||||
<div className="flex flex-wrap gap-[6px]">
|
||||
|
||||
@@ -480,7 +480,7 @@ function PackingCategoryCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-[10px] overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop wide:mb-[10px] wide:mt-0 wide:break-inside-avoid">
|
||||
<div className="mt-[10px] overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop wide:mb-[10px] wide:mt-0 wide:inline-block wide:w-full wide:break-inside-avoid wide:align-top">
|
||||
{/* A <div role="button"> rather than a real <button type="button"> — the rename input and
|
||||
the assign/menu triggers below are interactive elements a <button type="button"> may
|
||||
not contain (invalid HTML); this stays keyboard-operable via onKeyDown. */}
|
||||
|
||||
@@ -44,7 +44,10 @@ export function CardTitleButton({
|
||||
onClick={onOpen}
|
||||
className="flex min-w-0 flex-1 flex-wrap items-center gap-x-[7px] gap-y-1 text-start"
|
||||
>
|
||||
<span dir="auto" className="line-clamp-2 min-w-0 flex-[1_1_auto] break-words text-[0.78125rem] font-bold text-m-ink wide:basis-full">
|
||||
<span
|
||||
dir="auto"
|
||||
className="line-clamp-2 min-w-0 flex-[1_1_auto] break-words text-[0.78125rem] font-bold text-m-ink wide:basis-full"
|
||||
>
|
||||
{res.title}
|
||||
</span>
|
||||
<span className="inline-flex flex-none items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-m-card px-2 py-[2px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.06em] text-m-muted">
|
||||
@@ -116,7 +119,9 @@ export function CardWhenAndCode({
|
||||
<Field label={t('reservations.date')} className="flex-[1.4]">
|
||||
{dayDate ? (
|
||||
<span className="flex min-w-0 items-center justify-center gap-1.5">
|
||||
<span dir="auto" className="min-w-0 truncate">{dayValue}</span>
|
||||
<span dir="auto" className="min-w-0 truncate">
|
||||
{dayValue}
|
||||
</span>
|
||||
<span className="flex-none font-medium text-m-faint">{dayDate}</span>
|
||||
</span>
|
||||
) : (
|
||||
@@ -178,7 +183,9 @@ export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFo
|
||||
className="flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]"
|
||||
>
|
||||
<FileText size={12} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span dir="auto" className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">{f.original_name}</span>
|
||||
<span dir="auto" className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">
|
||||
{f.original_name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -144,7 +144,7 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
type="button"
|
||||
onClick={toggleStatus}
|
||||
aria-label={confirmed ? t('reservations.pending') : t('reservations.confirmed')}
|
||||
className="-m-1 flex-none p-1"
|
||||
className="-m-1 flex flex-none p-1"
|
||||
>
|
||||
<StatusDot color={dotColor} />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { MTripTabPanelProps } from '../MTripShell'
|
||||
import { lazyWithRetry } from '../../../../utils/lazyWithRetry'
|
||||
import { LazyPanel } from '../../../../pages/tripPlanner/LazyPanel'
|
||||
import { TabScroller } from './tabChrome'
|
||||
import MTransportsTab from './MTransportsTab'
|
||||
import MBookingsTab from './MBookingsTab'
|
||||
@@ -10,6 +12,9 @@ import MRoadtripTab from '../roadtrip/MRoadtripTab'
|
||||
import MJourneyDetail from '../../journey/MJourneyDetail'
|
||||
import PluginFrame from '../../../../components/Plugins/PluginFrame'
|
||||
|
||||
// The desktop's tour editor, map stage included: loaded the first time a large screen opens the tab.
|
||||
const MTourWorkspace = lazyWithRetry(() => import('../tours/MTourWorkspace'))
|
||||
|
||||
/**
|
||||
* Routes the active non-plan trip tab to its panel. `tab` is the legacy id the
|
||||
* desktop planner uses (transports · buchungen · finanzplan · listen · dateien ·
|
||||
@@ -62,6 +67,13 @@ export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProp
|
||||
// switch between them must not lose it.
|
||||
case 'roadtrip':
|
||||
return <MRoadtripTab planner={planner} shell={shell} tab={tab} />
|
||||
// Only ever reached on a large screen: the shell sends a phone back to the plan.
|
||||
case 'tour-planner':
|
||||
return (
|
||||
<LazyPanel id="m-tour-workspace">
|
||||
<MTourWorkspace planner={planner} />
|
||||
</LazyPanel>
|
||||
)
|
||||
// The journal the trip links: the Journey addon's own phone screen, moved
|
||||
// below the trip's floating top controls: its bars measure from --m-safe-top.
|
||||
case 'journal':
|
||||
|
||||
@@ -44,14 +44,16 @@ const PANE_INSETS = 'pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-s
|
||||
* columns, each keeping its own height, so the columns start level and no
|
||||
* short card ends above its neighbour in a ragged row or stretches into an
|
||||
* empty panel. In date order that reads down the first column, then the next.
|
||||
* The cards' own top margin gives way to an even gap under each card, so the
|
||||
* columns start on one line in every engine.
|
||||
* The cards' own top margin gives way to an even gap under each card. Each
|
||||
* card is an inline block, so that gap travels with it: as a plain block's
|
||||
* bottom margin it was carried over a column break by WebKit and pushed the
|
||||
* next column's first card down by the gap (seen on iPad).
|
||||
*/
|
||||
export function CardGrid({ children }: { children: ReactNode }) {
|
||||
// 380px a column: one column until the page has room for two cards that keep
|
||||
// their titles and time ranges whole (an 11" iPad upright stays at one).
|
||||
return (
|
||||
<div className="wide:columns-[380px] wide:gap-x-3 wide:pt-2 wide:[&>*]:mb-3 wide:[&>*]:mt-0 wide:[&>*]:break-inside-avoid">
|
||||
<div className="wide:columns-[380px] wide:gap-x-3 wide:pt-2 wide:[&>*]:mb-3 wide:[&>*]:mt-0 wide:[&>*]:inline-block wide:[&>*]:w-full wide:[&>*]:break-inside-avoid wide:[&>*]:align-top">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { List, Map as MapIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import TourGpxDropZone from '../../../../components/Tours/TourGpxDropZone';
|
||||
import TourWorkspaceRail from '../../../../components/Tours/TourWorkspaceRail';
|
||||
import { TourPlannerToursRail } from '../../../../components/Tours/planner/TourPlannerPanels';
|
||||
import { FloatingRailHeads } from '../../../../components/Tours/planner/railHead';
|
||||
import { useIsSplit } from '../../../../hooks/sizeClass';
|
||||
import { useTouchDragBridge } from '../../../../hooks/useTouchDragBridge';
|
||||
import { LazyPanel } from '../../../../pages/tripPlanner/LazyPanel';
|
||||
import { TourPlannerMapStage } from '../../../../pages/tripPlanner/TourPlannerMapStage';
|
||||
import { useTripPlannerPageOf } from '../../../../pages/tripPlanner/useTripPlannerPage';
|
||||
import MIconBtn from '../../../components/MIconBtn';
|
||||
import MSegmented from '../../../components/MSegmented';
|
||||
import type { MTripTabPanelProps } from '../MTripShell';
|
||||
import { useTabHeadSlot } from '../tabHeadSlot';
|
||||
|
||||
/** The pane's two halves: the trip's tours, or the editor with the tour open in it. */
|
||||
type MTourPane = 'tours' | 'editor';
|
||||
|
||||
/** The editor's title for what is open in it, as the desktop rail heads it. */
|
||||
const EDITOR_TITLE = {
|
||||
neutral: 'tours.planner.title',
|
||||
'new-draft': 'tours.planner.newTitle',
|
||||
'edit-saved': 'tours.planner.editTitle',
|
||||
'view-gpx': 'tours.planner.gpxTitle',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The tours tab on a large screen (wide and split; a phone has no such tab and
|
||||
* keeps its tours in the places browser). It is the desktop's tour editor, wired
|
||||
* over the shell's own planner through useTripPlannerPageOf, so the two cannot
|
||||
* drift apart: the same rails, the same map stage, the same GPX import.
|
||||
*
|
||||
* Split: the pane beside the map, with the trip's tours and the editor as its two
|
||||
* halves. Wide: one column, the pane or the map, switched at the pane's head and
|
||||
* from the map's corner. Opening a tour (from the list, the map or an import) turns
|
||||
* the pane to the editor, where it is then shown.
|
||||
*
|
||||
* The whole tab sits below the shell's floating top controls rather than under
|
||||
* them: that row spans the window and takes every press in its band, so a map
|
||||
* running beneath it would lose its search field to it.
|
||||
*/
|
||||
export default function MTourWorkspace({ planner }: Pick<MTripTabPanelProps, 'planner'>) {
|
||||
const page = useTripPlannerPageOf(planner);
|
||||
const { tourPlanner, wandererBrowser, tourGpxImport, tourMap, locationSearch, tourDetailActions } = page;
|
||||
const { canPlaceEdit, canDayEdit } = page.permissions;
|
||||
const split = useIsSplit();
|
||||
const [mapShown, setMapShown] = useState(false);
|
||||
// The waypoint list and the stage rail reorder by HTML5 drag, which a finger cannot start.
|
||||
useTouchDragBridge(true);
|
||||
|
||||
const mode = tourPlanner.mode.type;
|
||||
const editing = mode !== 'neutral' || wandererBrowser.open;
|
||||
// Something opened in the editor is shown there, on whichever half was up; once
|
||||
// it is closed (discarded, or back to the tours) the list comes back. Only on a
|
||||
// change: a tap on the editor half with nothing open stays where it went. Set
|
||||
// while rendering, as React has it for state that follows a prop, not in an effect.
|
||||
const [pane, setPane] = useState<MTourPane>(editing ? 'editor' : 'tours');
|
||||
const opened = `${editing}:${mode}`;
|
||||
const [seen, setSeen] = useState(opened);
|
||||
if (seen !== opened) {
|
||||
setSeen(opened);
|
||||
setPane(editing ? 'editor' : 'tours');
|
||||
}
|
||||
|
||||
const showPane = split || !mapShown;
|
||||
const showMap = split || mapShown;
|
||||
const { t } = planner;
|
||||
const headSlot = useTabHeadSlot();
|
||||
// In the shell's top row the switch also brings the pane back from the map.
|
||||
const pickPane = (next: MTourPane) => {
|
||||
setPane(next);
|
||||
setMapShown(false);
|
||||
};
|
||||
const head = (
|
||||
<>
|
||||
<MSegmented<MTourPane>
|
||||
className="min-w-0 flex-1"
|
||||
value={pane}
|
||||
onChange={pickPane}
|
||||
options={[
|
||||
{ value: 'tours', label: t('tours.planner.tripTours') },
|
||||
{ value: 'editor', label: t(EDITOR_TITLE[mode]) },
|
||||
]}
|
||||
/>
|
||||
{!split && (
|
||||
<MIconBtn
|
||||
ariaLabel={mapShown ? t('mobileTrip.listView') : t('mobileTrip.mapView')}
|
||||
onClick={() => setMapShown(!mapShown)}
|
||||
size={headSlot ? 38 : 36}
|
||||
className="pointer-events-auto backdrop-blur-[24px] backdrop-saturate-[1.7]"
|
||||
>
|
||||
{mapShown ? <List size={17} strokeWidth={2} /> : <MapIcon size={17} strokeWidth={2} />}
|
||||
</MIconBtn>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<TourGpxDropZone gpxImport={tourGpxImport} canEdit={canPlaceEdit} className="absolute inset-0">
|
||||
<div className="absolute inset-x-0 bottom-0 top-[calc(var(--m-safe-top,12px)+50px)] flex">
|
||||
{showPane && (
|
||||
<section
|
||||
aria-label={t('tours.planner.tripTours')}
|
||||
className="flex h-full min-w-0 flex-1 flex-col split:w-[var(--trip-pane-open)] split:flex-none split:border-e split:border-[color:var(--m-rowbr)]"
|
||||
>
|
||||
{!headSlot && <div className="flex items-center gap-2 px-4 pb-2 pt-1">{head}</div>}
|
||||
{/* Every rail head here is a rounded band like the cards under it. */}
|
||||
<FloatingRailHeads value>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden pb-[env(safe-area-inset-bottom,0px)]">
|
||||
{pane === 'tours' ? (
|
||||
<LazyPanel id="m-tour-tours">
|
||||
<TourPlannerToursRail
|
||||
planner={tourPlanner}
|
||||
canEdit={canPlaceEdit}
|
||||
canAssign={canDayEdit}
|
||||
tours={planner.tours}
|
||||
days={planner.days}
|
||||
loading={planner.toursLoading}
|
||||
onAssignToDay={planner.handleAssignToDay}
|
||||
onDeleteTour={planner.handleDeleteTour}
|
||||
gpxImport={tourGpxImport}
|
||||
onViewGpxTour={page.viewImportedTour}
|
||||
onTakenOver={tourDetailActions.onTakenOver}
|
||||
/>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
<LazyPanel id="m-tour-editor">
|
||||
<TourWorkspaceRail workspace={page} canEdit={canPlaceEdit} canAssign={canDayEdit} />
|
||||
</LazyPanel>
|
||||
)}
|
||||
</div>
|
||||
</FloatingRailHeads>
|
||||
</section>
|
||||
)}
|
||||
{showMap && (
|
||||
<div className="relative h-full min-w-0 flex-1 overflow-hidden">
|
||||
<TourPlannerMapStage
|
||||
tripId={planner.tripId}
|
||||
mapTileUrl={planner.mapTileUrl}
|
||||
leftHidden={false}
|
||||
rightHidden={false}
|
||||
leftPanelPx={0}
|
||||
rightPanelPx={0}
|
||||
t={t}
|
||||
tourPlanner={tourPlanner}
|
||||
tourMap={tourMap}
|
||||
locationSearch={locationSearch}
|
||||
canPlaceEdit={canPlaceEdit}
|
||||
assistantRoutes={planner.assistantMap}
|
||||
onViewportChange={wandererBrowser.onViewportChange}
|
||||
paddleChecklist={page.paddleChecklist}
|
||||
/>
|
||||
{!split && !headSlot && (
|
||||
<div className="absolute start-3 top-3 z-20">
|
||||
<MIconBtn
|
||||
ariaLabel={t('mobileTrip.listView')}
|
||||
onClick={() => setMapShown(false)}
|
||||
className="backdrop-blur-[24px] backdrop-saturate-[1.7]"
|
||||
>
|
||||
<List size={18} strokeWidth={2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{headSlot && createPortal(head, headSlot)}
|
||||
</TourGpxDropZone>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { opensSoon, refundedBeforeOpening } from './cloudOrders';
|
||||
|
||||
describe('opensSoon', () => {
|
||||
const base = { available: true, closed: true, sandbox: false, onPlay: false };
|
||||
|
||||
it('FE-NATIVE-ORDERS-001: closed orders hide the purchase from an App Store build', () => {
|
||||
expect(opensSoon(base)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-ORDERS-002: a TestFlight build may buy while orders are closed', () => {
|
||||
expect(opensSoon({ ...base, sandbox: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-ORDERS-003: an Android release build keeps the purchase while orders are closed', () => {
|
||||
expect(opensSoon({ ...base, onPlay: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-ORDERS-004: open orders and a binary without a store never say "opens soon"', () => {
|
||||
expect(opensSoon({ ...base, closed: false })).toBe(false);
|
||||
expect(opensSoon({ ...base, available: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refundedBeforeOpening', () => {
|
||||
it('FE-NATIVE-ORDERS-005: a purchase refused as ordering-closed on Android is the refunded one', () => {
|
||||
expect(refundedBeforeOpening('ordering-closed', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-ORDERS-006: other refusals, and ordering-closed on iOS, keep the general message', () => {
|
||||
expect(refundedBeforeOpening('ordering-closed', false)).toBe(false);
|
||||
expect(refundedBeforeOpening('redeem-failed', true)).toBe(false);
|
||||
expect(refundedBeforeOpening(undefined, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* What the app's plan screens do while TREK Cloud's orders are closed, apart
|
||||
* from the screens themselves so it can be tested.
|
||||
*
|
||||
* On iOS only a test build (TestFlight, App Review) may buy then: the App Store
|
||||
* charges it nothing and the cloud serves it. On Android the button shows in
|
||||
* every build, so the licence testers of the internal test can buy from a
|
||||
* release build; their purchases are Play test purchases, which the cloud
|
||||
* serves. A real purchase made before the opening is refused at the cloud with
|
||||
* `ordering-closed` and never acknowledged, so Google Play refunds it after
|
||||
* three days, and the app says so instead of showing a general error.
|
||||
*/
|
||||
|
||||
/** Whether the plans show without a way to buy: orders are closed and this build may not buy before the opening. */
|
||||
export function opensSoon(opts: { available: boolean; closed: boolean; sandbox: boolean; onPlay: boolean }): boolean {
|
||||
return opts.available && opts.closed && !opts.sandbox && !opts.onPlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cloud refused a purchase because it came before the opening, on
|
||||
* Android, where Google Play then refunds it: the app says so rather than
|
||||
* showing a general error. On iOS no real purchase gets this far.
|
||||
*/
|
||||
export function refundedBeforeOpening(code: string | undefined, onPlay: boolean): boolean {
|
||||
return onPlay && code === 'ordering-closed';
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user