mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-11 06:41:02 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8955790f17 | |||
| 61d925f3dd | |||
| bf00412863 | |||
| 2a6c771ad9 | |||
| 641b1882b5 | |||
| 5346182807 | |||
| 4dfccaa2a4 | |||
| 18a16d2aa2 | |||
| b7a91fc84e | |||
| bb725ac8d8 | |||
| b644235eb6 | |||
| d259ecb11c | |||
| 5d05363f36 | |||
| 6906f80ff8 | |||
| 7083667cbf | |||
| 0b7cee0b0d | |||
| 36ed0e0d23 | |||
| b1c0353548 | |||
| c204a25a9c | |||
| b008e010a5 |
@@ -60,6 +60,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard
|
||||
|
||||
/// The UI's shortcuts for the screen on show (TrekKeyCommands), in the app's
|
||||
/// menu: the overlay a held Command key brings up lists them there.
|
||||
override func buildMenu(with builder: UIMenuBuilder) {
|
||||
super.buildMenu(with: builder)
|
||||
guard builder.system == .main, !TrekKeyCommands.current.isEmpty else { return }
|
||||
let parent: UIMenu.Identifier = builder.menu(for: .application) != nil ? .application : .root
|
||||
builder.insertChild(TrekKeyCommands.menu(), atStartOfMenu: parent)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
|
||||
@@ -160,6 +160,8 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -50,6 +50,87 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
// the page follows the finger and an open sheet closes first.
|
||||
webView?.allowsBackForwardNavigationGestures = false
|
||||
}
|
||||
|
||||
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
|
||||
/// in the main menu, which sends their action up the responder chain from
|
||||
/// the web view, and this controller is the first stop that answers it.
|
||||
@objc func trekKeyCommand(_ sender: UIKeyCommand) {
|
||||
guard let id = sender.propertyList as? String else { return }
|
||||
TrekKeyCommands.onPress?(id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard (TrekShell.setKeyCommands)
|
||||
|
||||
/// The UI's keyboard shortcuts as key commands in the app's main menu. There
|
||||
/// they show in the overlay a held Command key brings up and, on iPadOS 26, in
|
||||
/// the menu bar, under the names the UI gives them in its language. Each screen
|
||||
/// replaces the list (mobile/keyboard/MKeyboardHost.tsx), and the menu is
|
||||
/// rebuilt with it, so only what works here and now is ever offered.
|
||||
enum TrekKeyCommands {
|
||||
struct Command {
|
||||
let id: String
|
||||
let title: String
|
||||
let input: String
|
||||
let modifiers: UIKeyModifierFlags
|
||||
|
||||
init?(raw: JSObject) {
|
||||
guard let id = raw["id"] as? String, let title = raw["title"] as? String,
|
||||
let input = raw["input"] as? String, !input.isEmpty else { return nil }
|
||||
var flags: UIKeyModifierFlags = []
|
||||
for name in raw["modifiers"] as? [String] ?? [] {
|
||||
switch name {
|
||||
case "command": flags.insert(.command)
|
||||
case "shift": flags.insert(.shift)
|
||||
case "option": flags.insert(.alternate)
|
||||
case "control": flags.insert(.control)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.input = Command.key(input)
|
||||
self.modifiers = flags
|
||||
}
|
||||
|
||||
private static func key(_ name: String) -> String {
|
||||
switch name {
|
||||
case "left": return UIKeyCommand.inputLeftArrow
|
||||
case "right": return UIKeyCommand.inputRightArrow
|
||||
case "up": return UIKeyCommand.inputUpArrow
|
||||
case "down": return UIKeyCommand.inputDownArrow
|
||||
case "escape": return UIKeyCommand.inputEscape
|
||||
default: return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static let menuIdentifier = UIMenu.Identifier("com.liketrek.trek.shortcuts")
|
||||
private(set) static var current: [Command] = []
|
||||
static var onPress: ((String) -> Void)?
|
||||
|
||||
static func set(_ commands: [Command]) {
|
||||
current = commands
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
|
||||
/// The commands as one menu, without a title of its own: it joins the app's
|
||||
/// menu, where iPadOS lists the shortcuts under the app's name.
|
||||
static func menu() -> UIMenu {
|
||||
let children = current.map { command -> UIKeyCommand in
|
||||
let key = UIKeyCommand(
|
||||
title: command.title,
|
||||
action: #selector(TrekBridgeViewController.trekKeyCommand(_:)),
|
||||
input: command.input,
|
||||
modifierFlags: command.modifiers,
|
||||
propertyList: command.id
|
||||
)
|
||||
// ⌘[ and ⌘, belong to the app here, not to the web view's editing.
|
||||
key.wantsPriorityOverSystemBehavior = true
|
||||
return key
|
||||
}
|
||||
return UIMenu(title: "", identifier: menuIdentifier, options: .displayInline, children: children)
|
||||
}
|
||||
}
|
||||
|
||||
/// The remembered server and the offline copy of its start page.
|
||||
|
||||
@@ -48,6 +48,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// What this binary offers the server's UI, raised with every native feature
|
||||
@@ -55,10 +56,19 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
|
||||
static let shellApi = 2
|
||||
/// The native features by name, so the UI can turn each on by itself.
|
||||
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never builds a snapshot for nothing.
|
||||
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never
|
||||
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
|
||||
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
|
||||
static var features: [String] {
|
||||
["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet"]
|
||||
+ (TrekWatchLink.isAvailable ? ["watch"] : [])
|
||||
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands"]
|
||||
if canAddWalletPasses { names.append("wallet") }
|
||||
if TrekWatchLink.isAvailable { names.append("watch") }
|
||||
return names
|
||||
}
|
||||
|
||||
/// Whether this device can add a pass to Apple Wallet (an iPhone; not an iPad).
|
||||
static var canAddWalletPasses: Bool {
|
||||
PKPassLibrary.isPassLibraryAvailable() && PKAddPassesViewController.canAddPasses()
|
||||
}
|
||||
|
||||
// MARK: - Shared booking documents
|
||||
@@ -72,6 +82,22 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
}
|
||||
TrekStore.watchUpdates()
|
||||
TrekWatchLink.shared.onAction = { [weak self] in self?.notifyListeners("watchAction", data: [:]) }
|
||||
TrekKeyCommands.onPress = { [weak self] id in
|
||||
self?.notifyListeners("keyCommand", data: ["id": id])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard
|
||||
|
||||
/// The UI's keyboard shortcuts for the screen it shows (mobile/keyboard),
|
||||
/// named in its language, as key commands in the app's menu.
|
||||
@objc func setKeyCommands(_ call: CAPPluginCall) {
|
||||
let raw = call.getArray("commands", JSObject.self) ?? []
|
||||
let commands = raw.compactMap { TrekKeyCommands.Command(raw: $0) }
|
||||
DispatchQueue.main.async {
|
||||
TrekKeyCommands.set(commands)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widgets and the travel-day Live Activity
|
||||
|
||||
@@ -6,16 +6,24 @@ import SwiftUI
|
||||
struct ShareView: View {
|
||||
@ObservedObject var model: ShareModel
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
@Environment(\.horizontalSizeClass) private var widthClass
|
||||
@State private var shown = false
|
||||
|
||||
/// An iPad window of regular width: the card stands centred over the dimmed
|
||||
/// app, as a form does there, instead of rising from the bottom edge. A
|
||||
/// narrow Split View or Slide Over window keeps the iPhone's bottom card.
|
||||
private var centred: Bool { widthClass == .regular }
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Color.black.opacity(shown ? 0.28 : 0)
|
||||
ZStack(alignment: centred ? .center : .bottom) {
|
||||
Color.black.opacity(shown ? (centred ? 0.36 : 0.28) : 0)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture { dismiss() }
|
||||
|
||||
card
|
||||
.offset(y: shown ? 0 : 520)
|
||||
.offset(y: shown ? 0 : (centred ? 40 : 520))
|
||||
.opacity(centred && !shown ? 0 : 1)
|
||||
.padding(.vertical, centred ? 24 : 0)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.86)) { shown = true }
|
||||
@@ -31,14 +39,17 @@ struct ShareView: View {
|
||||
|
||||
private var card: some View {
|
||||
VStack(spacing: 0) {
|
||||
Capsule()
|
||||
.fill(ShareStyle.faint(scheme).opacity(0.5))
|
||||
.frame(width: 38, height: 5)
|
||||
.padding(.top, 8)
|
||||
// The grabber belongs to a card that rises from the bottom edge.
|
||||
if !centred {
|
||||
Capsule()
|
||||
.fill(ShareStyle.faint(scheme).opacity(0.5))
|
||||
.frame(width: 38, height: 5)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
header
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.top, centred ? 22 : 14)
|
||||
|
||||
Group {
|
||||
switch model.phase {
|
||||
@@ -50,7 +61,7 @@ struct ShareView: View {
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
.padding(.bottom, centred ? 20 : 12)
|
||||
.frame(maxWidth: 560)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
||||
@@ -60,9 +71,9 @@ struct ShareView: View {
|
||||
.strokeBorder(ShareStyle.edge(scheme), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.25), radius: 30, y: 10)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.ignoresSafeArea(edges: centred ? [] : .bottom)
|
||||
)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.horizontal, centred ? 24 : 6)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
|
||||
@@ -9,6 +9,24 @@ import UIKit
|
||||
final class ShareViewController: UIViewController {
|
||||
private let model = ShareModel()
|
||||
|
||||
// On an iPad the system would put the extension in a form sheet, a box in the
|
||||
// middle of the screen with this sheet's own dimming and card inside it. Over
|
||||
// the full screen instead, the card stands centred over the app that shared,
|
||||
// the way an iPad presents a small form.
|
||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||
presentOverFullScreenOnPad()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
presentOverFullScreenOnPad()
|
||||
}
|
||||
|
||||
private func presentOverFullScreenOnPad() {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad { modalPresentationStyle = .overFullScreen }
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
|
||||
@@ -44,9 +44,16 @@ struct AtlasContent: View {
|
||||
Text("\(Image(systemName: "globe.europe.africa.fill")) \(atlas?.count ?? 0) \(labels?.countries ?? "")")
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
|
||||
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
|
||||
// The word goes under the number where both do not fit on one line (an iPad mini).
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
|
||||
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: -2) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
|
||||
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
|
||||
}
|
||||
}
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
|
||||
Spacer(minLength: 6)
|
||||
@@ -71,18 +78,15 @@ struct AtlasContent: View {
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
|
||||
if let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
|
||||
// The trips and days step aside before the share of the world is cut (an iPad mini).
|
||||
ViewThatFits(in: .horizontal) {
|
||||
largeHeader(atlas, labels, stats: true)
|
||||
largeHeader(atlas, labels, stats: false)
|
||||
}
|
||||
// The map takes what the rows leave, so a smaller large widget (an
|
||||
// iPad mini's) keeps the continents and the latest countries whole.
|
||||
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
|
||||
.frame(height: 150)
|
||||
.frame(minHeight: 60, maxHeight: 150)
|
||||
ContinentBars(continents: atlas?.continents ?? [], scheme: scheme)
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array((atlas?.recent ?? []).prefix(3).enumerated()), id: \.offset) { index, country in
|
||||
@@ -94,6 +98,19 @@ struct AtlasContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func largeHeader(_ atlas: WidgetSnapshot.Atlas?, _ labels: WidgetSnapshot.Labels?, stats: Bool) -> some View {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if stats, let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
|
||||
if stats, let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
|
||||
}
|
||||
}
|
||||
|
||||
private var ink: Color { Trek.ink(scheme) }
|
||||
private var muted: Color { Trek.muted(scheme) }
|
||||
private var glow: [Color] { [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)] }
|
||||
|
||||
@@ -29,6 +29,7 @@ struct BookingsContent: View {
|
||||
switch family {
|
||||
case .small: hero(first, labels)
|
||||
case .medium: list(Array(bookings.prefix(2)), labels, featureFirst: true)
|
||||
case .extraLarge: wide(bookings, labels)
|
||||
default: list(Array(bookings.prefix(5)), labels, featureFirst: true)
|
||||
}
|
||||
} else {
|
||||
@@ -74,7 +75,8 @@ struct BookingsContent: View {
|
||||
// MARK: Medium and large: a list, the first one featured
|
||||
|
||||
private func list(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels, featureFirst: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: family == .large ? 8 : 7) {
|
||||
// The extra large's list stands beside a card as tall as the widget: its rows space out to match.
|
||||
VStack(alignment: .leading, spacing: family == .extraLarge ? 14 : family.isTall ? 8 : 7) {
|
||||
HStack {
|
||||
Text(labels.bookings ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
@@ -89,7 +91,33 @@ struct BookingsContent: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if family == .large { Spacer(minLength: 0) }
|
||||
if family.isTall { Spacer(minLength: 0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Extra large (iPad): the next one as a card, the rest beside it
|
||||
|
||||
private func wide(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
let first = bookings[0]
|
||||
let look = Trek.booking(first.kind)
|
||||
let rest = Array(bookings.dropFirst().prefix(5))
|
||||
return HStack(alignment: .top, spacing: 18) {
|
||||
RowLink(path: first.path) {
|
||||
hero(first, labels)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(LinearGradient(colors: [look.color.opacity(scheme == .dark ? 0.24 : 0.14), look.color.opacity(scheme == .dark ? 0.08 : 0.04)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(look.color.opacity(0.25), lineWidth: 0.8))
|
||||
}
|
||||
.frame(maxWidth: rest.isEmpty ? CGFloat.infinity : 240)
|
||||
if !rest.isEmpty {
|
||||
list(rest, labels, featureFirst: false)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ extension TrekFamily {
|
||||
switch family {
|
||||
case .systemSmall: self = .small
|
||||
case .systemMedium: self = .medium
|
||||
case .systemExtraLarge: self = .extraLarge
|
||||
case .accessoryCircular: self = .circular
|
||||
case .accessoryRectangular: self = .rectangular
|
||||
case .accessoryInline: self = .inline
|
||||
@@ -92,7 +93,8 @@ struct NextTripWidget: Widget {
|
||||
}
|
||||
.configurationDisplayName("widget.trip.name")
|
||||
.description("widget.trip.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
// systemExtraLarge is the iPad's; an iPhone leaves it out of its gallery.
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +125,7 @@ struct TodayWidget: Widget {
|
||||
}
|
||||
.configurationDisplayName("widget.today.name")
|
||||
.description("widget.today.description")
|
||||
.supportedFamilies([.systemMedium, .systemLarge])
|
||||
.supportedFamilies([.systemMedium, .systemLarge, .systemExtraLarge])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +148,7 @@ struct BookingsWidget: Widget {
|
||||
}
|
||||
.configurationDisplayName("widget.bookings.name")
|
||||
.description("widget.bookings.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryInline])
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,55 +14,49 @@ struct TodayContent: View {
|
||||
let total = today.total ?? snapshot.tripLength ?? 1
|
||||
let timed = today.stops.filter { $0.minutes != nil }
|
||||
let done = timed.filter { ($0.minutes ?? 0) < nowMinutes }.count
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
DayBadge(index: today.index ?? 1, total: total)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(trip.title).trekEyebrow(scheme)
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: family == .large ? 19 : 16, weight: .heavy))
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
.lineLimit(1)
|
||||
if family == .extraLarge {
|
||||
wide(snapshot, trip, today, total: total, done: done, timed: timed.count, nowMinutes: nowMinutes)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// On a narrow widget (an iPad mini's) the weather shrinks to its
|
||||
// chip, then steps aside, before the trip's name is cut short.
|
||||
ViewThatFits(in: .horizontal) {
|
||||
header(trip, today, total: total, weather: family == .large ? .full : .chip)
|
||||
header(trip, today, total: total, weather: .chip)
|
||||
header(trip, today, total: total, weather: .hidden)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
if family == .large {
|
||||
PaperWeather(weather: weather)
|
||||
} else {
|
||||
WeatherChip(weather: weather)
|
||||
if family == .large {
|
||||
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 14)
|
||||
} else {
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
StopList(
|
||||
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
|
||||
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
|
||||
)
|
||||
if family == .large {
|
||||
Spacer(minLength: 8)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if family == .large {
|
||||
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 14)
|
||||
} else {
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
StopList(
|
||||
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
|
||||
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
|
||||
)
|
||||
if family == .large {
|
||||
Spacer(minLength: 8)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let snapshot, let trip = snapshot.trip {
|
||||
// Before the trip there is no day yet: count down to it instead.
|
||||
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
|
||||
let roomy = family == .extraLarge
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrow(scheme)
|
||||
Text(trip.title).font(.system(size: 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
|
||||
Text(trip.title).font(.system(size: roomy ? 24 : 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(days)").font(Trek.number(40, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
Text(snapshot.labels.days ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
Text("\(days)").font(Trek.number(roomy ? 56 : 40, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
Text(snapshot.labels.days ?? "").font(.system(size: roomy ? 16 : 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: 24)
|
||||
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: roomy ? 34 : 24)
|
||||
}
|
||||
} else {
|
||||
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
|
||||
@@ -70,6 +64,66 @@ struct TodayContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension TodayContent {
|
||||
enum HeaderWeather { case full, chip, hidden }
|
||||
|
||||
/// The day's badge, the trip and the day, and the weather as much as fits.
|
||||
func header(_ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today, total: Int, weather: HeaderWeather) -> some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
DayBadge(index: today.index ?? 1, total: total)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(trip.title).trekEyebrow(scheme)
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: family == .medium ? 16 : 19, weight: .heavy))
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let forecast = snapshot?.weather {
|
||||
switch weather {
|
||||
case .full: PaperWeather(weather: forecast)
|
||||
case .chip: WeatherChip(weather: forecast)
|
||||
case .hidden: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The iPad's extra large: the day, its progress and the weather beside
|
||||
/// the day's timeline, and the next two bookings across the foot.
|
||||
func wide(
|
||||
_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today,
|
||||
total: Int, done: Int, timed: Int, nowMinutes: Int
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 22) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header(trip, today, total: total, weather: .hidden)
|
||||
DayProgress(done: done, total: timed, scheme: scheme, label: snapshot.labels.allDone)
|
||||
.padding(.top, 12)
|
||||
if let weather = snapshot.weather {
|
||||
PaperWeather(weather: weather).padding(.top, 16)
|
||||
}
|
||||
}
|
||||
.frame(width: 250)
|
||||
StopList(
|
||||
stops: today.stops, nowMinutes: nowMinutes, limit: 6,
|
||||
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
Spacer(minLength: 10)
|
||||
if let bookings = snapshot.bookings, !bookings.isEmpty {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Array(bookings.prefix(2).enumerated()), id: \.offset) { _, booking in
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's number in a dark tile with the trip's length under it.
|
||||
struct DayBadge: View {
|
||||
let index: Int
|
||||
@@ -142,3 +196,64 @@ struct DayProgress: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The travel day as its Live Activity shows it on the lock screen (iPhone and
|
||||
/// iPad): trip and day on top, the next stop large, the day's stops as
|
||||
/// segments. Plain values rather than the activity's attributes, so the
|
||||
/// preview renderer can draw it at every width the lock screen gives it.
|
||||
struct TripDayBanner: View {
|
||||
let tripTitle: String
|
||||
let dayLabel: String
|
||||
let nextName: String
|
||||
let nextTime: String?
|
||||
let done: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
|
||||
Circle()
|
||||
.trim(from: 0, to: total > 0 ? max(0.02, Double(done) / Double(total)) : 0)
|
||||
.stroke(
|
||||
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
|
||||
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(tripTitle)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.tracking(0.5)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1).minimumScaleFactor(0.8)
|
||||
Spacer(minLength: 4)
|
||||
if let nextTime {
|
||||
Text(nextTime).font(Trek.number(18, .heavy)).foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
DayStopProgress(done: done, total: total).padding(.top, 3)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's timed stops as segments, those behind filled (the Live Activity's bar).
|
||||
struct DayStopProgress: View {
|
||||
let done: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,22 @@ import Foundation
|
||||
/// so the views also render outside a widget (the preview renderer in
|
||||
/// client/ios/WidgetPreviews draws every size to a PNG on the CI's Mac).
|
||||
enum TrekFamily: String, CaseIterable {
|
||||
case small, medium, large, circular, rectangular, inline
|
||||
case small, medium, large, extraLarge, circular, rectangular, inline
|
||||
|
||||
var isAccessory: Bool { self == .circular || self == .rectangular || self == .inline }
|
||||
|
||||
/// The point size of the family on a 6.1" iPhone, for the renderer.
|
||||
/// A home-screen widget as tall as a large one: the large and the iPad's
|
||||
/// extra large, which have room for a list with a foot under it.
|
||||
var isTall: Bool { self == .large || self == .extraLarge }
|
||||
|
||||
/// The point size of the family on a 6.1" iPhone, for the renderer. The
|
||||
/// extra large exists on an iPad only; its size is that of an 11" iPad.
|
||||
var previewSize: CGSize {
|
||||
switch self {
|
||||
case .small: return CGSize(width: 170, height: 170)
|
||||
case .medium: return CGSize(width: 364, height: 170)
|
||||
case .large: return CGSize(width: 364, height: 382)
|
||||
case .extraLarge: return CGSize(width: 715.5, height: 342)
|
||||
case .circular: return CGSize(width: 76, height: 76)
|
||||
case .rectangular: return CGSize(width: 172, height: 76)
|
||||
case .inline: return CGSize(width: 250, height: 26)
|
||||
@@ -21,6 +27,34 @@ enum TrekFamily: String, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The iPads the renderer draws the home-screen widgets for, with the sizes
|
||||
/// iPadOS gives each family there (Human Interface Guidelines, Widgets). An
|
||||
/// iPad mini's small widget is smaller than an iPhone's, a 13" iPad's medium
|
||||
/// and large are wider.
|
||||
enum TrekPad: String, CaseIterable {
|
||||
case ipad13, ipad11, ipadMini
|
||||
|
||||
/// The small widget's side, the medium's and large's width, the extra large's width.
|
||||
private var metrics: (small: CGFloat, wide: CGFloat, extraWide: CGFloat) {
|
||||
switch self {
|
||||
case .ipad13: return (170, 378.5, 795)
|
||||
case .ipad11: return (155, 342, 715.5)
|
||||
case .ipadMini: return (141, 305.5, 634.5)
|
||||
}
|
||||
}
|
||||
|
||||
func size(_ family: TrekFamily) -> CGSize {
|
||||
let m = metrics
|
||||
switch family {
|
||||
case .small: return CGSize(width: m.small, height: m.small)
|
||||
case .medium: return CGSize(width: m.wide, height: m.small)
|
||||
case .large: return CGSize(width: m.wide, height: m.wide)
|
||||
case .extraLarge: return CGSize(width: m.extraWide, height: m.wide)
|
||||
default: return family.previewSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WidgetSnapshot {
|
||||
/// What the widget gallery shows before the app ever wrote a snapshot, and
|
||||
/// what the renderer draws: a trip to Kyoto with a full day, bookings and to-dos.
|
||||
|
||||
@@ -85,6 +85,53 @@ struct TripContent: View {
|
||||
}
|
||||
.frame(width: 96, height: 96)
|
||||
}
|
||||
case .extraLarge:
|
||||
// The iPad's wide widget: the trip and its countdown on the left, the
|
||||
// bookings ahead in a column on the right.
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true) }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(trip.title)
|
||||
.font(.system(size: 28, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
if let place = trip.place {
|
||||
Label(place, systemImage: "location.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
countdown(days, snapshot.labels, size: 54)
|
||||
}
|
||||
RouteArc(progress: progress, color: .white)
|
||||
.frame(height: 34)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
TripFacts(snapshot: snapshot, onPhoto: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
if let bookings = snapshot.bookings, !bookings.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(snapshot.labels.bookings ?? "").trekEyebrowOnPhoto()
|
||||
ForEach(Array(bookings.prefix(4).enumerated()), id: \.offset) { _, booking in
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(width: 290)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
@@ -154,10 +201,17 @@ struct TripContent: View {
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(today.dayLabel).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
// An iPad mini's small widget has no room for both: the day stays, the weather gives way.
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .top) {
|
||||
Text(today.dayLabel).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
}
|
||||
HStack(alignment: .top) {
|
||||
Text(today.dayLabel).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 7)
|
||||
Spacer(minLength: 0)
|
||||
@@ -175,31 +229,87 @@ struct TripContent: View {
|
||||
Text(trip.title).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)).lineLimit(1).padding(.top, 2)
|
||||
}
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: 21, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.top, 3)
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
HStack(spacing: 6) {
|
||||
WeatherChip(weather: weather, onPhoto: true)
|
||||
if let high = weather.high, let low = weather.low {
|
||||
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
|
||||
// 176 pt for the stops on an iPhone and an 11" or 13" iPad; an iPad
|
||||
// mini's medium widget is narrower, so the panel gives up some width
|
||||
// and the weather keeps to its chip, and the day still reads whole.
|
||||
// The line is drawn by the iPad mini alone: its medium widget is
|
||||
// 305.5 pt wide, 273.5 inside the content margins, while the smallest
|
||||
// iPhone's (a 4.7" SE) is 321 pt, 289 inside, and keeps the full layout.
|
||||
GeometryReader { geo in
|
||||
let narrow = geo.size.width < 281
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: 21, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.top, 3)
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
HStack(spacing: 6) {
|
||||
WeatherChip(weather: weather, onPhoto: true, compact: narrow)
|
||||
if !narrow, let high = weather.high, let low = weather.low {
|
||||
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
}
|
||||
.frame(width: narrow ? 150 : 176)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
case .extraLarge:
|
||||
// The iPad's wide widget: the day and the next stop on the left, the
|
||||
// day's stops and the next booking on the right, each column
|
||||
// anchored at its top and its foot.
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: 26, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { BigWeather(weather: weather) }
|
||||
}
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 10)
|
||||
Spacer(minLength: 8)
|
||||
if let next {
|
||||
Text(next.time ?? snapshot.labels.today)
|
||||
.font(Trek.number(34, .heavy))
|
||||
.foregroundStyle(.white)
|
||||
Text(next.name)
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
} else {
|
||||
Text(snapshot.labels.nothingPlanned).font(.system(size: 17, weight: .bold)).foregroundStyle(.white).lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(width: 176)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 6, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
|
||||
}
|
||||
}
|
||||
.frame(width: 300)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
@@ -7,7 +7,11 @@ import WidgetKit
|
||||
struct TripDayLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: TripDayAttributes.self) { context in
|
||||
TripDayBanner(attributes: context.attributes, state: context.state)
|
||||
TripDayBanner(
|
||||
tripTitle: context.attributes.tripTitle, dayLabel: context.state.dayLabel,
|
||||
nextName: context.state.nextName, nextTime: context.state.nextTime,
|
||||
done: context.state.done, total: context.state.total
|
||||
)
|
||||
.activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
.widgetURL(WidgetSnapshot.link(context.attributes.path))
|
||||
@@ -31,7 +35,7 @@ struct TripDayLiveActivity: Widget {
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1)
|
||||
StopProgress(done: state.done, total: state.total)
|
||||
DayStopProgress(done: state.done, total: state.total)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
@@ -55,58 +59,3 @@ struct TripDayLiveActivity: Widget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock-screen banner: trip and day on top, the next stop large, the
|
||||
/// day's stops as segments.
|
||||
private struct TripDayBanner: View {
|
||||
let attributes: TripDayAttributes
|
||||
let state: TripDayAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
|
||||
Circle()
|
||||
.trim(from: 0, to: state.total > 0 ? max(0.02, Double(state.done) / Double(state.total)) : 0)
|
||||
.stroke(
|
||||
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
|
||||
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(attributes.tripTitle)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.tracking(0.5)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(state.dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(state.nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if let time = state.nextTime {
|
||||
Text(time).font(Trek.number(18, .heavy)).foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
StopProgress(done: state.done, total: state.total).padding(.top, 3)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's timed stops as segments, those behind filled.
|
||||
private struct StopProgress: View {
|
||||
let done: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,23 @@ struct Render {
|
||||
}
|
||||
let lock = LockScreen(snapshots: states.prefix(3).map(\.1), now: now).environment(\.colorScheme, scheme)
|
||||
try write(lock, to: out.appendingPathComponent("lock-\(schemeName).png"))
|
||||
|
||||
// The iPad: every home-screen size of each widget as that iPad gives
|
||||
// them, the extra large included, per kind in one strip.
|
||||
for pad in TrekPad.allCases {
|
||||
for (stateName, snapshot) in states where ["upcoming", "coverrunning", "empty"].contains(stateName) {
|
||||
for kind in WidgetKind.allCases {
|
||||
let strip = PadStrip(kind: kind, pad: pad, snapshot: snapshot, now: now)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.environment(\.trekStaticPreview, true)
|
||||
try write(strip, to: out.appendingPathComponent("pad-\(pad.rawValue)-\(kind.rawValue)-\(stateName)-\(schemeName).png"), scale: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
let padLock = PadLockScreen(snapshot: states[1].1, now: now).environment(\.colorScheme, scheme)
|
||||
try write(padLock, to: out.appendingPathComponent("pad-lock-\(schemeName).png"), scale: 2)
|
||||
}
|
||||
try write(LiveActivityWidths(), to: out.appendingPathComponent("liveactivity-widths.png"))
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
try Mockups.render(to: out, iconPath: args.count > 1 ? args[1] : nil, now: now)
|
||||
print("wrote \(try FileManager.default.contentsOfDirectory(atPath: out.path).count) images to \(out.path)")
|
||||
@@ -77,9 +93,9 @@ struct Render {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func write<V: View>(_ view: V, to url: URL) throws {
|
||||
static func write<V: View>(_ view: V, to url: URL, scale: CGFloat = 3) throws {
|
||||
let renderer = ImageRenderer(content: view)
|
||||
renderer.scale = 3
|
||||
renderer.scale = scale
|
||||
guard let image = renderer.nsImage,
|
||||
let tiff = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiff),
|
||||
@@ -95,9 +111,9 @@ enum WidgetKind: String, CaseIterable {
|
||||
|
||||
var families: [TrekFamily] {
|
||||
switch self {
|
||||
case .trip: return [.small, .medium, .large, .circular, .rectangular, .inline]
|
||||
case .today: return [.medium, .large]
|
||||
case .bookings: return [.small, .medium, .large, .rectangular, .inline]
|
||||
case .trip: return [.small, .medium, .large, .extraLarge, .circular, .rectangular, .inline]
|
||||
case .today: return [.medium, .large, .extraLarge]
|
||||
case .bookings: return [.small, .medium, .large, .extraLarge, .rectangular, .inline]
|
||||
case .todos: return [.small, .medium, .large, .circular, .rectangular]
|
||||
case .packing: return [.small, .medium, .large, .circular, .rectangular]
|
||||
case .atlas: return [.small, .medium, .large, .circular, .rectangular, .inline]
|
||||
@@ -113,9 +129,11 @@ struct WidgetFrame: View {
|
||||
let family: TrekFamily
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
/// The device's size for the family, when it is not the 6.1" iPhone's.
|
||||
var size: CGSize? = nil
|
||||
|
||||
var body: some View {
|
||||
let size = family.previewSize
|
||||
let size = self.size ?? family.previewSize
|
||||
if family.isAccessory {
|
||||
// As the lock screen draws them: white on the wallpaper, no box of their own
|
||||
// (the circular one gets the system's soft disc).
|
||||
@@ -270,3 +288,98 @@ struct NewWidgets: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One widget kind in every home-screen size an iPad gives it, side by side on
|
||||
/// that iPad's wallpaper, the extra large last.
|
||||
struct PadStrip: View {
|
||||
let kind: WidgetKind
|
||||
let pad: TrekPad
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
ForEach(kind.families.filter { !$0.isAccessory }, id: \.self) { family in
|
||||
WidgetFrame(kind: kind, family: family, snapshot: snapshot, now: now, size: pad.size(family))
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
|
||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// An 11" iPad's lock screen on its side: the clock with the accessories under
|
||||
/// it on the left, as iPadOS places them, and the travel day's Live Activity.
|
||||
struct PadLockScreen: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
WidgetFrame(kind: .trip, family: .inline, snapshot: snapshot, now: now)
|
||||
Text(now, format: .dateTime.hour().minute())
|
||||
.font(.system(size: 96, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
HStack(spacing: 10) {
|
||||
WidgetFrame(kind: .trip, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .packing, family: .circular, snapshot: snapshot, now: now)
|
||||
}
|
||||
WidgetFrame(kind: .trip, family: .rectangular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .rectangular, snapshot: snapshot, now: now)
|
||||
}
|
||||
.padding(.leading, 64)
|
||||
.padding(.top, 70)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(width: 1194, height: 834, alignment: .topLeading)
|
||||
.overlay(alignment: .bottom) {
|
||||
LiveActivityCard(width: 500).padding(.bottom, 40)
|
||||
}
|
||||
.background(
|
||||
LinearGradient(colors: [Color(hex: 0x2B3A67), Color(hex: 0x6B4A6E), Color(hex: 0x1C1726)], startPoint: .top, endPoint: .bottom)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The travel day's Live Activity as the lock screen tints it.
|
||||
struct LiveActivityCard: View {
|
||||
let width: CGFloat
|
||||
|
||||
var body: some View {
|
||||
TripDayBanner(
|
||||
tripTitle: "Autumn in Kyoto", dayLabel: "Day 3 of 9",
|
||||
nextName: "Kiyomizu-dera", nextTime: "13:30", done: 2, total: 5
|
||||
)
|
||||
.frame(width: width)
|
||||
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The Live Activity at the widths an iPhone's and an iPad's lock screen give
|
||||
/// it, and with a long German day and stop, to see it hold at each.
|
||||
struct LiveActivityWidths: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
ForEach([364, 500, 640] as [CGFloat], id: \.self) { width in
|
||||
LiveActivityCard(width: width)
|
||||
}
|
||||
TripDayBanner(
|
||||
tripTitle: "Herbst in Kyoto und Nara", dayLabel: "Tag 3 von 9",
|
||||
nextName: "Kiyomizu-dera und Sannenzaka-Spaziergang", nextTime: "13:30", done: 2, total: 5
|
||||
)
|
||||
.frame(width: 364)
|
||||
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
|
||||
}
|
||||
.padding(28)
|
||||
.background(Color(hex: 0x1E2A3A))
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n'
|
||||
import { SUPPORTED_LANGUAGE_CODES, isRtlLanguage } from '@trek/shared/i18n'
|
||||
import type { TranslationStrings } from '@trek/shared/i18n'
|
||||
|
||||
// Only the native strings of every locale, not the whole UI: this page ships
|
||||
@@ -32,3 +32,8 @@ export function translator(code: string) {
|
||||
: key
|
||||
}
|
||||
}
|
||||
|
||||
/** Arabic and Hebrew run right to left. */
|
||||
export function directionOf(code: string): 'rtl' | 'ltr' {
|
||||
return isRtlLanguage(code) ? 'rtl' : 'ltr'
|
||||
}
|
||||
|
||||
+99
-52
@@ -9,7 +9,7 @@ import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src
|
||||
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';
|
||||
import { TrekShell } from '../src/native/trekShell';
|
||||
import { pickLocale, translator } from './i18n';
|
||||
import { directionOf, pickLocale, translator } from './i18n';
|
||||
import './shell.css';
|
||||
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ const locale = pickLocale(navigator.languages?.length ? navigator.languages : [n
|
||||
const onPlay = Capacitor.getPlatform() === 'android';
|
||||
const t = translator(locale);
|
||||
document.documentElement.lang = locale;
|
||||
document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr';
|
||||
document.documentElement.dir = directionOf(locale);
|
||||
|
||||
const root = document.getElementById('shell')!;
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
@@ -128,6 +128,11 @@ function show(screen: HTMLElement, opts: { back?: boolean } = {}): void {
|
||||
if (opts.back) screen.classList.add('screen-back');
|
||||
const previous = current;
|
||||
if (previous) {
|
||||
// The leaving screen is lifted out of the flow where it stands, so it fades
|
||||
// from its own place rather than jumping to the top of a centred layout.
|
||||
previous.style.top = `${previous.offsetTop}px`;
|
||||
previous.style.left = `${previous.offsetLeft}px`;
|
||||
previous.style.width = `${previous.offsetWidth}px`;
|
||||
previous.classList.remove('screen-enter');
|
||||
previous.classList.add('screen-leave');
|
||||
if (opts.back) previous.classList.add('screen-back');
|
||||
@@ -144,6 +149,15 @@ function externalLink(text: string, href: string): HTMLAnchorElement {
|
||||
function footLinks(...extra: Node[]): HTMLElement {
|
||||
return el('div', { className: 'foot-links' }, ...extra, externalLink(t('native.privacy'), PRIVACY_URL));
|
||||
}
|
||||
/**
|
||||
* A screen's two halves: what it is (brand, title, explanation) and what to do
|
||||
* on it (the choices, the form). Stacked in one column they read as before; on
|
||||
* a phone or a foldable on its side the screen is too short for one column, so
|
||||
* the halves stand side by side there (shell.css, `.col`).
|
||||
*/
|
||||
function columns(about: Node[], act: Node[]): HTMLElement[] {
|
||||
return [el('div', { className: 'col col-about' }, ...about), el('div', { className: 'col col-act' }, ...act)];
|
||||
}
|
||||
function topbar(onBack: () => void): HTMLElement {
|
||||
const back = el('button', { type: 'button', className: 'back' }, icon('M15 18l-6-6 6-6', 18), t('native.back'));
|
||||
back.addEventListener('click', () => {
|
||||
@@ -202,13 +216,14 @@ function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
hero,
|
||||
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
|
||||
el('p', { className: 'sub' }, t('native.welcome.sub')),
|
||||
cloud,
|
||||
connect,
|
||||
demo,
|
||||
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
|
||||
...columns(
|
||||
[
|
||||
hero,
|
||||
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
|
||||
el('p', { className: 'sub' }, t('native.welcome.sub')),
|
||||
],
|
||||
[cloud, connect, demo, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -225,6 +240,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
});
|
||||
input.setAttribute('inputmode', 'url');
|
||||
input.setAttribute('autocomplete', 'url');
|
||||
// An address reads left to right in every language, Arabic too.
|
||||
input.dir = 'ltr';
|
||||
// The on-screen keyboard's return key submits the address; it reads "Go", not a newline.
|
||||
input.setAttribute('enterkeyhint', 'go');
|
||||
input.setAttribute('aria-label', t('native.connect.title'));
|
||||
@@ -266,11 +283,14 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.connect.title')),
|
||||
el('p', { className: 'sub' }, t('native.connect.hint')),
|
||||
form,
|
||||
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.connect.title')),
|
||||
el('p', { className: 'sub' }, t('native.connect.hint')),
|
||||
],
|
||||
[form, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -288,7 +308,13 @@ function feature(path: string, text: string): HTMLElement {
|
||||
}
|
||||
|
||||
function showCloud(opts: { back?: boolean } = {}): void {
|
||||
const cta = el('button', { type: 'button', className: 'btn' }, t('native.cloud.cta'), icon(I.chevron, 20, 'chev'));
|
||||
// The label centred, the chevron at the far edge: it points on, not at the word.
|
||||
const cta = el(
|
||||
'button',
|
||||
{ type: 'button', className: 'btn btn-go' },
|
||||
el('span', { className: 'btn-label' }, t('native.cloud.cta')),
|
||||
icon(I.chevron, 20, 'chev')
|
||||
);
|
||||
cta.addEventListener('click', () => {
|
||||
void tap();
|
||||
showPlans();
|
||||
@@ -304,25 +330,32 @@ function showCloud(opts: { back?: boolean } = {}): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el(
|
||||
'div',
|
||||
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title') },
|
||||
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
|
||||
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
|
||||
el('span', { className: 'cloud-tag' }, 'cloud')
|
||||
),
|
||||
el('p', { className: 'sub' }, t('native.cloud.sub')),
|
||||
el(
|
||||
'div',
|
||||
{ className: 'features' },
|
||||
feature(I.globe, t('native.cloud.f1')),
|
||||
feature(I.bolt, t('native.cloud.f2')),
|
||||
feature(I.shield, t('native.cloud.f3'))
|
||||
),
|
||||
cta,
|
||||
have,
|
||||
footLinks()
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
// The lockup is a logo: "trek" then "cloud" in every language, never mirrored.
|
||||
el(
|
||||
'div',
|
||||
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title'), dir: 'ltr' },
|
||||
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
|
||||
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
|
||||
el('span', { className: 'cloud-tag' }, 'cloud')
|
||||
),
|
||||
el('p', { className: 'sub' }, t('native.cloud.sub')),
|
||||
],
|
||||
[
|
||||
el(
|
||||
'div',
|
||||
{ className: 'features' },
|
||||
feature(I.globe, t('native.cloud.f1')),
|
||||
feature(I.bolt, t('native.cloud.f2')),
|
||||
feature(I.shield, t('native.cloud.f3'))
|
||||
),
|
||||
cta,
|
||||
have,
|
||||
footLinks(),
|
||||
]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -427,7 +460,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HT
|
||||
{ className: 'plan-top' },
|
||||
el('span', { className: 'plan-name' }, plan.name),
|
||||
// No price yet (orders not open): the plan shows without one rather than with an empty one.
|
||||
...(label ? [el('span', { className: 'plan-price' }, label, el('small', {}, per))] : [])
|
||||
// The price is figures and a currency sign: one left-to-right run, so Arabic
|
||||
// keeps "5,99 €" whole, with the period standing apart beside it.
|
||||
...(label ? [el('span', { className: 'plan-price' }, el('bdi', { dir: 'ltr' }, label), el('small', {}, per))] : [])
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
@@ -578,12 +613,15 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showCloud({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.plans.title')),
|
||||
el('p', { className: 'sub' }, t('native.plans.sub')),
|
||||
seg,
|
||||
content,
|
||||
footer
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showCloud({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.plans.title')),
|
||||
el('p', { className: 'sub' }, t('native.plans.sub')),
|
||||
seg,
|
||||
],
|
||||
[content, footer]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -661,9 +699,10 @@ function showClaim(
|
||||
slugInput.setAttribute('autocomplete', 'off');
|
||||
slugInput.setAttribute('maxlength', '40');
|
||||
slugInput.setAttribute('aria-label', t('native.claim.addressLabel'));
|
||||
// "name.liketrek.com" is one address, left to right in every language.
|
||||
const slugField = el(
|
||||
'div',
|
||||
{ className: 'field slug-field' },
|
||||
{ className: 'field slug-field', dir: 'ltr' },
|
||||
slugInput,
|
||||
el('span', { className: 'slug-suffix' }, cloudSuffix())
|
||||
);
|
||||
@@ -680,6 +719,8 @@ function showClaim(
|
||||
emailInput.setAttribute('autocomplete', 'email');
|
||||
emailInput.setAttribute('enterkeyhint', 'go');
|
||||
emailInput.setAttribute('aria-label', t('native.claim.emailLabel'));
|
||||
emailInput.dir = 'ltr';
|
||||
emailInput.classList.add('ltr-value');
|
||||
const emailField = el('div', { className: 'field' }, emailInput);
|
||||
|
||||
const submit = el('button', { type: 'submit', className: 'btn' }, t('native.claim.subscribe', { price }));
|
||||
@@ -805,10 +846,14 @@ function showClaim(
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showPlans({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.claim.title')),
|
||||
el('p', { className: 'sub' }, t('native.claim.sub')),
|
||||
form
|
||||
...columns(
|
||||
[
|
||||
topbar(() => showPlans({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.claim.title')),
|
||||
el('p', { className: 'sub' }, t('native.claim.sub')),
|
||||
],
|
||||
[form]
|
||||
)
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -933,12 +978,14 @@ function showUnreachable(server: string): void {
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
|
||||
el('h1', { className: 'title-sm' }, t('native.offline.title')),
|
||||
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
|
||||
retry,
|
||||
change,
|
||||
footLinks()
|
||||
...columns(
|
||||
[
|
||||
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
|
||||
el('h1', { className: 'title-sm' }, t('native.offline.title')),
|
||||
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
|
||||
],
|
||||
[retry, change, footLinks()]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+283
-24
@@ -38,6 +38,45 @@
|
||||
font-display: swap;
|
||||
src: url('./fonts/museomoderno-latin-600.woff2') format('woff2');
|
||||
}
|
||||
/*
|
||||
* Poppins and MuseoModerno have no Cyrillic, so Russian and Ukrainian fell back
|
||||
* to the system's plain sans. Each family gets a Cyrillic-only stand-in under its
|
||||
* own name, picked for the same build: Montserrat for the geometric text face,
|
||||
* Comfortaa for the rounded headline. The unicode-range keeps Latin on TREK's
|
||||
* own faces and loads these only when a Cyrillic letter is on screen.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/montserrat-cyrillic-500.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/montserrat-cyrillic-600.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/montserrat-cyrillic-700.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'MuseoModerno';
|
||||
font-style: normal;
|
||||
font-weight: 600 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/comfortaa-cyrillic-700.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */
|
||||
@font-face {
|
||||
font-family: 'MuseoModerno';
|
||||
@@ -71,8 +110,13 @@
|
||||
--radius: 16px;
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
/* 1 left to right, -1 in Arabic: a screen that comes next slides in from the trailing side. */
|
||||
--flow: 1;
|
||||
color-scheme: dark;
|
||||
}
|
||||
:root[dir='rtl'] {
|
||||
--flow: -1;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
@@ -167,6 +211,18 @@ body::after {
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
/* In Arabic the photo holds the leading (right) side and the grid the other. */
|
||||
:root[dir='rtl'] body::after {
|
||||
background-position:
|
||||
0 0,
|
||||
right 28%;
|
||||
-webkit-mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
|
||||
mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
|
||||
}
|
||||
:root[dir='rtl'] body::before {
|
||||
-webkit-mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
|
||||
mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
@@ -175,11 +231,12 @@ body::after {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-width: 440px;
|
||||
margin: 0 auto;
|
||||
padding: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
|
||||
overflow: hidden;
|
||||
/* Clip only the sideways slide: a screen taller than the window scrolls the
|
||||
page instead (and a sticky column needs an ancestor that does not scroll). */
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* ── Screen transitions ──────────────────────────────────────────────────── */
|
||||
@@ -187,14 +244,22 @@ body::after {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
/* Centred while it fits; taller than the window it starts at the top and the
|
||||
page scrolls, rather than overflowing both edges as justify-content would. */
|
||||
margin-block: auto;
|
||||
}
|
||||
/* A screen's two halves (main.ts, columns()): one column unless the layout asks. */
|
||||
.col {
|
||||
display: contents;
|
||||
}
|
||||
.screen-enter {
|
||||
animation: screenIn 0.5s var(--ease) both;
|
||||
}
|
||||
/* Lifted out where it stood: main.ts sets its top, left and width. */
|
||||
.screen-leave {
|
||||
animation: screenOut 0.28s var(--ease-in) both;
|
||||
position: absolute;
|
||||
inset: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
|
||||
margin: 0;
|
||||
}
|
||||
.screen-back.screen-enter {
|
||||
animation-name: screenInBack;
|
||||
@@ -205,7 +270,7 @@ body::after {
|
||||
@keyframes screenIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(26px);
|
||||
transform: translateX(calc(26px * var(--flow)));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -215,13 +280,13 @@ body::after {
|
||||
@keyframes screenOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-22px);
|
||||
transform: translateX(calc(-22px * var(--flow)));
|
||||
}
|
||||
}
|
||||
@keyframes screenInBack {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-26px);
|
||||
transform: translateX(calc(-26px * var(--flow)));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -231,14 +296,40 @@ body::after {
|
||||
@keyframes screenOutBack {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(22px);
|
||||
transform: translateX(calc(22px * var(--flow)));
|
||||
}
|
||||
}
|
||||
|
||||
/* Children stagger in, so a screen assembles rather than snaps. */
|
||||
.screen-enter > * {
|
||||
/* Children stagger in, so a screen assembles rather than snaps. Inside the two
|
||||
halves the explanation comes first and the choices follow. */
|
||||
.screen-enter > :not(.col),
|
||||
.screen-enter > .col > * {
|
||||
animation: riseIn 0.55s var(--ease) both;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(1) {
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(2) {
|
||||
animation-delay: 0.07s;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(3) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.screen-enter > .col > :nth-child(4) {
|
||||
animation-delay: 0.17s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(1) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(3) {
|
||||
animation-delay: 0.25s;
|
||||
}
|
||||
.screen-enter > .col-act > :nth-child(4) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.screen-enter > *:nth-child(1) {
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
@@ -364,6 +455,7 @@ body::after {
|
||||
margin: 0;
|
||||
/* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */
|
||||
white-space: pre-line;
|
||||
text-wrap: balance;
|
||||
}
|
||||
/* The welcome headline as the brand says it: MuseoModerno, lower case. */
|
||||
.brand-title {
|
||||
@@ -380,6 +472,8 @@ body::after {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
/* Left to right inside (dir="ltr"), yet on the screen's start edge in Arabic. */
|
||||
align-self: flex-start;
|
||||
}
|
||||
.cloud-wordmark img {
|
||||
display: block;
|
||||
@@ -414,6 +508,7 @@ body::after {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.sub {
|
||||
color: var(--muted);
|
||||
@@ -421,6 +516,8 @@ body::after {
|
||||
line-height: 1.55;
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
/* No lone last word ("AI.") on a line of its own. */
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
@@ -428,9 +525,10 @@ body::after {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 1.02rem;
|
||||
padding: 16px 18px;
|
||||
padding: 16px 22px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
/* A pill, as the app's buttons are. */
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
cursor: pointer;
|
||||
@@ -455,6 +553,11 @@ body::after {
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
/* A two-line choice (welcome) is a card rather than a button: the app's card radius. */
|
||||
.btn:has(.btn-row) {
|
||||
border-radius: 22px;
|
||||
padding-inline: 18px;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
@@ -471,6 +574,8 @@ body::after {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
/* A subtitle that wraps keeps to the start edge under its title. */
|
||||
text-align: start;
|
||||
}
|
||||
.btn-row b {
|
||||
font-weight: 650;
|
||||
@@ -481,9 +586,20 @@ body::after {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.btn .chev {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* A centred label with the chevron at the far edge, the label optically centred. */
|
||||
.btn-go .btn-label {
|
||||
flex: 1;
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
/* Chevrons point along the reading direction. */
|
||||
:root[dir='rtl'] .chev,
|
||||
:root[dir='rtl'] .back svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.linkbtn {
|
||||
font: inherit;
|
||||
@@ -507,7 +623,7 @@ body::after {
|
||||
margin-top: 2px;
|
||||
padding: 9px 18px;
|
||||
min-height: 40px;
|
||||
border-radius: var(--radius);
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--edge);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
@@ -565,8 +681,8 @@ a.linkbtn {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 13px 16px;
|
||||
border-radius: var(--radius);
|
||||
padding: 13px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--edge);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
@@ -707,7 +823,8 @@ a.linkbtn {
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0 2px -8px;
|
||||
margin-block: 0 2px;
|
||||
margin-inline-start: -8px;
|
||||
}
|
||||
.back {
|
||||
font: inherit;
|
||||
@@ -741,7 +858,7 @@ a.linkbtn {
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 13px;
|
||||
border-radius: 999px;
|
||||
align-self: stretch;
|
||||
}
|
||||
.segmented button {
|
||||
@@ -751,7 +868,7 @@ a.linkbtn {
|
||||
font-size: 0.95rem;
|
||||
padding: 9px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
@@ -829,7 +946,7 @@ a.linkbtn {
|
||||
font-weight: 500;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-left: 7px;
|
||||
margin-inline-start: 7px;
|
||||
}
|
||||
.plan-feats {
|
||||
margin: 10px 0 14px;
|
||||
@@ -902,8 +1019,8 @@ a.linkbtn {
|
||||
.slug-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 16px;
|
||||
border-radius: var(--radius);
|
||||
padding-inline-end: 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--edge);
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
@@ -913,14 +1030,20 @@ a.linkbtn {
|
||||
border-color 0.2s,
|
||||
box-shadow 0.25s;
|
||||
}
|
||||
.slug-field .slug-input {
|
||||
.slug-field .slug-input,
|
||||
.slug-field .slug-input:focus {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
/* The ring is the whole field's (focus-within below), not a second one inside it. */
|
||||
box-shadow: none;
|
||||
}
|
||||
/* An email is typed left to right, but its Arabic placeholder reads from the right. */
|
||||
:root[dir='rtl'] .field input.ltr-value:placeholder-shown {
|
||||
direction: rtl;
|
||||
}
|
||||
.slug-field:focus-within {
|
||||
border-color: var(--edge-strong);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
|
||||
@@ -934,7 +1057,7 @@ a.linkbtn {
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
padding-left: 4px;
|
||||
padding-inline-start: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.hint {
|
||||
@@ -976,3 +1099,139 @@ a.linkbtn {
|
||||
border-color: var(--edge-strong);
|
||||
border-top-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── A large screen ──────────────────────────────────────────────────────────
|
||||
On its side (an iPad, a foldable unfolded) the photo becomes a panel of its
|
||||
own on the leading half, full height, and the column of choices stands
|
||||
centred in the other half, the way a split sign-in page uses the width.
|
||||
Upright, the photo band grows with the screen instead of stopping at a
|
||||
phone's 440px. Logical sides, so Arabic mirrors it. */
|
||||
@media (min-width: 840px) and (min-height: 600px) and (orientation: landscape) {
|
||||
body::after,
|
||||
:root[dir='rtl'] body::after {
|
||||
inset: 16px auto 16px 16px;
|
||||
inset-block: 16px;
|
||||
inset-inline: 16px auto;
|
||||
width: calc(50% - 24px);
|
||||
height: auto;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(180deg, transparent 50%, rgba(0, 0, 0, 0.32) 100%),
|
||||
url('./img/kyoto.webp') 30% 45% / cover no-repeat;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
box-shadow: 0 30px 70px -30px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
body::before {
|
||||
inset: 0;
|
||||
inset-inline-start: 50%;
|
||||
height: auto;
|
||||
}
|
||||
.shell {
|
||||
max-width: none;
|
||||
width: 50%;
|
||||
margin-inline: 50% 0;
|
||||
padding-inline: max(32px, calc(25vw - 220px));
|
||||
}
|
||||
}
|
||||
/* With the whole half of a 13" iPad on its side, the plans stand two by two
|
||||
rather than in a column to scroll through. */
|
||||
@media (min-width: 1200px) and (min-height: 600px) and (orientation: landscape) {
|
||||
.shell:has(.plans) {
|
||||
padding-inline: max(32px, calc(25vw - 320px));
|
||||
}
|
||||
.plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
/* An iPad upright, a foldable unfolded, half of an iPad in Split View: the photo
|
||||
spans the whole width as a band across the top and fades out only downwards,
|
||||
and the choices stand under it in a wider column than a phone's, centred in
|
||||
the room the band leaves, so the page reads as one composition rather than a
|
||||
phone screen at the top of the glass. The plans take two columns. */
|
||||
@media (min-width: 600px) and (min-height: 800px) and (orientation: portrait) {
|
||||
body::before,
|
||||
body::after {
|
||||
height: 56vh;
|
||||
}
|
||||
body::after,
|
||||
:root[dir='rtl'] body::after {
|
||||
background-position:
|
||||
0 0,
|
||||
center 30%;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
.shell {
|
||||
max-width: 560px;
|
||||
padding-top: calc(env(safe-area-inset-top) + 28vh);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 6vh);
|
||||
}
|
||||
.shell:has(.plans) {
|
||||
max-width: 760px;
|
||||
padding-top: calc(env(safe-area-inset-top) + 16vh);
|
||||
}
|
||||
.plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── A short screen on its side ──────────────────────────────────────────────
|
||||
A phone, or a foldable folded, turned landscape: one column of choices is
|
||||
taller than the glass, and the second choice or the main button ended up
|
||||
below the edge. The two halves of a screen stand side by side instead, what
|
||||
it is on the leading side and what to do on the other, top-aligned under the
|
||||
status bar. The leading half stays put while a long list beside it (the
|
||||
plans) scrolls, so Back is always in reach. */
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.shell {
|
||||
max-width: 880px;
|
||||
padding-top: calc(env(safe-area-inset-top) + 16px);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
|
||||
padding-inline: max(28px, env(safe-area-inset-left)) max(28px, env(safe-area-inset-right));
|
||||
}
|
||||
.screen:has(> .col) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
column-gap: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
/* The plans run longer than the window: both halves start at the top. */
|
||||
.screen:has(.plans) {
|
||||
align-items: start;
|
||||
}
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.col-about {
|
||||
position: sticky;
|
||||
top: calc(env(safe-area-inset-top) + 16px);
|
||||
}
|
||||
.hero-logo {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.hero-logo img {
|
||||
height: 46px;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.btn {
|
||||
min-height: 50px;
|
||||
padding-block: 12px;
|
||||
}
|
||||
.features {
|
||||
margin: 0;
|
||||
}
|
||||
.feature {
|
||||
padding: 7px 0;
|
||||
}
|
||||
.foot-links {
|
||||
margin-top: 0;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,11 +282,17 @@
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm, useExpenseLinks",
|
||||
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts", "src/components/Budget/useExpenseLinks.ts"],
|
||||
"feature": "Costs/budget ledger: useCostsLedger, useExpenseLinks",
|
||||
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useExpenseLinks.ts"],
|
||||
"desktop": ["src/components/Budget/CostsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Costs/budget settle-up payment: useSettlementForm",
|
||||
"hooks": ["src/components/Budget/useSettlementForm.ts"],
|
||||
"desktop": ["src/components/Budget/CostsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/tabs/MCostsPayment.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Create/edit trip form",
|
||||
"hooks": ["src/components/Trips/tripFormModel.ts", "src/components/Trips/useTripCoverPicker.ts"],
|
||||
@@ -1002,11 +1008,13 @@
|
||||
"src/mobile/screens/collections/MCollInvites.tsx": "Markup only; the invites and accept and decline come through props from useCollections via MCollections.",
|
||||
"src/mobile/screens/collections/MCollLabelsSheet.tsx": "Phone label sheet; create, update, delete and assign come through props from useCollections.",
|
||||
"src/mobile/screens/collections/MCollLinksEditor.tsx": "Markup only; link rows and the change handler come through props from the collection sheets.",
|
||||
"src/mobile/screens/collections/MCollListNav.tsx": "Markup only; the collections' lists as the header dropdown or as the column beside the places on a wide window, with the lists and the selection handed in through props from useCollections.",
|
||||
"src/mobile/screens/collections/MCollListPickerSheet.tsx": "Phone list picker sheet; the move or copy action comes through onPick from useCollections.",
|
||||
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
|
||||
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
|
||||
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
|
||||
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
|
||||
"src/mobile/screens/journey/MJourneyFeed.tsx": "Markup only; the journey's entries as a feed beside the map on a wide window, with the entries, the active one and the tap handed in through props from useJourneyDetail and useJourneyCarouselSync.",
|
||||
"src/mobile/screens/settings/MAppLockCard.tsx": "Phone only: the app lock exists only in the native app, so there is no desktop setting to share with.",
|
||||
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
||||
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
||||
@@ -1018,6 +1026,8 @@
|
||||
"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/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.",
|
||||
"src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.",
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.",
|
||||
@@ -1053,10 +1063,13 @@
|
||||
"src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.",
|
||||
"src/mobile/screens/trip/sheets/tourSheetOpen.ts": "Phone only context for MTourDetailShell: whether the tour sheet is open and the name it carries.",
|
||||
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
|
||||
"src/mobile/screens/trip/tabs/MFilesBoard.tsx": "Markup only; the trip's files as tiles and the picked file's preview on a wide window, with the files and their actions handed in through props from MFilesTab.",
|
||||
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
|
||||
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
|
||||
"src/mobile/screens/trip/tabs/MReservationCardParts.tsx": "Markup only: the title, actions, date, files and delete sheet parts both phone booking tabs draw.",
|
||||
"src/mobile/screens/trip/tabs/MTodoSidebar.tsx": "Markup only; the to-do filters as a pane beside the tasks on a wide window, with the filter state and the counts handed in through props from useTodoView.",
|
||||
"src/mobile/screens/trip/tabs/MTripTabPanel.tsx": "Phone tab router that mounts the per tab screens and plugin frames, with no logic of its own.",
|
||||
"src/mobile/screens/trip/tabs/collabPanes.ts": "Phone only; decides whether the collab tab shows the chat beside a board on a wide window, a layout question of the touch shell that the desktop planner does not have.",
|
||||
"src/mobile/screens/trip/tabs/listsModel.ts": "Priority colour and label constants only the phone to-do list and task sheet use.",
|
||||
"src/mobile/screens/trip/tabs/tabChrome.tsx": "Phone tab chrome primitives (scroller, section headers, fields, traveler chips, plugin slots).",
|
||||
"src/mobile/screens/trip/tabs/tabModel.ts": "Phone tab prop type and status colour tokens only the phone tabs use.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"admin.ts": 112,
|
||||
"atlas.ts": 9,
|
||||
"backup.ts": 10,
|
||||
"budget.ts": 36,
|
||||
"budget.ts": 35,
|
||||
"categories.ts": 1,
|
||||
"collab.ts": 9,
|
||||
"collection.ts": 35,
|
||||
|
||||
@@ -80,14 +80,10 @@
|
||||
"hooks/useElementRect.ts": 3,
|
||||
"mobile/components/MSegmented.tsx": 1,
|
||||
"mobile/components/MSheet.tsx": 1,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 1,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 3,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
|
||||
"mobile/screens/trip/MTripShell.tsx": 3,
|
||||
"mobile/screens/trip/map/MMapArea.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 1,
|
||||
"mobile/screens/trip/tabs/MFileLightbox.tsx": 2,
|
||||
"mobile/screens/vacay/MVacay.tsx": 1,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 1,
|
||||
"pages/AtlasPage.tsx": 2,
|
||||
"pages/JourneyDetailPage.tsx": 1,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1214,
|
||||
"src/components/Map/MapView.tsx": 1201,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2127,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
@@ -20,7 +20,7 @@
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4028,
|
||||
"src/pages/LoginPage.tsx": 1347,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/atlas/useAtlas.ts": 1210,
|
||||
"src/pages/atlas/useAtlas.ts": 1207,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 1918
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"components/Journey/JourneyDetailPageProviderPicker.tsx": 163,
|
||||
"components/Journey/JourneyDetailPageScrollTrigger.tsx": 4,
|
||||
"components/Journey/JourneyDetailPageVerdictSection.tsx": 51,
|
||||
"components/Journey/JourneyEntryCover.tsx": 15,
|
||||
"components/Journey/JourneyEntryCover.tsx": 13,
|
||||
"components/Journey/JourneyMap.tsx": 3,
|
||||
"components/Journey/JourneyMapGL.tsx": 5,
|
||||
"components/Journey/MobileEntryView.tsx": 94,
|
||||
@@ -146,7 +146,7 @@
|
||||
"mobile/components/MStorageTestResult.tsx": 3,
|
||||
"mobile/components/MToastHost.tsx": 2,
|
||||
"mobile/components/MUserPickerList.tsx": 1,
|
||||
"mobile/screens/admin/MAdmin.tsx": 9,
|
||||
"mobile/screens/admin/MAdmin.tsx": 8,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 9,
|
||||
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
|
||||
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
|
||||
@@ -181,9 +181,9 @@
|
||||
"mobile/screens/collections/MCollShareSheet.tsx": 14,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 5,
|
||||
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
|
||||
"mobile/screens/collections/MCollections.tsx": 18,
|
||||
"mobile/screens/collections/MCollections.tsx": 14,
|
||||
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 31,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 30,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
|
||||
"mobile/screens/dashboard/MUserMenu.tsx": 7,
|
||||
"mobile/screens/journey/MJourney.tsx": 10,
|
||||
@@ -202,7 +202,7 @@
|
||||
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
|
||||
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettings.tsx": 1,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
@@ -214,7 +214,7 @@
|
||||
"mobile/screens/settings/MSettingsUi.tsx": 11,
|
||||
"mobile/screens/settings/MWebPushCard.tsx": 1,
|
||||
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
|
||||
"mobile/screens/trip/MTripShell.tsx": 9,
|
||||
"mobile/screens/trip/MTripShell.tsx": 7,
|
||||
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
|
||||
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
|
||||
@@ -262,7 +262,7 @@
|
||||
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
|
||||
"mobile/screens/trip/tabs/MCollabTab.tsx": 1,
|
||||
"mobile/screens/trip/tabs/MCostsTab.tsx": 71,
|
||||
"mobile/screens/trip/tabs/MCostsTab.tsx": 70,
|
||||
"mobile/screens/trip/tabs/MDocSyncSheet.tsx": 43,
|
||||
"mobile/screens/trip/tabs/MFileLightbox.tsx": 12,
|
||||
"mobile/screens/trip/tabs/MFileLinkSheet.tsx": 2,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-016: the trip chat logic behind the desktop
|
||||
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-018: the trip chat logic behind the desktop
|
||||
// Collab panel (quiet older-page errors, scroll on remote delete, upload progress) and the
|
||||
// phone's chat tab (toasted older-page errors, sending gated on the edit permission).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
|
||||
import { createElement, type ChangeEvent } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { collabApi } from '../../api/client';
|
||||
@@ -285,3 +285,80 @@ describe('useCollabChatData', () => {
|
||||
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCollabChatData on a box that changes size', () => {
|
||||
// The observer reports when the test says the list's box has changed.
|
||||
let report: () => void = () => {};
|
||||
let observed: Element[] = [];
|
||||
class Observer {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
report = () => callback([], this as unknown as ResizeObserver);
|
||||
}
|
||||
observe(el: Element) {
|
||||
observed.push(el);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The list as both views draw it: only once the messages are in, so at mount there
|
||||
* is no element yet. Its sizes are the test's to set, since jsdom lays nothing out.
|
||||
*/
|
||||
function ChatList({ scrollHeight }: { scrollHeight: () => number }) {
|
||||
const chat = useCollabChatData({
|
||||
tripId: 1,
|
||||
t,
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
});
|
||||
const setList = (el: HTMLDivElement | null) => {
|
||||
if (el && !Object.getOwnPropertyDescriptor(el, 'scrollHeight')) {
|
||||
let scrollTop = 0;
|
||||
Object.defineProperty(el, 'scrollHeight', { configurable: true, get: scrollHeight });
|
||||
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => 400 });
|
||||
Object.defineProperty(el, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (v: number) => {
|
||||
scrollTop = v;
|
||||
},
|
||||
});
|
||||
el.scrollTo = () => {};
|
||||
}
|
||||
(chat.scrollRef as { current: HTMLDivElement | null }).current = el;
|
||||
};
|
||||
if (chat.loading || chat.messages.length === 0) return createElement('p', null, 'loading');
|
||||
return createElement('div', { 'data-testid': 'list', ref: setList, onScroll: chat.checkAtBottom });
|
||||
}
|
||||
|
||||
async function mountList(height: number) {
|
||||
let scrollHeight = height;
|
||||
render(createElement(ChatList, { scrollHeight: () => scrollHeight }));
|
||||
const list = await screen.findByTestId('list');
|
||||
return { list, grow: (to: number) => (scrollHeight = to) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observed = [];
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('FE-COLLAB-CHATDATA-017: a list at its newest message stays there when a rotation or a fold resizes it', async () => {
|
||||
vi.stubGlobal('ResizeObserver', Observer);
|
||||
const { list, grow } = await mountList(400);
|
||||
expect(observed).toContain(list);
|
||||
grow(900);
|
||||
report();
|
||||
expect(list.scrollTop).toBe(900);
|
||||
});
|
||||
|
||||
it('FE-COLLAB-CHATDATA-018: a list read further up keeps its place', async () => {
|
||||
vi.stubGlobal('ResizeObserver', Observer);
|
||||
const { list, grow } = await mountList(2000);
|
||||
list.scrollTop = 200;
|
||||
fireEvent.scroll(list);
|
||||
grow(2600);
|
||||
report();
|
||||
expect(list.scrollTop).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,22 @@ export function useCollabChatData({
|
||||
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
||||
}, []);
|
||||
|
||||
// A list that sat at its newest message stays there when its box changes size (a
|
||||
// rotation, a fold, a pane opening beside it): the messages wrap anew and the list
|
||||
// grows, which left the newest ones below the composer. Both views draw the list
|
||||
// only once there are messages to show, so the observer attaches when it appears,
|
||||
// not at mount, when there is nothing yet to observe.
|
||||
const listShown = !loading && messages.length > 0;
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!listShown || !el || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isAtBottom.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [listShown]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
@@ -77,8 +77,9 @@ describe('JournalBody', () => {
|
||||
const { container } = render(<JournalBody text={'# Big\n\n### Small'} />);
|
||||
expect(container.querySelector('h1')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('h3')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Big').tagName).toBe('P');
|
||||
expect(screen.getByText('Small').tagName).toBe('P');
|
||||
// Each paragraph's text sits in an isolate (<bdi>) so it keeps its own direction.
|
||||
expect(screen.getByText('Big').parentElement?.tagName).toBe('P');
|
||||
expect(screen.getByText('Small').parentElement?.tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-012: keeps a setext underline as text instead of a heading', () => {
|
||||
|
||||
@@ -15,13 +15,14 @@ export default function JournalBody({ text, dark }: Props) {
|
||||
lineHeight: 1.6,
|
||||
color: 'inherit',
|
||||
}}>
|
||||
{/* Each paragraph in its own direction (bdi): a Latin story in the Arabic UI keeps its full stop at its end. */}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={{
|
||||
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote style={{
|
||||
borderInlineStart: `3px solid var(--journal-accent)`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-COVER-001 to FE-JRN-COVER-010
|
||||
// FE-JRN-COVER-001 to FE-JRN-COVER-011
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -133,4 +133,25 @@ describe('JourneyEntryCover', () => {
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
|
||||
expect(container.querySelector('svg.lucide-play')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-011: only the phone draws a card without a photo in the deep day colour, opaque even as a suggestion', () => {
|
||||
const entry = buildEntry({ photos: [], type: 'skeleton' })
|
||||
const phone = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} tone="mobile" />)
|
||||
const phoneCard = phone.getByRole('button')
|
||||
// d9, the deep wash (jsdom spells the colour out as rgba)
|
||||
expect(phoneCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.85/)
|
||||
expect(phoneCard.className).not.toContain('opacity-90')
|
||||
expect(phone.container.querySelector('svg.lucide-map-pin')?.getAttribute('class')).toContain('-mt-6')
|
||||
phone.unmount()
|
||||
|
||||
// The desktop and shared timelines keep their faint wash and the day-coloured pin.
|
||||
const desk = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
|
||||
const deskCard = desk.getByRole('button')
|
||||
// 2e, the faint one
|
||||
expect(deskCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.18/)
|
||||
expect(deskCard.className).toContain('opacity-90')
|
||||
const pin = desk.container.querySelector('svg.lucide-map-pin') as SVGElement
|
||||
expect(pin.getAttribute('class')).not.toContain('-mt-6')
|
||||
expect(pin.style.color).not.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,12 @@ interface Props {
|
||||
/** The public journey page serves photos through a share token. */
|
||||
photoUrlFor?: (photoId: number) => string
|
||||
tone?: 'app' | 'mobile'
|
||||
/**
|
||||
* large: the card a wide window shows (an iPad, a foldable unfolded), with
|
||||
* the room for a two-line name and the whole place under it. compact: a phone
|
||||
* on its side, where a regular card left the map a strip 30px tall.
|
||||
*/
|
||||
size?: 'compact' | 'regular' | 'large'
|
||||
}
|
||||
|
||||
export default function JourneyEntryCover({
|
||||
@@ -73,7 +79,10 @@ export default function JourneyEntryCover({
|
||||
showWeather = true,
|
||||
photoUrlFor,
|
||||
tone = 'app',
|
||||
size = 'regular',
|
||||
}: Props) {
|
||||
const large = size === 'large'
|
||||
const box = large ? 'h-[196px] w-[176px]' : size === 'compact' ? 'h-[112px] w-[124px]' : 'h-[152px] w-[136px]'
|
||||
const { t, locale } = useTranslation()
|
||||
const isSuggestion = entry.type === 'skeleton'
|
||||
|
||||
@@ -90,7 +99,12 @@ export default function JourneyEntryCover({
|
||||
const mood = showMood ? moodMeta(entry.mood) : undefined
|
||||
const weather = showWeather ? weatherMeta(entry.weather) : undefined
|
||||
|
||||
const emptyGround = tone === 'mobile' ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800'
|
||||
const mobile = tone === 'mobile'
|
||||
const emptyGround = mobile ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800'
|
||||
// The mark of a card without a photo: white and raised clear of the name on the
|
||||
// phone's deep day colour, the day's own colour on the faint wash elsewhere.
|
||||
const emptyIcon = mobile ? '-mt-6 opacity-80' : 'opacity-60'
|
||||
const emptyIconStyle = mobile ? undefined : { color: dayColor }
|
||||
// One ground for every corner mark. Dark rather than a white veil: these sit on
|
||||
// whatever photograph the traveller took, and a bright sky swallowed the white one.
|
||||
const badge = 'rounded-full bg-black/45 px-[7px] py-[2px] text-[10px] font-bold whitespace-nowrap text-white backdrop-blur-[2px]'
|
||||
@@ -108,9 +122,11 @@ export default function JourneyEntryCover({
|
||||
// growing its box moved every card after it once a swipe had landed, and the
|
||||
// snap then pulled the strip again, which read as a jolt (carousels keep the
|
||||
// room for it with their gap and top padding).
|
||||
className={`relative h-[152px] w-[136px] flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
|
||||
// White on the root: every mark and line on the card is white, over a photo or
|
||||
// over the day's colour alike.
|
||||
className={`relative text-white ${box} flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
|
||||
isActive ? 'z-[1] scale-[1.18] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'scale-100 shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
|
||||
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
|
||||
} ${src ? '' : emptyGround} ${isSuggestion && !mobile ? 'opacity-90' : ''}`}
|
||||
// backgroundImage, not background: the shorthand would drop the opaque colour
|
||||
// the class supplies and leave the map showing through a card that is only a
|
||||
// faint wash of the day's colour.
|
||||
@@ -122,7 +138,14 @@ export default function JourneyEntryCover({
|
||||
// ring lost its top edge to that clip.
|
||||
style={{
|
||||
boxShadow: `inset 0 0 0 ${isActive ? 2.5 : 1.5}px ${dayColor}${isActive ? '' : 'b3'}`,
|
||||
...(src ? null : { backgroundImage: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }),
|
||||
// Without a photograph the phone's card is the day's colour, deep enough to
|
||||
// carry the white name like a photo does, rather than a grey that read as
|
||||
// empty. Its ground stays opaque (no dimming for a suggestion either): it
|
||||
// floats over the map, whose labels read through anything less. The desktop
|
||||
// and shared timelines keep their faint wash of the day.
|
||||
...(src ? null : { backgroundImage: mobile
|
||||
? `linear-gradient(160deg, ${dayColor}d9, ${dayColor}8c 55%, ${dayColor}59)`
|
||||
: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }),
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
@@ -130,8 +153,8 @@ export default function JourneyEntryCover({
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
{posterless
|
||||
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={{ color: dayColor }} className="ml-0.5 opacity-60" />
|
||||
: <MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />}
|
||||
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={emptyIconStyle} className={`ml-0.5 ${emptyIcon}`} />
|
||||
: <MapPin size={24} strokeWidth={1.8} style={emptyIconStyle} className={emptyIcon} />}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -155,7 +178,7 @@ export default function JourneyEntryCover({
|
||||
italic, dimmed title carries the rest. */}
|
||||
{isSuggestion && (
|
||||
<span
|
||||
className={`absolute top-2 ${entry.country_code ? 'start-[30px]' : 'start-2'} ${badgeDot} text-white`}
|
||||
className={`absolute top-2 ${entry.country_code ? 'start-[30px]' : 'start-2'} ${badgeDot}`}
|
||||
title={t('journey.entry.suggestion')}
|
||||
aria-label={t('journey.entry.suggestion')}
|
||||
>
|
||||
@@ -172,7 +195,7 @@ export default function JourneyEntryCover({
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className={`${badgeDot} text-white`}>
|
||||
<span className={`${badgeDot}`}>
|
||||
<weather.icon size={11} strokeWidth={2.4} />
|
||||
</span>
|
||||
)}
|
||||
@@ -181,11 +204,11 @@ export default function JourneyEntryCover({
|
||||
|
||||
{/* Bottom: the name gets the room the thumbnail layout never had. */}
|
||||
<span className="absolute inset-x-0 bottom-0 flex flex-col gap-[1px] px-[10px] pb-[10px]">
|
||||
<span className={`line-clamp-2 text-[13px] leading-[1.25] font-bold text-white drop-shadow-[0_1px_3px_rgba(0,0,0,.6)] ${isSuggestion ? 'italic' : ''}`}>
|
||||
<span className={`line-clamp-2 text-[13px] leading-[1.25] font-bold drop-shadow-[0_1px_3px_rgba(0,0,0,.6)] ${isSuggestion ? 'italic' : ''}`}>
|
||||
{title}
|
||||
</span>
|
||||
{place && (
|
||||
<span className="truncate text-[10.5px] leading-[1.3] font-medium text-white/80 drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
|
||||
<span className={`${large ? 'line-clamp-2' : 'truncate'} text-[10.5px] leading-[1.3] font-medium text-white/80 drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]`}>
|
||||
{place}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-062
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-063
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -592,6 +592,28 @@ describe('JourneyMap', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-063: paddingStart keeps the journey clear of a rail on the leading side, either way round', () => {
|
||||
const origRAF = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
|
||||
try {
|
||||
const { unmount } = render(<JourneyMap checkins={[]} entries={entriesWithCoords} paddingBottom={200} paddingStart={100} />);
|
||||
expect(mockedMap().fitBounds).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ paddingTopLeft: [150, 50], paddingBottomRight: [50, 200] }),
|
||||
);
|
||||
unmount();
|
||||
document.documentElement.dir = 'rtl';
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} paddingBottom={200} paddingStart={100} />);
|
||||
expect(mockedMap().fitBounds).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ paddingTopLeft: [50, 50], paddingBottomRight: [150, 200] }),
|
||||
);
|
||||
} finally {
|
||||
document.documentElement.dir = '';
|
||||
globalThis.requestAnimationFrame = origRAF;
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-026: dark mode picks the dark basemap', async () => {
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} dark />);
|
||||
// The default basemap is a vector style now, so it arrives as a style option
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref, type RefObject } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref, type RefObject } from 'react'
|
||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||
import L from 'leaflet'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -76,6 +76,12 @@ interface Props {
|
||||
*/
|
||||
onMarkerCardClick?: (entryId: string) => void
|
||||
paddingBottom?: number
|
||||
/**
|
||||
* How much of the map's leading edge a bar laid over it covers (the touch
|
||||
* shell's rail), in px: framing keeps the journey clear of it. Read when the
|
||||
* map frames, so a change alone does not rebuild the map.
|
||||
*/
|
||||
paddingStart?: number
|
||||
/** CARTO key from the share payload: the public journey has no settings store to read. */
|
||||
cartoApiKey?: string
|
||||
/**
|
||||
@@ -281,13 +287,23 @@ function markCoords(marks: JourneyMapMark[] | undefined): L.LatLngTuple[] {
|
||||
return (marks ?? []).map(mark => [mark.lat, mark.lng] as L.LatLngTuple)
|
||||
}
|
||||
|
||||
function fitOptions(paddingBottom: number | undefined): L.FitBoundsOptions {
|
||||
return { paddingTopLeft: [50, 50], paddingBottomRight: [50, paddingBottom || 50], maxZoom: 16 }
|
||||
function fitOptions(paddingBottom: number | undefined, paddingStart = 0): L.FitBoundsOptions {
|
||||
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'
|
||||
return {
|
||||
paddingTopLeft: [50 + (rtl ? 0 : paddingStart), 50],
|
||||
paddingBottomRight: [50 + (rtl ? paddingStart : 0), paddingBottom || 50],
|
||||
maxZoom: 16,
|
||||
}
|
||||
}
|
||||
|
||||
function JourneyMap(
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
) {
|
||||
// The leading inset is read when the map frames, not a reason to rebuild it.
|
||||
const paddingStartRef = useRef(paddingStart)
|
||||
useLayoutEffect(() => {
|
||||
paddingStartRef.current = paddingStart
|
||||
})
|
||||
// Read through a ref: the handler is fixed per surface, and putting it in the
|
||||
// marker effect's deps would rebuild every marker for nothing.
|
||||
const onMarkerCardClickRef = useRef(onMarkerCardClick)
|
||||
@@ -536,7 +552,7 @@ function JourneyMap(
|
||||
const pointCoords = [...allCoords, ...markCoords(marksRef.current)]
|
||||
const fitCoords = pointCoords.length > 0 ? pointCoords : trackCoords
|
||||
marksFramedRef.current = (marksRef.current?.length ?? 0) > 0
|
||||
if (fitCoords.length > 0) map.fitBounds(L.latLngBounds(fitCoords), fitOptions(paddingBottom))
|
||||
if (fitCoords.length > 0) map.fitBounds(L.latLngBounds(fitCoords), fitOptions(paddingBottom, paddingStartRef.current))
|
||||
else map.setView([30, 0], 2)
|
||||
} catch {}
|
||||
})
|
||||
@@ -652,7 +668,7 @@ function JourneyMap(
|
||||
const coords = markCoords(marks)
|
||||
if (!marksFramedRef.current && coords.length > 0) {
|
||||
marksFramedRef.current = true
|
||||
if (!readerMovedRef.current) map.fitBounds(L.latLngBounds([...entryCoordsRef.current, ...coords]), fitOptions(paddingBottom))
|
||||
if (!readerMovedRef.current) map.fitBounds(L.latLngBounds([...entryCoordsRef.current, ...coords]), fitOptions(paddingBottom, paddingStartRef.current))
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
@@ -39,6 +39,12 @@ interface Props {
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
/**
|
||||
* How much of the map's leading edge a bar laid over it covers (the touch
|
||||
* shell's rail), in px: framing keeps the journey clear of it. Read when the
|
||||
* map frames, so a change alone does not rebuild the map.
|
||||
*/
|
||||
paddingStart?: number
|
||||
/** Open the entry a marker card names when the card is tapped, both renderers. */
|
||||
onMarkerCardClick?: (entryId: string) => void
|
||||
/** Open an entry's photos from the marker card's thumbnail strip. GL renderer only. */
|
||||
|
||||
@@ -317,6 +317,14 @@ describe('JourneyMapGL', () => {
|
||||
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'journey-route-line' }))
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-049: paddingStart widens the fit on the side of the rail', () => {
|
||||
withToken()
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} paddingBottom={200} paddingStart={100} fullScreen />)
|
||||
expect(gl.map.fitBounds).toHaveBeenCalledWith(gl.boundsList[0], expect.objectContaining({
|
||||
padding: { top: 50, bottom: 200, left: 150, right: 50 },
|
||||
}))
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-010: the camera fits entries and trail with the requested bottom padding', () => {
|
||||
withToken()
|
||||
render(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
@@ -57,6 +57,12 @@ interface Props {
|
||||
photos?: MapPhoto[]
|
||||
onPhotoClick?: (photoIds: string[]) => void
|
||||
paddingBottom?: number
|
||||
/**
|
||||
* How much of the map's leading edge a bar laid over it covers (the touch
|
||||
* shell's rail), in px: framing keeps the journey clear of it. Read when the
|
||||
* map frames, so a change alone does not rebuild the map.
|
||||
*/
|
||||
paddingStart?: number
|
||||
/** See the Leaflet twin (JRN-04): hidden layers go off the map in place. */
|
||||
layerVisibility?: JourneyMapLayerVisibility
|
||||
marks?: JourneyMapMark[]
|
||||
@@ -185,9 +191,10 @@ function markBounds(gl: Props['gl'], marks: JourneyMapMark[], points: [number, n
|
||||
return bounds
|
||||
}
|
||||
|
||||
function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefined) {
|
||||
function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefined, paddingStart = 0) {
|
||||
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'
|
||||
return {
|
||||
padding: { top: 50, bottom: paddingBottom || 50, left: 50, right: 50 },
|
||||
padding: { top: 50, bottom: paddingBottom || 50, left: 50 + (rtl ? 0 : paddingStart), right: 50 + (rtl ? paddingStart : 0) },
|
||||
maxZoom: 16,
|
||||
pitch: tilted ? 45 : 0,
|
||||
duration: 0,
|
||||
@@ -195,8 +202,13 @@ function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefin
|
||||
}
|
||||
|
||||
function JourneyMapGL(
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
) {
|
||||
// The leading inset is read when the map frames, not a reason to rebuild it.
|
||||
const paddingStartRef = useRef(paddingStart)
|
||||
useLayoutEffect(() => {
|
||||
paddingStartRef.current = paddingStart
|
||||
})
|
||||
const onMarkerCardClickRef = useRef(onMarkerCardClick)
|
||||
onMarkerCardClickRef.current = onMarkerCardClick
|
||||
const onMarkerPhotoClickRef = useRef(onMarkerPhotoClick)
|
||||
@@ -526,7 +538,7 @@ function JourneyMapGL(
|
||||
known.forEach(mark => bounds.extend([mark.lng, mark.lat]))
|
||||
if (hasPoints || known.length > 0) {
|
||||
try {
|
||||
map.fitBounds(bounds, fitOptions(paddingBottom, enableMapbox3d && fullScreen))
|
||||
map.fitBounds(bounds, fitOptions(paddingBottom, enableMapbox3d && fullScreen, paddingStartRef.current))
|
||||
} catch { /* empty bounds */ }
|
||||
}
|
||||
})
|
||||
@@ -631,7 +643,7 @@ function JourneyMapGL(
|
||||
marksFramedRef.current = true
|
||||
if (!readerMovedRef.current) {
|
||||
try {
|
||||
map.fitBounds(markBounds(gl, marks, entryPointsRef.current), fitOptions(paddingBottom, enableMapbox3d && fullScreen))
|
||||
map.fitBounds(markBounds(gl, marks, entryPointsRef.current), fitOptions(paddingBottom, enableMapbox3d && fullScreen, paddingStartRef.current))
|
||||
} catch { /* empty bounds */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-009: the card carousel over the journey map,
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-010: the card carousel over the journey map,
|
||||
// behind both the phone journey screen and the narrow desktop and public timeline.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -184,4 +184,33 @@ describe('useJourneyCarouselSync', () => {
|
||||
settleAt(0);
|
||||
expect(hook.result.current.activeIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-JRN-CAROUSEL-010: a strip that comes back (the feed on a wide window gave way to it) is tracked again', () => {
|
||||
const carousel = document.createElement('div');
|
||||
carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
Object.defineProperty(carousel, 'clientWidth', { value: 500 });
|
||||
let strip: HTMLDivElement | null = null;
|
||||
const hook = renderHook(
|
||||
(p: Options) => {
|
||||
const sync = useJourneyCarouselSync(p);
|
||||
(sync.carouselRef as { current: HTMLDivElement | null }).current = strip;
|
||||
return sync;
|
||||
},
|
||||
{ initialProps: { entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn(), layoutKey: true } }
|
||||
);
|
||||
ENTRIES.forEach((_, i) => {
|
||||
const node = document.createElement('div');
|
||||
node.getBoundingClientRect = () => ({ left: i * 200, width: 100 }) as DOMRect;
|
||||
hook.result.current.cardRefs.current.set(i, node);
|
||||
});
|
||||
|
||||
// The strip mounts with the layout's turn, with the same entries as before.
|
||||
strip = carousel;
|
||||
hook.rerender({ entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn(), layoutKey: false });
|
||||
carousel.dispatchEvent(new Event('scroll'));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(90);
|
||||
});
|
||||
expect(hook.result.current.activeIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
||||
|
||||
import type { JourneyMapHandle } from './JourneyMap';
|
||||
import { journeyDays } from './journeyCard';
|
||||
@@ -17,6 +17,11 @@ export interface JourneyCarouselSyncOptions<E extends CarouselEntry> {
|
||||
onOpenEntry: (entry: E) => void;
|
||||
/** A tap on another card also moves the map to it (the phone screen). */
|
||||
syncMapOnTap?: boolean;
|
||||
/**
|
||||
* Changes whenever the strip comes or goes (the phone screen shows a feed in its
|
||||
* place on a wide window), so its scroll tracking attaches to the strip on show.
|
||||
*/
|
||||
layoutKey?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +60,56 @@ function nearestCardIndex(el: HTMLDivElement | null, cards: Map<number, HTMLDivE
|
||||
*/
|
||||
const SETTLE_MS = 90;
|
||||
|
||||
interface StripScrollOptions {
|
||||
carouselRef: RefObject<HTMLDivElement | null>;
|
||||
count: number;
|
||||
nearestCard: () => number | null;
|
||||
pickNearestCard: () => void;
|
||||
onLive: (index: number | null) => void;
|
||||
layoutKey: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the strip's scrolling: the card under the centre while it moves, the
|
||||
* active card once it settles. Every state update waits for the strip to come
|
||||
* to rest, because updating the active card mid swipe resizes the cards and
|
||||
* reflows the layout on every frame. Attaches again whenever `layoutKey`
|
||||
* changes, since the strip may only now be on screen.
|
||||
*/
|
||||
function useStripScroll({ carouselRef, count, nearestCard, pickNearestCard, onLive, layoutKey }: StripScrollOptions) {
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current;
|
||||
if (!el || count === 0) return;
|
||||
let settleTimer: number | null = null;
|
||||
let frame: number | null = null;
|
||||
const onScroll = () => {
|
||||
if (frame == null) {
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
onLive(nearestCard());
|
||||
});
|
||||
}
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
|
||||
};
|
||||
// Where the browser says when the snap has landed, the card grows right then
|
||||
// instead of a beat later; the timer stays for the ones that do not.
|
||||
const onScrollEnd = () => {
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
pickNearestCard();
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
el.addEventListener('scrollend', onScrollEnd, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
el.removeEventListener('scrollend', onScrollEnd);
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
if (frame != null) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [carouselRef, count, pickNearestCard, nearestCard, onLive, layoutKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The card carousel over the journey map, behind both the phone journey screen and the
|
||||
* narrow desktop and public timeline: the active card is the one nearest the centre
|
||||
@@ -66,6 +121,7 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
mapEntries,
|
||||
onOpenEntry,
|
||||
syncMapOnTap = false,
|
||||
layoutKey,
|
||||
}: JourneyCarouselSyncOptions<E>) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null);
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
@@ -106,39 +162,7 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
});
|
||||
}, [nearestCard, syncMapToCard]);
|
||||
|
||||
// Defer every state update until scrolling settles: updating the active card
|
||||
// mid swipe resizes the cards and reflows the layout on every frame.
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current;
|
||||
if (!el || entries.length === 0) return;
|
||||
let settleTimer: number | null = null;
|
||||
let frame: number | null = null;
|
||||
const onScroll = () => {
|
||||
if (frame == null) {
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
setLiveIndex(nearestCard());
|
||||
});
|
||||
}
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
|
||||
};
|
||||
// Where the browser says when the snap has landed, the card grows right then
|
||||
// instead of a beat later; the timer stays for the ones that do not.
|
||||
const onScrollEnd = () => {
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
pickNearestCard();
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
el.addEventListener('scrollend', onScrollEnd, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
el.removeEventListener('scrollend', onScrollEnd);
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
if (frame != null) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [entries.length, pickNearestCard, nearestCard]);
|
||||
useStripScroll({ carouselRef, count: entries.length, nearestCard, pickNearestCard, onLive: setLiveIndex, layoutKey });
|
||||
|
||||
const scrollCardIntoCenter = useCallback((idx: number) => {
|
||||
cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
height: 'var(--nav-h)',
|
||||
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
|
||||
}} data-desktop-navbar="" className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
|
||||
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
|
||||
the tab pill between them sits in the middle of the bar rather than
|
||||
being centred on top of both (#1983). Both columns keep at least their
|
||||
|
||||
@@ -31,18 +31,10 @@ export function useCreateAction(phone: boolean): CreateAction {
|
||||
const inCollection = useMatch('/collections/:id');
|
||||
|
||||
if (inTrip) {
|
||||
// The "+" is context-aware per active tab: Bookings → reservation,
|
||||
// Transports → transport, Costs → expense. Tabs without a create modal
|
||||
// (lists / files / collab) fall through to adding a place. #1349
|
||||
const id = inTrip.params.id;
|
||||
const id = inTrip.params.id ?? '';
|
||||
const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${id}`) : null;
|
||||
if (tripTab === 'finanzplan')
|
||||
return { label: t('costs.addExpense'), run: () => navigate(`/trips/${id}?create=expense`) };
|
||||
if (tripTab === 'buchungen')
|
||||
return { label: t('reservations.addManual'), run: () => navigate(`/trips/${id}?create=reservation`) };
|
||||
if (tripTab === 'transports')
|
||||
return { label: t('transport.addManual'), run: () => navigate(`/trips/${id}?create=transport`) };
|
||||
return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) };
|
||||
const intent = tripCreateIntent(id, tripTab);
|
||||
return { label: t(intent.labelKey), run: () => navigate(intent.to) };
|
||||
}
|
||||
if (inJourney) {
|
||||
// Context-aware per tab, like the trip's "+": the Gallery holds photos, so
|
||||
@@ -69,6 +61,19 @@ export function useCreateAction(phone: boolean): CreateAction {
|
||||
return { label: t('dashboard.newTrip'), run: () => navigate('/dashboard?create=1') };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the "+" creates inside a trip, by its active tab: Bookings a reservation,
|
||||
* Transports a transport, Costs an expense. Tabs without a create modal (lists,
|
||||
* files, collab) fall through to adding a place (#1349). Pure, so a caller that
|
||||
* knows the tab itself (the trip's own ⌘N, MTripShell) asks with that one.
|
||||
*/
|
||||
export function tripCreateIntent(tripId: string, tab: string | null): { labelKey: string; to: string } {
|
||||
if (tab === 'finanzplan') return { labelKey: 'costs.addExpense', to: `/trips/${tripId}?create=expense` };
|
||||
if (tab === 'buchungen') return { labelKey: 'reservations.addManual', to: `/trips/${tripId}?create=reservation` };
|
||||
if (tab === 'transports') return { labelKey: 'transport.addManual', to: `/trips/${tripId}?create=transport` };
|
||||
return { labelKey: 'places.addPlace', to: `/trips/${tripId}?create=place` };
|
||||
}
|
||||
|
||||
/** Whether a nav item is the current route: the dashboard only on its exact path, the rest by prefix. */
|
||||
export function isNavItemActive(pathname: string, to: string): boolean {
|
||||
return to === '/dashboard' ? pathname === '/dashboard' : pathname.startsWith(to);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// FE-MAP-RESIZE-001 onwards
|
||||
|
||||
const map = vi.hoisted(() => ({
|
||||
container: {} as HTMLElement,
|
||||
invalidateSize: vi.fn(),
|
||||
getContainer: vi.fn(),
|
||||
getCenter: vi.fn(() => ({ lat: 35, lng: 135.7 })),
|
||||
getZoom: vi.fn(() => 12),
|
||||
getSize: vi.fn(() => ({ x: 1376, y: 1032 })),
|
||||
setView: vi.fn(),
|
||||
whenReady: vi.fn((fn: () => void) => fn()),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
}));
|
||||
vi.mock('react-leaflet', () => ({ useMap: () => map }));
|
||||
vi.mock('./lngFrameContext', () => ({ useLngFrame: () => null }));
|
||||
|
||||
import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport';
|
||||
import ContainerResizeWatcher, { MapCameraSnapshot, OpeningFrameFit } from './ContainerResizeWatcher';
|
||||
import type { OpeningFrame } from './openingView';
|
||||
|
||||
const realObserver = globalThis.ResizeObserver;
|
||||
|
||||
describe('ContainerResizeWatcher', () => {
|
||||
afterEach(() => {
|
||||
globalThis.ResizeObserver = realObserver;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('FE-MAP-RESIZE-001: the map measures itself again whenever its box changes size', () => {
|
||||
const element = document.createElement('div');
|
||||
map.getContainer.mockReturnValue(element);
|
||||
let fire: () => void = () => {};
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(cb: () => void) {
|
||||
fire = cb;
|
||||
}
|
||||
observe = observe;
|
||||
unobserve = vi.fn();
|
||||
disconnect = disconnect;
|
||||
} as unknown as typeof ResizeObserver;
|
||||
|
||||
const { unmount } = render(<ContainerResizeWatcher />);
|
||||
expect(observe).toHaveBeenCalledWith(element);
|
||||
fire();
|
||||
expect(map.invalidateSize).toHaveBeenCalledWith();
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MAP-RESIZE-002: the camera snapshot reports where the map looks and stops on unmount', () => {
|
||||
const onChange = vi.fn();
|
||||
const { unmount } = render(<MapCameraSnapshot onChange={onChange} />);
|
||||
expect(onChange).toHaveBeenCalledWith([35, 135.7], 12);
|
||||
expect(map.on).toHaveBeenCalledWith('moveend zoomend', expect.any(Function));
|
||||
unmount();
|
||||
expect(map.off).toHaveBeenCalledWith('moveend zoomend', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpeningFrameFit', () => {
|
||||
// A day across Kyoto from Arashiyama in the west to Ginkaku-ji in the east, wider than tall.
|
||||
const points = [
|
||||
{ lat: 35.0094, lng: 135.6668 },
|
||||
{ lat: 35.005, lng: 135.7649 },
|
||||
{ lat: 35.0037, lng: 135.7788 },
|
||||
{ lat: 35.027, lng: 135.7982 },
|
||||
];
|
||||
const padding = { top: 20, right: 40, bottom: 20, left: 40 };
|
||||
const windowFrame = (): OpeningFrame => {
|
||||
const view = computeMapViewport(points, { tileSize: TILE_SIZE_RASTER, padding, width: 1376, height: 1032 });
|
||||
if (!view) throw new Error('the day has places to frame');
|
||||
return { points, padding, wrapsWorld: false, center: view.center, zoom: view.zoom, width: 1376, height: 1032 };
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it('FE-MAP-RESIZE-004: a map beside the plan, narrower than the window, frames the day again for its own size', () => {
|
||||
const frame = windowFrame();
|
||||
map.getSize.mockReturnValue({ x: 824, y: 1032 });
|
||||
map.getZoom.mockReturnValue(frame.zoom);
|
||||
map.getCenter.mockReturnValue({ lat: frame.center[0], lng: frame.center[1] });
|
||||
render(<OpeningFrameFit frame={frame} />);
|
||||
expect(map.setView).toHaveBeenCalledOnce();
|
||||
const [, zoom, options] = map.setView.mock.calls[0];
|
||||
// Further out than the window's frame, so the whole day fits the narrower map.
|
||||
expect(zoom).toBeLessThan(frame.zoom);
|
||||
expect(options).toEqual({ animate: false });
|
||||
});
|
||||
|
||||
it('FE-MAP-RESIZE-005: a map the size of the window, or one that opened at a kept camera, stays where it is', () => {
|
||||
const frame = windowFrame();
|
||||
map.getSize.mockReturnValue({ x: 1376, y: 1000 });
|
||||
map.getZoom.mockReturnValue(frame.zoom);
|
||||
map.getCenter.mockReturnValue({ lat: frame.center[0], lng: frame.center[1] });
|
||||
const { unmount } = render(<OpeningFrameFit frame={frame} />);
|
||||
expect(map.setView).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
map.getSize.mockReturnValue({ x: 824, y: 1032 });
|
||||
map.getZoom.mockReturnValue(9);
|
||||
render(<OpeningFrameFit frame={frame} />);
|
||||
expect(map.setView).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
|
||||
import { frameLng } from '../../utils/lngFrame';
|
||||
import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport';
|
||||
import { useLngFrame } from './lngFrameContext';
|
||||
import type { OpeningFrame } from './openingView';
|
||||
|
||||
/**
|
||||
* Leaflet measures its container on window resizes only. The touch shell on a
|
||||
* wide window resizes the map without one: the trip's plan pane opens and
|
||||
* closes beside it, and a foldable folds. Without a fresh measure the map kept
|
||||
* its old size, with grey bands or tiles cut off at the old edge.
|
||||
*/
|
||||
export default function ContainerResizeWatcher() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
// Leaflet's default keeps the centre where it was, so what the map framed stays
|
||||
// in the middle of the room it now has.
|
||||
const observer = new ResizeObserver(() => map.invalidateSize());
|
||||
observer.observe(map.getContainer());
|
||||
return () => observer.disconnect();
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reports where the map looks after every move, so a remount can come back to it. */
|
||||
export function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const save = () => {
|
||||
const center = map.getCenter();
|
||||
onChange([center.lat, center.lng], map.getZoom());
|
||||
};
|
||||
map.whenReady(save);
|
||||
map.on('moveend zoomend', save);
|
||||
return () => {
|
||||
map.off('moveend zoomend', save);
|
||||
};
|
||||
}, [map, onChange]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The map opens framed on the trip's places, a frame worked out before the
|
||||
* map exists and so for the window's size. Where the map is much smaller
|
||||
* than the window (the plan beside it on an iPad or an unfolded foldable),
|
||||
* that frame is too close and the day runs off the map. Once the map knows
|
||||
* its size, the frame is worked out again for it. A map that opened anywhere
|
||||
* else (a camera kept from before a remount) is left where it is.
|
||||
*/
|
||||
export function OpeningFrameFit({ frame }: { frame: OpeningFrame }) {
|
||||
const map = useMap();
|
||||
const lngFrame = useLngFrame();
|
||||
const done = useRef(false);
|
||||
useEffect(() => {
|
||||
if (done.current) return;
|
||||
done.current = true;
|
||||
const { x, y } = map.getSize();
|
||||
if (x <= 0 || y <= 0) return;
|
||||
if (Math.abs(x - frame.width) <= frame.width * 0.1 && Math.abs(y - frame.height) <= frame.height * 0.1) return;
|
||||
const at = map.getCenter();
|
||||
const opened =
|
||||
Math.abs(map.getZoom() - frame.zoom) < 0.01 &&
|
||||
Math.abs(at.lat - frame.center[0]) < 1e-6 &&
|
||||
Math.abs(at.lng - frame.center[1]) < 1e-6;
|
||||
if (!opened) return;
|
||||
const view = computeMapViewport(frame.points, {
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
padding: frame.padding,
|
||||
wrapsWorld: frame.wrapsWorld,
|
||||
width: x,
|
||||
height: y,
|
||||
});
|
||||
// A whole zoom level, as Leaflet snaps to them, and the one that fits rather than the nearest.
|
||||
if (view)
|
||||
map.setView([view.center[0], frameLng(lngFrame, view.center[1])], Math.floor(view.zoom), { animate: false });
|
||||
}, [map, lngFrame, frame]);
|
||||
return null;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { render, screen } from '../../../tests/helpers/render'
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { setSizeClass } from '../../../tests/helpers/sizeClass'
|
||||
import { buildPlace, buildReservation } from '../../../tests/helpers/factories'
|
||||
import { AMAP_ROAD, AMAP_SATELLITE, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL } from '../../constants/mapDefaults'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -16,6 +17,8 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
const mapMock = vi.hoisted(() => ({
|
||||
getContainer: vi.fn(() => document.createElement('div')),
|
||||
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
// The map fills the window here, so the opening frame stands (OpeningFrameFit).
|
||||
getSize: vi.fn(() => ({ x: window.innerWidth, y: window.innerHeight })),
|
||||
panTo: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
@@ -204,6 +207,13 @@ vi.mock('../../services/photoService', () => ({
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
// The app shows the touch shell at every size; a test can say it does (undefined: as in a browser).
|
||||
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() }
|
||||
})
|
||||
|
||||
vi.mock('./gcj02Crs', () => ({
|
||||
crsForBasemap: (gcj02: boolean) => gcj02 ? { code: 'TREK:GCJ02' } : undefined,
|
||||
}))
|
||||
@@ -227,6 +237,7 @@ const ORIGINAL_WIDTH = window.innerWidth
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
shell.phone = undefined
|
||||
resetAllStores()
|
||||
clusterMock.markers.clear()
|
||||
clusterMock.asked.length = 0
|
||||
@@ -1296,7 +1307,8 @@ describe('MapView selection panning (#921)', () => {
|
||||
afterEach(() => {
|
||||
delete projectable.project
|
||||
delete projectable.unproject
|
||||
delete projectable.getSize
|
||||
// Back to the map that fills the window (the mock's own size).
|
||||
projectable.getSize = vi.fn(() => ({ x: window.innerWidth, y: window.innerHeight }))
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-090: pans the pin back out once the card measured taller than the allowance', () => {
|
||||
@@ -1574,6 +1586,37 @@ describe('MapView bounds fitting', () => {
|
||||
expect(mapMock.fitBounds.mock.calls[2][1]).toMatchObject({ paddingTopLeft: [40, 20], paddingBottomRight: [40, 20] })
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-094: beside the pane the touch shell opens at the whole zoom level that fits; the desktop keeps its own opening', () => {
|
||||
// A day spread so that its exact fit lies between two levels.
|
||||
const places = [buildMapPlace({ id: 1, lat: 34.9671, lng: 135.7727 }), buildMapPlace({ id: 2, lat: 35.005, lng: 135.7649 })]
|
||||
shell.phone = true
|
||||
setSizeClass('wide')
|
||||
const { unmount } = render(<MapView places={places} />)
|
||||
const paned = Number(screen.getByTestId('map-container').getAttribute('data-zoom'))
|
||||
expect(Number.isInteger(paned)).toBe(true)
|
||||
unmount()
|
||||
|
||||
// The desktop planner hands Leaflet the fitted level, which it rounds to the nearest
|
||||
// as it always has, and fits no second time.
|
||||
shell.phone = false
|
||||
setSizeClass('compact')
|
||||
mapMock.setView.mockClear()
|
||||
render(<MapView places={places} />)
|
||||
const desk = Number(screen.getByTestId('map-container').getAttribute('data-zoom'))
|
||||
expect(Number.isInteger(desk)).toBe(false)
|
||||
expect(mapMock.setView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-093: on an iPad the touch shell frames a day clear of its floating toolbar and corner buttons', () => {
|
||||
shell.phone = true
|
||||
setSizeClass('wide')
|
||||
const places = [buildMapPlace({ id: 1, lat: 35, lng: 135.7 }), buildMapPlace({ id: 2, lat: 35.04, lng: 135.78 })]
|
||||
const { rerender } = render(<MapView places={places} fitKey={1} />)
|
||||
rerender(<MapView places={places} fitKey={2} />)
|
||||
const opts = mapMock.fitBounds.mock.calls[mapMock.fitBounds.mock.calls.length - 1]?.[1]
|
||||
expect(opts).toMatchObject({ paddingTopLeft: [40, 84], paddingBottomRight: [40, 56] })
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-079: the caller padding is read by value, so only new numbers refit the points on screen', () => {
|
||||
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState, useMemo, useCallback, memo, type Component
|
||||
import DOM from 'react-dom'
|
||||
import { categoryIconSvg } from './categoryIconSvg'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
import ContainerResizeWatcher, { MapCameraSnapshot, OpeningFrameFit } from './ContainerResizeWatcher'
|
||||
import { openingView } from './openingView'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
|
||||
@@ -73,7 +75,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
|
||||
import type { ViewportPadding } from '../../utils/mapViewport'
|
||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
|
||||
|
||||
import type { MapViewProps } from './mapViewContract'
|
||||
@@ -244,20 +246,6 @@ function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { cur
|
||||
return null
|
||||
}
|
||||
|
||||
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const save = () => {
|
||||
const center = map.getCenter()
|
||||
onChange([center.lat, center.lng], map.getZoom())
|
||||
}
|
||||
map.whenReady(save)
|
||||
map.on('moveend zoomend', save)
|
||||
return () => { map.off('moveend zoomend', save) }
|
||||
}, [map, onChange])
|
||||
return null
|
||||
}
|
||||
|
||||
interface MapControllerProps {
|
||||
center: [number, number]
|
||||
zoom: number
|
||||
@@ -421,6 +409,7 @@ import { markerPhotoHtml, placePhotoFull, placePhotoUrl } from './placePhoto'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { useIsWide } from '../../hooks/sizeClass'
|
||||
|
||||
// Live-location rendering inside the Leaflet map. Subscribes via the
|
||||
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
|
||||
@@ -683,40 +672,37 @@ export const MapView = memo(function MapView({
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
|
||||
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
||||
const transportRoutes = useTransportRoutes(visibleReservations)
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
// The chrome overlaying the map (side panels, day detail). Kept as a plain box so both the
|
||||
// Leaflet fit options and the opening-camera maths can read the same numbers.
|
||||
// Followed rather than read once: a phone turned sideways and back crosses
|
||||
// the breakpoint twice, and the padding here and the locate button further
|
||||
// down used to stay with whatever the map saw when it mounted.
|
||||
const isMobile = useIsPhone()
|
||||
// The touch shell on an iPad or an unfolded foldable floats its toolbar over the
|
||||
// top of the map and its buttons in the corners, and its pins stand above their
|
||||
// spot: a day framed to the phone's thin margin ran under the toolbar.
|
||||
const isWide = useIsWide()
|
||||
const paddingBox = useMemo((): ViewportPadding => {
|
||||
if (isMobile) return { top: 20, right: 40, bottom: 20, left: 40 }
|
||||
if (isMobile) {
|
||||
const margin = { top: 20, right: 40, bottom: 20, left: 40 }
|
||||
return isWide ? { ...margin, top: 84, bottom: 56 } : margin
|
||||
}
|
||||
return {
|
||||
top: 60,
|
||||
right: rightWidth + 40,
|
||||
bottom: bottomChromePadding({ hasInspector, inspectorHeight, hasDayDetail }),
|
||||
left: leftWidth + 40,
|
||||
}
|
||||
}, [isMobile, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
|
||||
}, [isMobile, isWide, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
|
||||
|
||||
const paddingOpts = useMemo(() => leafletPadding(paddingBox), [paddingBox])
|
||||
|
||||
// Open framed on the places rather than on the caller's default, so a trip in Japan shows
|
||||
// Japan straight away instead of the world view followed by a flight across the planet.
|
||||
// The initializer runs once, at mount — exactly when this should be decided; afterwards the
|
||||
// camera belongs to the user. `framed` is false when no place has coordinates (a new trip),
|
||||
// and then the caller's center/zoom stands.
|
||||
const [initialView] = useState(() => {
|
||||
const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, {
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
padding: paddingBox,
|
||||
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
|
||||
wrapsWorld: lngFrame !== null,
|
||||
})
|
||||
const opening: [number, number] | undefined = framed ? [framed.center[0], frameLng(lngFrame, framed.center[1])] : undefined
|
||||
return { center: opening ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
|
||||
})
|
||||
// Open framed on the places, not on the caller's default: a trip in Japan shows Japan at once instead
|
||||
// of the world and a flight across it. Decided once, at mount; afterwards the camera is the user's.
|
||||
// `framed` is false when no place has coordinates (a new trip), and then the caller's center/zoom stands.
|
||||
const [initialView] = useState(() => openingView(dayPlaces.length > 0 ? dayPlaces : places, {
|
||||
padding: paddingBox, lngFrame, center, zoom, paned: isMobile && isWide,
|
||||
}))
|
||||
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
|
||||
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
|
||||
cameraSnapshotRef.current = { center: cameraCenter, zoom: cameraZoom }
|
||||
@@ -952,6 +938,8 @@ export const MapView = memo(function MapView({
|
||||
{/* First, so a fit or a camera request in the same commit starts from the trip's world copy. */}
|
||||
<LngFrameFollower />
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<ContainerResizeWatcher />
|
||||
{initialView.refit && <OpeningFrameFit frame={initialView.refit} />}
|
||||
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
|
||||
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingBox={paddingBox} />
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { frameLng, type LngFrame } from '../../utils/lngFrame';
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type GeoPointish, type ViewportPadding } from '../../utils/mapViewport';
|
||||
|
||||
/**
|
||||
* Where MapView opens: framed on the places when they have coordinates, else
|
||||
* at the caller's centre and zoom.
|
||||
*
|
||||
* In the touch shell on a wide window (`paned`), where the map is a part of
|
||||
* the window beside the trip's pane: Leaflet snaps to whole zoom levels and
|
||||
* rounds to the nearest, so 14.7 became 15 and the day ran off the map; the
|
||||
* whole level below always fits. `refit` keeps what the frame was worked out
|
||||
* for (the window), so OpeningFrameFit can work it out again for the map's
|
||||
* own size once it has one. Everywhere else (the desktop planner, a phone) the
|
||||
* map opens as it always has: Leaflet's nearest level, and no second fit.
|
||||
*/
|
||||
export function openingView(
|
||||
points: readonly GeoPointish[],
|
||||
options: {
|
||||
padding: ViewportPadding;
|
||||
lngFrame: LngFrame | null;
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
paned?: boolean;
|
||||
}
|
||||
): { center: [number, number]; zoom: number; framed: boolean; refit: OpeningFrame | null } {
|
||||
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
|
||||
const wrapsWorld = options.lngFrame !== null;
|
||||
const fitted = computeMapViewport(points, { tileSize: TILE_SIZE_RASTER, padding: options.padding, wrapsWorld });
|
||||
if (!fitted) return { center: options.center, zoom: options.zoom, framed: false, refit: null };
|
||||
const center: [number, number] = [fitted.center[0], frameLng(options.lngFrame, fitted.center[1])];
|
||||
if (!options.paned) return { center, zoom: fitted.zoom, framed: true, refit: null };
|
||||
const zoom = Math.floor(fitted.zoom);
|
||||
const width = typeof window !== 'undefined' && window.innerWidth > 0 ? window.innerWidth : 1024;
|
||||
const height = typeof window !== 'undefined' && window.innerHeight > 0 ? window.innerHeight : 768;
|
||||
return {
|
||||
center,
|
||||
zoom,
|
||||
framed: true,
|
||||
refit: { points, padding: options.padding, wrapsWorld, center, zoom, width, height },
|
||||
};
|
||||
}
|
||||
|
||||
/** The opening frame as MapView worked it out before the map existed: for the window. */
|
||||
export interface OpeningFrame {
|
||||
points: readonly GeoPointish[];
|
||||
padding: ViewportPadding;
|
||||
wrapsWorld: boolean;
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
/** The size it was worked out for: the window's. */
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore'
|
||||
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
|
||||
import { KIND_LOOK, createdAt, kindOf } from './notificationModel'
|
||||
import { KIND_LOOK, createdAt, kindOf, localizedParams } from './notificationModel'
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: InAppNotification
|
||||
@@ -55,7 +55,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
useNotificationItemActions(notification, onClose)
|
||||
|
||||
const titleText = t(notification.title_key, notification.title_params)
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const bodyText = t(notification.text_key, localizedParams(notification.text_params, locale))
|
||||
const unread = !notification.is_read
|
||||
const answered = notification.response !== null
|
||||
const { positive_text_key: yesKey, negative_text_key: noKey, navigate_text_key: goKey } = notification
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-007
|
||||
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-009
|
||||
*
|
||||
* What a notification's tile shows, how the server's time is read, and how the
|
||||
* list is cut into days under headings in the reader's language.
|
||||
@@ -9,7 +9,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
import NotificationDayList from './NotificationDayList';
|
||||
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf } from './notificationModel';
|
||||
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf, localizedParams } from './notificationModel';
|
||||
|
||||
const note = (id: number, created_at: string, title_key = 'notif.trip_invite.title'): InAppNotification => ({
|
||||
id,
|
||||
@@ -89,3 +89,21 @@ describe('NotificationDayList', () => {
|
||||
expect(screen.getAllByRole('region')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localizedParams', () => {
|
||||
const NOW = new Date(2026, 10, 7, 12);
|
||||
|
||||
it('FE-NOTIF-MODEL-008: a due day sent as YYYY-MM-DD reads as the short date of the language', () => {
|
||||
const params = { todo: 'Exchange the JR Pass', trip: 'Kyoto', due: '2026-11-05' };
|
||||
expect(localizedParams(params, 'de', NOW)).toEqual({ todo: 'Exchange the JR Pass', trip: 'Kyoto', due: '5. Nov.' });
|
||||
expect(localizedParams(params, 'en-US', NOW).due).toBe('Nov 5');
|
||||
expect(localizedParams(params, 'ru', NOW).due).toBe('5 нояб.');
|
||||
// Another year names it.
|
||||
expect(localizedParams({ due: '2027-01-02' }, 'en-US', NOW).due).toBe('Jan 2, 2027');
|
||||
});
|
||||
|
||||
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(undefined, 'de', NOW)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,34 @@ export function createdAt(value: string): Date {
|
||||
|
||||
const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
|
||||
const BARE_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
/**
|
||||
* The parameters of a notification's text as the reader's language writes
|
||||
* them. A day the server sends (a todo's due date) arrives as YYYY-MM-DD, which
|
||||
* would stand as "2026-11-05" in a German or Arabic sentence; it becomes the
|
||||
* short date of the language ("5. Nov.", "٥ نوفمبر"), with the year only when
|
||||
* it is not this one. Everything else passes through as it came.
|
||||
*/
|
||||
export function localizedParams(
|
||||
params: Record<string, string> | null | undefined,
|
||||
locale: string,
|
||||
now: Date = new Date()
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = { ...params };
|
||||
for (const [key, value] of Object.entries(out)) {
|
||||
const day = BARE_DAY.exec(value);
|
||||
if (!day) continue;
|
||||
const date = new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3]));
|
||||
out[key] = new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
|
||||
}).format(date);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heading of the day a notification belongs to, in the reader's language:
|
||||
* today and yesterday by name, the rest of the week by weekday, anything older
|
||||
|
||||
@@ -112,6 +112,46 @@ describe('reservation form fields', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESFORM-017: a hotel booking without its own times shows the ones its stay keeps', () => {
|
||||
// The stay's check-in and check-out live on the accommodation; a booking that
|
||||
// never had them written into its own metadata showed the "15:00" hint as if
|
||||
// it were the saved time.
|
||||
const acc = {
|
||||
id: 9,
|
||||
place_id: 4,
|
||||
start_day_id: 2,
|
||||
end_day_id: 3,
|
||||
check_in: '15:00',
|
||||
check_in_end: '22:00',
|
||||
check_out: '11:00',
|
||||
} as Accommodation;
|
||||
const res = buildReservation({
|
||||
type: 'hotel',
|
||||
accommodation_id: 9,
|
||||
metadata: JSON.stringify({ price: '2184' }),
|
||||
} as Partial<Reservation>);
|
||||
expect(reservationFieldsFrom(res, [acc], [], false)).toMatchObject({
|
||||
meta_check_in_time: '15:00',
|
||||
meta_check_out_time: '11:00',
|
||||
});
|
||||
expect(reservationFieldsFrom(res, [acc], [], true)).toMatchObject({ meta_check_in_end_time: '22:00' });
|
||||
// The booking's own times still win.
|
||||
const own = buildReservation({
|
||||
type: 'hotel',
|
||||
accommodation_id: 9,
|
||||
metadata: JSON.stringify({ check_in_time: '14:00', check_out_time: '10:00' }),
|
||||
} as Partial<Reservation>);
|
||||
expect(reservationFieldsFrom(own, [acc], [], false)).toMatchObject({
|
||||
meta_check_in_time: '14:00',
|
||||
meta_check_out_time: '10:00',
|
||||
});
|
||||
// Nothing anywhere stays empty, so the hint shows as a hint.
|
||||
expect(reservationFieldsFrom(buildReservation({ type: 'hotel' }), [], [], false)).toMatchObject({
|
||||
meta_check_in_time: '',
|
||||
meta_check_out_time: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESFORM-004: an import under review resolves its stay days and keeps its source details', () => {
|
||||
const pf = {
|
||||
title: 'Inn',
|
||||
|
||||
@@ -97,9 +97,13 @@ export function reservationFieldsFrom(
|
||||
url: res.url || '',
|
||||
place_id: res.place_id || '',
|
||||
accommodation_id: res.accommodation_id || '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || '' } : {}),
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
// The stay's own times when the booking carries none: a hotel booked before
|
||||
// the times were written on it, or whose stay was edited from the plan,
|
||||
// keeps them on the accommodation, and the empty field showed the
|
||||
// "15:00" hint as if it were the saved check-in.
|
||||
meta_check_in_time: meta.check_in_time || acc?.check_in || '',
|
||||
...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || acc?.check_in_end || '' } : {}),
|
||||
meta_check_out_time: meta.check_out_time || acc?.check_out || '',
|
||||
hotel_place_id: acc?.place_id || '',
|
||||
hotel_start_day: acc?.start_day_id || '',
|
||||
hotel_end_day: acc?.end_day_id || '',
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
* category, addressed by name, so a category a user called "all" or "done"
|
||||
* can have its own bucket instead of the built-in one.
|
||||
*/
|
||||
type TodoActiveFilter = { kind: 'smart'; id: TodoSmartFilter } | { kind: 'category'; name: string }
|
||||
export type TodoActiveFilter = { kind: 'smart'; id: TodoSmartFilter } | { kind: 'category'; name: string }
|
||||
|
||||
type TodoSort = 'priority' | 'due' | null
|
||||
|
||||
|
||||
@@ -41,4 +41,23 @@ describe('MarkdownText', () => {
|
||||
expect(wrapper.className).toContain('truncate')
|
||||
expect(wrapper.className).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('FE-MDTEXT-007: a clamped row takes its direction from its own text, so a Latin address in Arabic is cut at its end', () => {
|
||||
const { container } = render(<MarkdownText clamp>{'68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto'}</MarkdownText>)
|
||||
const wrapper = container.firstElementChild as HTMLElement
|
||||
expect(wrapper).toHaveAttribute('dir', 'auto')
|
||||
expect(wrapper).toHaveClass('truncate')
|
||||
// No isolate inside: dir="auto" would not see the text through a <bdi>.
|
||||
expect(wrapper.querySelector('bdi')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MDTEXT-008: in a full view each paragraph runs in its own direction, inside its block', () => {
|
||||
const text = ["Kyoto's kitchen: pickles and matcha everything.", 'Second paragraph?'].join('\n\n')
|
||||
const { container } = render(<MarkdownText>{text}</MarkdownText>)
|
||||
expect(container.firstElementChild).not.toHaveAttribute('dir')
|
||||
const paragraphs = container.querySelectorAll('p')
|
||||
expect(paragraphs).toHaveLength(2)
|
||||
paragraphs.forEach(p => expect(p.firstElementChild?.tagName).toBe('BDI'))
|
||||
expect(paragraphs[1].textContent).toBe('Second paragraph?')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { markdownLinkComponents } from './markdownLink'
|
||||
import { isolatedTextBlocks } from './markdownSanitize'
|
||||
|
||||
/**
|
||||
* A clamped caption lives inside a tappable row, so an anchor there would eat
|
||||
@@ -12,6 +13,8 @@ const clampedComponents: Components = {
|
||||
a: ({ children }) => <>{children}</>,
|
||||
}
|
||||
|
||||
const fullComponents: Components = { ...isolatedTextBlocks, ...markdownLinkComponents }
|
||||
|
||||
/**
|
||||
* A description or note, rendered (#2337). The field is Markdown wherever it is
|
||||
* written — the editors ship a formatting bar — so printing it raw anywhere
|
||||
@@ -20,6 +23,9 @@ const clampedComponents: Components = {
|
||||
* `clamp` is the list-row variant: one line high, matching what the desktop
|
||||
* sidebar rows do with the same field. The height cap is inline because the row
|
||||
* itself sets a font size, and a Tailwind `leading-*` would race with it.
|
||||
* The clamped line takes its direction from its own text (dir="auto" on the
|
||||
* row, which a <bdi> inside would hide from it), so a Latin address in an Arabic
|
||||
* row loses its end at the cut, not its beginning.
|
||||
*/
|
||||
export default function MarkdownText({ children, className = '', clamp = false }: {
|
||||
children: string
|
||||
@@ -28,12 +34,13 @@ export default function MarkdownText({ children, className = '', clamp = false }
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
dir={clamp ? 'auto' : undefined}
|
||||
className={`collab-note-md ${clamp ? 'truncate ' : ''}${className}`}
|
||||
style={clamp ? { maxHeight: '1.2em', lineHeight: 1.2 } : undefined}
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={clamp ? clampedComponents : markdownLinkComponents}
|
||||
components={clamp ? clampedComponents : fullComponents}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
|
||||
@@ -70,13 +70,23 @@ describe('Stars', () => {
|
||||
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
|
||||
|
||||
expect(filled()).toBe(1)
|
||||
fireEvent.mouseEnter(screen.getByRole('radio', { name: '4' }))
|
||||
fireEvent.pointerEnter(screen.getByRole('radio', { name: '4' }), { pointerType: 'mouse' })
|
||||
expect(filled()).toBe(4)
|
||||
|
||||
fireEvent.mouseLeave(container.querySelector('[role="radiogroup"]')!)
|
||||
fireEvent.pointerLeave(container.querySelector('[role="radiogroup"]')!, { pointerType: 'mouse' })
|
||||
expect(filled()).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-W4STAR-016: a finger previews nothing, so a tap cannot leave the stars filled', () => {
|
||||
// A tap fires an enter with no leave after it: the preview used to stick,
|
||||
// and an unrated place showed five full stars beside "Not rated yet".
|
||||
const { container } = render(<Stars value={0} onRate={() => {}} />)
|
||||
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
|
||||
|
||||
fireEvent.pointerEnter(screen.getByRole('radio', { name: '5' }), { pointerType: 'touch' })
|
||||
expect(filled()).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-W4STAR-008: focus previews and blur clears the preview', () => {
|
||||
const { container } = render(<Stars value={0} onRate={() => {}} />)
|
||||
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }:
|
||||
role={onRate ? 'radiogroup' : undefined}
|
||||
aria-label={ariaLabel}
|
||||
style={{ display: 'inline-flex', gap: 2, cursor: onRate ? 'pointer' : 'default' }}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map(i => {
|
||||
// Fractional fill for averages: full below, empty above, clipped between.
|
||||
@@ -52,7 +52,11 @@ export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }:
|
||||
role="radio"
|
||||
aria-checked={myRating === i}
|
||||
aria-label={`${i}`}
|
||||
onMouseEnter={() => setHover(i)}
|
||||
// A preview for a pointer that hovers (a mouse, a trackpad, a pen held
|
||||
// above the screen). A finger has no hover: the enter it fires on a
|
||||
// tap has no leave after it, so the preview stuck and an unrated
|
||||
// place showed five full stars beside "Not rated yet".
|
||||
onPointerEnter={e => { if (e.pointerType !== 'touch') setHover(i) }}
|
||||
onFocus={() => setHover(i)}
|
||||
onBlur={() => setHover(null)}
|
||||
onClick={() => onRate(myRating === i ? null : i)}
|
||||
|
||||
@@ -31,6 +31,35 @@ export const sanitizedMarkdownPlugins: NonNullable<Options['rehypePlugins']> = [
|
||||
[rehypeSanitize, schema],
|
||||
]
|
||||
|
||||
/**
|
||||
* A paragraph or heading of user text runs in its own direction: a Latin note
|
||||
* in the Arabic UI keeps its question mark and full stop at its end (and an
|
||||
* Arabic one in an English UI), while the block keeps the alignment of the page
|
||||
* around it. The isolate is inline, so the block itself stays as it was.
|
||||
*/
|
||||
export const isolatedTextBlocks: Components = {
|
||||
p: ({ children }) => (
|
||||
<p>
|
||||
<bdi>{children}</bdi>
|
||||
</p>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<h1>
|
||||
<bdi>{children}</bdi>
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2>
|
||||
<bdi>{children}</bdi>
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3>
|
||||
<bdi>{children}</bdi>
|
||||
</h3>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Links as in markdownLinkComponents (#1629): own tab plus `rel` protection.
|
||||
* The exception is a footnote or heading link, which points inside the text
|
||||
@@ -38,6 +67,7 @@ export const sanitizedMarkdownPlugins: NonNullable<Options['rehypePlugins']> = [
|
||||
* somewhere else.
|
||||
*/
|
||||
export const sanitizedMarkdownComponents: Components = {
|
||||
...isolatedTextBlocks,
|
||||
a: ({ children, href, ...rest }) =>
|
||||
href?.startsWith('#') ? (
|
||||
<a {...rest} href={href}>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { isNativeApp } from '../native/platform';
|
||||
|
||||
/**
|
||||
* The touch shell's size classes, the web's answer to UIKit's compact and
|
||||
* regular widths and to Android's window size classes. They are read off the
|
||||
* window's CSS size, which is the same in the iOS and the Android app (a CSS
|
||||
* pixel is a point there and a dp here), so both apps lay out alike.
|
||||
*
|
||||
* compact every phone, upright or on its side, and the narrow Split View and
|
||||
* Stage Manager windows. The shell as it has always been: the dock at
|
||||
* the bottom, sheets rising from below, one column.
|
||||
* wide from 600pt across and taller than a phone on its side: an iPad in
|
||||
* any orientation or window above a third of the screen, a foldable
|
||||
* unfolded. A navigation rail on the leading edge takes the dock's
|
||||
* place, sheets become centred form sheets, lists get columns.
|
||||
* split from 840pt across, Android's expanded width: room for two panes, so
|
||||
* the trip's plan sits next to its map and settings show their
|
||||
* sections beside the page. An Android foldable unfolded on its side
|
||||
* (about 841dp) has it, an 11" iPad upright (834pt) does not.
|
||||
*
|
||||
* The height bound keeps a phone on its side compact: no phone is taller than
|
||||
* 500pt in landscape, no tablet or unfolded foldable is shorter.
|
||||
*
|
||||
* Only the app lays out for a large screen. In a browser the touch shell is
|
||||
* the phone's, below 768px (mobile/useIsPhone.ts), and stays compact there: an
|
||||
* iPad mini upright in Safari or a narrowed desktop window keeps the dock it
|
||||
* always had.
|
||||
*
|
||||
* The class is one value in JavaScript, and the stylesheet follows it rather
|
||||
* than asking media queries of its own: MobileShell writes it to the shell's
|
||||
* root as data-size, and the `wide:` and `split:` variants (tailwind.config.js)
|
||||
* and the shell's variables (mobile.css) key on that attribute. So the hooks
|
||||
* and the CSS never disagree, and the guard against the soft keyboard below
|
||||
* holds for both.
|
||||
*/
|
||||
export type SizeClass = 'compact' | 'wide' | 'split';
|
||||
|
||||
const WIDE_MIN_WIDTH = 600;
|
||||
const SPLIT_MIN_WIDTH = 840;
|
||||
const MIN_HEIGHT = 501;
|
||||
|
||||
/** The class of a window of this size, in CSS pixels. */
|
||||
export function sizeClassOf(width: number, height: number): SizeClass {
|
||||
if (width < WIDE_MIN_WIDTH || height < MIN_HEIGHT) return 'compact';
|
||||
return width >= SPLIT_MIN_WIDTH ? 'split' : 'wide';
|
||||
}
|
||||
|
||||
/** Input types that bring up no soft keyboard. */
|
||||
const NO_KEYBOARD = new Set([
|
||||
'button',
|
||||
'checkbox',
|
||||
'color',
|
||||
'file',
|
||||
'hidden',
|
||||
'image',
|
||||
'radio',
|
||||
'range',
|
||||
'reset',
|
||||
'submit',
|
||||
]);
|
||||
|
||||
/** Whether the focused element is one the soft keyboard comes up for. */
|
||||
function typing(): boolean {
|
||||
const el = document.activeElement;
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
if (el.isContentEditable || el.tagName === 'TEXTAREA') return true;
|
||||
return el instanceof HTMLInputElement && !NO_KEYBOARD.has(el.type);
|
||||
}
|
||||
|
||||
let seen: { width: number; height: number } | null = null;
|
||||
let pinned: SizeClass | null = null;
|
||||
let listening = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Takes in the window's size, except a height lost to the soft keyboard.
|
||||
*
|
||||
* Android resizes the WebView when the keyboard comes up (adjustResize), so a
|
||||
* tablet on its side keeps about 400px of height while a field has focus. Read
|
||||
* as it is, that made the class compact for as long as one typed: the rail
|
||||
* turned into the dock, form sheets into bottom sheets, and a pane that
|
||||
* remounted on the way took the focused field with it, which closed the
|
||||
* keyboard, which brought the class back, in a loop. A window that only grew
|
||||
* shorter, at the same width, while a field has focus is the keyboard, not a
|
||||
* new window: the height seen before stays. The keyboard going down, a turn or
|
||||
* any other resize is taken as it comes.
|
||||
*/
|
||||
function measure(): boolean {
|
||||
const next = { width: window.innerWidth, height: window.innerHeight };
|
||||
const prev = seen;
|
||||
if (prev && next.width === prev.width && next.height < prev.height && typing()) return false;
|
||||
seen = next;
|
||||
return !prev || prev.width !== next.width || prev.height !== next.height;
|
||||
}
|
||||
|
||||
function follow(): void {
|
||||
if (listening || typeof window === 'undefined') return;
|
||||
listening = true;
|
||||
window.addEventListener('resize', () => {
|
||||
if (measure()) for (const listener of listeners) listener();
|
||||
});
|
||||
}
|
||||
|
||||
function read(): SizeClass {
|
||||
if (pinned) return pinned;
|
||||
if (typeof window === 'undefined' || !isNativeApp()) return 'compact';
|
||||
follow();
|
||||
if (!seen) measure();
|
||||
return seen ? sizeClassOf(seen.width, seen.height) : 'compact';
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
follow();
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
const serverClass = (): SizeClass => 'compact';
|
||||
|
||||
/** Whether the touch shell is laid out for a wide window right now, outside React. */
|
||||
export function isWideViewport(): boolean {
|
||||
return read() !== 'compact';
|
||||
}
|
||||
|
||||
/** The size class. Follows rotation, folding and resizing, not the soft keyboard. */
|
||||
export function useSizeClass(): SizeClass {
|
||||
return useSyncExternalStore(subscribe, read, serverClass);
|
||||
}
|
||||
|
||||
/** True while the window is in the wide size class (split included). */
|
||||
export function useIsWide(): boolean {
|
||||
return useSizeClass() !== 'compact';
|
||||
}
|
||||
|
||||
/** True while the window has room for two panes side by side. */
|
||||
export function useIsSplit(): boolean {
|
||||
return useSizeClass() === 'split';
|
||||
}
|
||||
|
||||
/** Test helper: pins the size class, or with null hands it back to the window. */
|
||||
export function _pinSizeClass(next: SizeClass | null): void {
|
||||
pinned = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Test helper: forgets the window size seen so far. */
|
||||
export function _resetSizeClass(): void {
|
||||
pinned = null;
|
||||
seen = null;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type RefObject, useLayoutEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Grows a textarea with what is written in it, so its last line is never
|
||||
* sliced through the glyphs at the bottom edge the way a fixed `rows` height
|
||||
* cuts a fourth line. `rows` stays the least it shows. It measures again when
|
||||
* the text changes and when the field's width does (a rotation, a fold), since
|
||||
* both decide how many lines the text wraps to.
|
||||
*/
|
||||
export function useAutoGrow(ref: RefObject<HTMLTextAreaElement | null>, value: string): void {
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let width = -1;
|
||||
const fit = () => {
|
||||
width = el.clientWidth;
|
||||
el.style.height = 'auto';
|
||||
// scrollHeight leaves the border out; the box is border-box.
|
||||
const border = el.offsetHeight - el.clientHeight;
|
||||
el.style.height = `${el.scrollHeight + border}px`;
|
||||
};
|
||||
fit();
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (el.clientWidth !== width) fit();
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [ref, value]);
|
||||
}
|
||||
@@ -1307,12 +1307,16 @@ 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; }
|
||||
.dark input, .dark textarea, .dark select {
|
||||
/* 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. */
|
||||
.dark input:not(:where(.m-root *)), .dark textarea:not(:where(.m-root *)), .dark select:not(:where(.m-root *)) {
|
||||
background-color: #1c1c21;
|
||||
color: #f4f4f5;
|
||||
border-color: #27272a;
|
||||
}
|
||||
.dark input::placeholder, .dark textarea::placeholder { color: #71717a; }
|
||||
.dark input:not(:where(.m-root *))::placeholder, .dark textarea:not(:where(.m-root *))::placeholder { color: #71717a; }
|
||||
.dark .bg-emerald-50 { background-color: rgba(16,185,129,0.15) !important; }
|
||||
.dark .bg-red-50 { background-color: rgba(239,68,68,0.15) !important; }
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import '@fontsource/geist-sans/600.css'
|
||||
// several .leaflet-* styles.
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './index.css'
|
||||
import './styles/rtl.css'
|
||||
import './styles/ferratas.css'
|
||||
import './styles/paddle.css'
|
||||
import './styles/walkRoute.css'
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { ReactNode, useRef } from 'react'
|
||||
import { CSSProperties, ReactNode, useRef } from 'react'
|
||||
import { useMatch } from 'react-router'
|
||||
import BottomNav from '../components/Layout/BottomNav'
|
||||
import ErrorBoundary from '../components/shared/ErrorBoundary'
|
||||
import MBottomNav from './components/MBottomNav'
|
||||
import MNavRail from './components/MNavRail'
|
||||
import { useSizeClass } from '../hooks/sizeClass'
|
||||
import MKeyboardHost from './keyboard/MKeyboardHost'
|
||||
import MShellShortcuts from './keyboard/MShellShortcuts'
|
||||
import MToastHost from './components/MToastHost'
|
||||
import MEdgeBack from './components/MEdgeBack'
|
||||
import { useNativeCapabilities } from '../native/capabilities'
|
||||
import { isNativeIos } from '../native/platform'
|
||||
import { useWindowFold } from '../native/windowFold'
|
||||
import './mobile.css'
|
||||
|
||||
interface MobileShellProps {
|
||||
@@ -39,6 +44,17 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
|
||||
const { caps } = useNativeCapabilities()
|
||||
const pageRef = useRef<HTMLDivElement>(null)
|
||||
const edgeBack = isNativeIos() && caps.features.includes('edgeBack')
|
||||
// On a wide window (an iPad, a foldable unfolded) the rail on the leading edge
|
||||
// replaces the dock, and the page column starts after it. The class goes on the
|
||||
// root as data-size: the wide:/split: variants and mobile.css key on it.
|
||||
const sizeClass = useSizeClass()
|
||||
const wide = sizeClass !== 'compact'
|
||||
// A foldable's fold, as the Android app reports it: two-pane screens end their
|
||||
// leading pane there (mobile.css, data-fold).
|
||||
const fold = useWindowFold()
|
||||
const foldStyle = fold
|
||||
? ({ '--m-fold-start': `${fold.start}px`, '--m-fold-end': `${fold.end}px` } as CSSProperties)
|
||||
: undefined
|
||||
|
||||
if (!isPhone) {
|
||||
return (
|
||||
@@ -52,7 +68,7 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
|
||||
return (
|
||||
// min-h-svh, not min-h-dvh: dvh grows while Safari's toolbar retracts, so a
|
||||
// dvh page length would chase the animation it just triggered.
|
||||
<div className="m-root flex min-h-svh flex-col text-m-ink">
|
||||
<div className="m-root m-shell flex min-h-svh flex-col text-m-ink" data-size={sizeClass} data-fold={fold ? 'vertical' : undefined} style={foldStyle}>
|
||||
{/* Background and screen gradient live on their own viewport-sized layer.
|
||||
The shell root is as tall as the document now, and --m-scr is a radial
|
||||
gradient measured against its element box, so on it the light cone would
|
||||
@@ -66,10 +82,23 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
|
||||
layer to be visible, body must not paint over it, which index.css handles
|
||||
for this breakpoint. */}
|
||||
<div aria-hidden className="pointer-events-none fixed inset-0 -z-10 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]" />
|
||||
<div ref={pageRef} className="flex-1">{children}</div>
|
||||
{/* The rail comes before the page in the document, as it does on screen,
|
||||
so a keyboard (Full Keyboard Access on an iPad) reaches the navigation
|
||||
first. It is fixed, so where it sits in the DOM moves nothing. */}
|
||||
{!inTripPlanner && wide && (
|
||||
<ErrorBoundary boundaryId="chrome:m-nav-rail" fallback={null}><MNavRail /></ErrorBoundary>
|
||||
)}
|
||||
<div ref={pageRef} className="flex-1 wide:ps-[var(--m-rail-w)]">{children}</div>
|
||||
<MEdgeBack target={pageRef} enabled={edgeBack} />
|
||||
{!inTripPlanner && <ErrorBoundary boundaryId="chrome:m-bottom-nav" fallback={null}><MBottomNav /></ErrorBoundary>}
|
||||
{!inTripPlanner && !wide && (
|
||||
<ErrorBoundary boundaryId="chrome:m-bottom-nav" fallback={null}><MBottomNav /></ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary boundaryId="chrome:m-toast" fallback={null}><MToastHost /></ErrorBoundary>
|
||||
{/* A hardware keyboard (an iPad's, mostly) gets the shortcuts an iPad app has. */}
|
||||
<ErrorBoundary boundaryId="chrome:m-keyboard" fallback={null}>
|
||||
<MKeyboardHost />
|
||||
<MShellShortcuts />
|
||||
</ErrorBoundary>
|
||||
<div id="m-sheet-root" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { useNavigate, useLocation, matchPath } from 'react-router'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { ChevronRight, MoreHorizontal, Plus, Search, Sparkles, Upload } from 'lucide-react'
|
||||
@@ -9,6 +9,7 @@ import { useNavItems, splitMobileNav } from '../../components/Layout/navItems'
|
||||
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction'
|
||||
import MFab from './MFab'
|
||||
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome'
|
||||
import MDockScrim from './MDockScrim'
|
||||
|
||||
interface NavItem { to: string; label: string; icon: LucideIcon }
|
||||
|
||||
@@ -72,6 +73,14 @@ export default function MBottomNav() {
|
||||
`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
|
||||
active ? 'bg-m-act text-m-actfg' : 'text-m-muted'
|
||||
}`
|
||||
// More stands for whichever of its screens is on show, so it is marked as a
|
||||
// ring: a filled circle beside the filled "+" read as two of the same action.
|
||||
const moreCls = (active: boolean) =>
|
||||
`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
|
||||
active ? 'bg-[color:var(--m-ic)] text-m-ink ring-[1.5px] ring-inset ring-[color:var(--m-act)]' : 'text-m-muted'
|
||||
}`
|
||||
// A map runs to the screen's edge on purpose; everything else fades out under the dock.
|
||||
const overMap = location.pathname.startsWith('/atlas') || matchPath('/journey/:id', location.pathname) !== null
|
||||
|
||||
const renderItem = ({ to, label, icon: Icon }: NavItem) => {
|
||||
const active = isActive(to)
|
||||
@@ -120,7 +129,8 @@ export default function MBottomNav() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="m-dock-vt fixed inset-x-4 z-40 flex h-[62px] items-center rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.8] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
|
||||
{!overMap && <MDockScrim />}
|
||||
<nav className="m-dock-vt fixed inset-x-4 z-40 flex h-[62px] items-center rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-dock)] px-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.4] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-around">{left.map(renderItem)}</div>
|
||||
|
||||
{logoSlot ? (
|
||||
@@ -151,7 +161,7 @@ export default function MBottomNav() {
|
||||
aria-label={t('mobileNav.more')}
|
||||
aria-expanded={moreOpen}
|
||||
aria-current={moreActive ? 'page' : undefined}
|
||||
className={circleCls(moreActive)}
|
||||
className={moreCls(moreActive)}
|
||||
>
|
||||
<MoreHorizontal size={21} strokeWidth={1.9} />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
|
||||
import { edgeFadeClass, useEdgeFade } from './useEdgeFade';
|
||||
|
||||
/**
|
||||
* A row of chips that scrolls sideways once it is wider than its room, and
|
||||
* fades out at an edge with more behind it, the way the trip's day strip does,
|
||||
* so a chip cut by the edge reads as "the row goes on" rather than as a word
|
||||
* cut in half. It shrinks to its room in a row it shares (min-width 0), so a
|
||||
* button beside it stays beside it instead of covering the chips.
|
||||
*/
|
||||
export default function MChipStrip({
|
||||
children,
|
||||
className = '',
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
/** Layout of the row itself: gap, alignment, growth. */
|
||||
className?: string;
|
||||
/** Names the group for a screen reader, where the chips are a set of filters. */
|
||||
label?: string;
|
||||
}) {
|
||||
const [strip, setStrip] = useState<HTMLDivElement | null>(null);
|
||||
const fade = useEdgeFade(strip);
|
||||
return (
|
||||
<div
|
||||
ref={setStrip}
|
||||
role={label ? 'group' : undefined}
|
||||
aria-label={label}
|
||||
className={`flex min-w-0 overflow-x-auto ${edgeFadeClass(fade)} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The screen's own background under a floating dock, fading out upwards. The
|
||||
* dock floats 12px above the screen's foot, and in that gap scrolled content
|
||||
* used to stay sharp: a pill, a name and an avatar peeking out under the bar.
|
||||
* Now the page ends where the dock begins, the way an iOS bar's scroll edge
|
||||
* does.
|
||||
*
|
||||
* The same layer as the shell's background (MobileShell), the colour and the
|
||||
* screen gradient over the whole window, masked down to the strip at the foot,
|
||||
* so the strip is exactly the colour behind it and shows no band. Above the
|
||||
* dock's middle the mask is clear, so nothing that sits above the dock (a stats
|
||||
* card, a carousel) is touched.
|
||||
*/
|
||||
export default function MDockScrim() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="m-dock-scrim pointer-events-none fixed inset-0 z-[39] bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,19 +3,31 @@ import { ReactNode } from 'react'
|
||||
interface MGlassBarProps {
|
||||
/** Pins the bar below the status bar (default). Pass false to embed it in flow. */
|
||||
floating?: boolean
|
||||
/**
|
||||
* The width the screen's content column stops at on a wide window, as the
|
||||
* same class the content uses (`wide:max-w-[1280px]`). The floating bar then
|
||||
* stands in that column with the content's own padding, so their edges line
|
||||
* up at every width.
|
||||
*/
|
||||
column?: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Top bar glass pill: blur 26 / saturate 1.7 on --m-glass with --m-gbr border. */
|
||||
export default function MGlassBar({ floating = true, className = '', children }: MGlassBarProps) {
|
||||
return (
|
||||
export default function MGlassBar({ floating = true, column = '', className = '', children }: MGlassBarProps) {
|
||||
const bar = (
|
||||
<div
|
||||
className={`flex items-center gap-[11px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[14px] py-2 backdrop-blur-[26px] backdrop-saturate-[1.7] shadow-[0_14px_34px_-16px_rgba(0,0,0,.22)] ${
|
||||
floating ? 'fixed inset-x-4 top-[var(--m-safe-top,12px)] z-30' : ''
|
||||
} ${className}`}
|
||||
className={`pointer-events-auto flex items-center gap-[11px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[14px] py-2 backdrop-blur-[26px] backdrop-saturate-[1.7] shadow-[0_14px_34px_-16px_rgba(0,0,0,.22)] ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
if (!floating) return bar
|
||||
// The page column (m-span-x), padded and capped like the content under it.
|
||||
return (
|
||||
<div className="m-span-x pointer-events-none fixed top-[var(--m-safe-top,12px)] z-30">
|
||||
<div className={`px-4 wide:mx-auto wide:px-6 ${column}`}>{bar}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Plus, Search, Settings2, Shield, Sparkles, Upload } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome';
|
||||
import { useNavItems } from '../../components/Layout/navItems';
|
||||
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import MFab from './MFab';
|
||||
import { MRail, MRailButton, MRailSpacer } from './MRail';
|
||||
import { useRailAction } from './railAction';
|
||||
|
||||
interface RailEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
active: boolean;
|
||||
go: () => void;
|
||||
}
|
||||
|
||||
const FOCUS_RING =
|
||||
'outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--m-bg)]';
|
||||
|
||||
/**
|
||||
* The touch shell's navigation on a wide window (hooks/sizeClass.ts): a rail
|
||||
* on the leading edge, where an iPad app keeps its sidebar, in place of the
|
||||
* phone's bottom dock.
|
||||
*
|
||||
* It has the room the dock lacks, so every destination sits in it at once,
|
||||
* with its name under the icon: the dock's own items, the ones the phone keeps
|
||||
* under More, the home assistant, and settings and administration at the foot,
|
||||
* which the phone reaches through the avatar menu.
|
||||
*
|
||||
* Its head is always one 56px circle, so no entry moves when the page changes:
|
||||
* the create action with the dock's context-aware meaning, the screen's own
|
||||
* action where it has one (the vacation calendar's switch), or the dock's
|
||||
* quiet logo where there is nothing to create (settings, administration).
|
||||
*/
|
||||
export default function MNavRail() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const create = useCreateAction(true);
|
||||
const assistant = useAssistantHomeLauncher();
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin');
|
||||
const navItems = useNavItems();
|
||||
const screenAction = useRailAction();
|
||||
|
||||
const isActive = (to: string) => isNavItemActive(location.pathname, to);
|
||||
const main: RailEntry[] = [
|
||||
...navItems.map(({ id, to, label, icon }) => ({
|
||||
id: to,
|
||||
// A few names are too long for a rail in some languages; the rail has its own words for those.
|
||||
label: id === 'collections' ? t('mobileNav.railCollections') : label,
|
||||
icon,
|
||||
active: isActive(to),
|
||||
go: () => void navigate(to),
|
||||
})),
|
||||
...(assistant.available
|
||||
? [{ id: 'assistant', label: t('assistant.launcher.tooltip'), icon: Sparkles, active: false, go: assistant.open }]
|
||||
: []),
|
||||
];
|
||||
const foot: RailEntry[] = [
|
||||
{
|
||||
id: '/settings',
|
||||
label: t('mobileNav.railSettings'),
|
||||
icon: Settings2,
|
||||
active: isActive('/settings'),
|
||||
go: () => void navigate('/settings'),
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
id: '/admin',
|
||||
label: t('mobileNav.railAdmin'),
|
||||
icon: Shield,
|
||||
active: isActive('/admin'),
|
||||
go: () => void navigate('/admin'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const path = location.pathname;
|
||||
const nothingToCreate = path.startsWith('/settings') || path.startsWith('/admin');
|
||||
// /vacay draws its own action (the year/editor switch) into the head, as it
|
||||
// does into the dock's centre on the phone; until it has, the slot stays empty.
|
||||
const screenOwnsHead = path.startsWith('/vacay');
|
||||
const searchFab = path.startsWith('/atlas');
|
||||
|
||||
let head;
|
||||
if (nothingToCreate) {
|
||||
head = (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-14 w-14 items-center justify-center rounded-full bg-[color:var(--m-ic)] opacity-70"
|
||||
>
|
||||
<img src="/icons/icon-dark.svg" alt="" className="block h-6 w-6 opacity-75 dark:hidden" />
|
||||
<img src="/icons/icon-white.svg" alt="" className="hidden h-6 w-6 opacity-75 dark:block" />
|
||||
</span>
|
||||
);
|
||||
} else if (screenOwnsHead) {
|
||||
head = screenAction ? <ScreenActionButton /> : <span aria-hidden="true" className="h-14 w-14" />;
|
||||
} else {
|
||||
head = (
|
||||
<MFab onClick={create.run} ariaLabel={create.label} className={FOCUS_RING}>
|
||||
{searchFab ? (
|
||||
<Search size={22} strokeWidth={2.4} />
|
||||
) : create.upload ? (
|
||||
<Upload size={21} strokeWidth={2.4} />
|
||||
) : (
|
||||
<Plus size={24} strokeWidth={2.4} />
|
||||
)}
|
||||
</MFab>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MRail label={t('mobileNav.navigation')} fixed head={head}>
|
||||
{main.map((entry) => (
|
||||
<RailEntryButton key={entry.id} {...entry} />
|
||||
))}
|
||||
<MRailSpacer />
|
||||
<div aria-hidden="true" className="my-1 h-px w-8 flex-none bg-[color:var(--m-rowbr)]" />
|
||||
{foot.map((entry) => (
|
||||
<RailEntryButton key={entry.id} {...entry} />
|
||||
))}
|
||||
</MRail>
|
||||
);
|
||||
}
|
||||
|
||||
/** The screen's own action, drawn like the dock's circle: filled, or a ring while it is on. */
|
||||
function ScreenActionButton() {
|
||||
const action = useRailAction();
|
||||
if (!action) return null;
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.run}
|
||||
aria-label={action.label}
|
||||
className={`flex h-14 w-14 flex-none items-center justify-center rounded-full transition-[background,color] duration-300 ease-in-out ${FOCUS_RING} ${
|
||||
action.pressed
|
||||
? 'border-2 border-[color:var(--m-act)] bg-[color:var(--m-sheetop)] text-m-ink'
|
||||
: 'bg-m-act text-m-actfg shadow-[0_8px_20px_-6px_rgba(0,0,0,.4)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={24} strokeWidth={2.3} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RailEntryButton({ label, icon: Icon, active, go }: RailEntry) {
|
||||
return (
|
||||
<MRailButton label={label} active={active} onClick={go}>
|
||||
<Icon size={20} strokeWidth={active ? 2.1 : 1.9} />
|
||||
</MRailButton>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export default function MPullIndicator({ state, label }: { state: PullState; lab
|
||||
aria-hidden={!state.refreshing}
|
||||
role={state.refreshing ? 'status' : undefined}
|
||||
aria-label={state.refreshing ? label : undefined}
|
||||
className="pointer-events-none fixed inset-x-0 z-30 flex justify-center transition-opacity duration-200"
|
||||
className="m-span-x pointer-events-none fixed z-30 flex justify-center transition-opacity duration-200"
|
||||
style={{ top: 'calc(var(--m-safe-top, 12px) + 70px)', opacity: visible ? Math.max(0.35, progress) : 0 }}
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] text-m-ink shadow-sm backdrop-blur-xl">
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* The rail on the leading edge that takes the dock's place on a wide window
|
||||
* (hooks/sizeClass.ts): the app's sections (MNavRail) or a trip's (MTripRail).
|
||||
* It sits inside the strip --m-rail-w reserves, so the page column and every
|
||||
* fixed bar start right after it.
|
||||
*
|
||||
* 80px wide, a navigation rail's width on Android, so a section's name in its
|
||||
* own words has room. Its material is the sheets' near-opaque glass rather than
|
||||
* the dock's: on a map a saturated country used to show through it in bands
|
||||
* behind the names.
|
||||
*
|
||||
* `head` is the action at its top (the "+" and its stand-ins). It stays put
|
||||
* while the entries under it scroll, which they do on a short window.
|
||||
*/
|
||||
export function MRail({
|
||||
label,
|
||||
fixed = false,
|
||||
head,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
fixed?: boolean;
|
||||
head?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
useEvenLines(listRef);
|
||||
return (
|
||||
<nav
|
||||
aria-label={label}
|
||||
className={`m-dock-vt ${fixed ? 'fixed' : 'absolute'} bottom-[calc(env(safe-area-inset-bottom,0px)+12px)] start-[calc(var(--m-rail-w)-88px)] top-[var(--m-safe-top,12px)] z-40 flex w-[80px] flex-col items-center rounded-[30px] border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] py-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px]`}
|
||||
>
|
||||
{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]"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every entry of a rail takes the same height: two lines for its name as soon
|
||||
* as one name needs them, one line otherwise. Names wrap where their language
|
||||
* needs, never at a smaller size, and the rail's rhythm does not change from
|
||||
* entry to entry or from language to language.
|
||||
*
|
||||
* What it settles on is written to the list's own style (--m-rail-lines), a
|
||||
* fact about the laid-out names rather than React state. It is measured again
|
||||
* whenever a name changes height (the user's text size, a font arriving).
|
||||
*/
|
||||
function useEvenLines(listRef: RefObject<HTMLDivElement | null>) {
|
||||
useLayoutEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const names = () => Array.from(list.querySelectorAll<HTMLElement>('[data-rail-name]'));
|
||||
const measure = () => {
|
||||
list.style.setProperty('--m-rail-lines', '1');
|
||||
const two = names().some((el) => {
|
||||
const line = parseFloat(getComputedStyle(el).lineHeight) || 0;
|
||||
return line > 0 && el.scrollHeight > line * 1.5;
|
||||
});
|
||||
list.style.setProperty('--m-rail-lines', two ? '2' : '1');
|
||||
};
|
||||
measure();
|
||||
let cancelled = false;
|
||||
void document.fonts?.ready.then(() => {
|
||||
if (!cancelled) measure();
|
||||
});
|
||||
// A name that changes height (the text size setting) is measured again. The
|
||||
// measure itself settles at once, so it cannot keep the observer going.
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
|
||||
for (const el of names()) observer?.observe(el);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
observer?.disconnect();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Pushes the entries after it to the foot of the rail. */
|
||||
export function MRailSpacer() {
|
||||
return <div className="flex-1" />;
|
||||
}
|
||||
|
||||
interface MRailButtonProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
/** The icon, drawn at 20px. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* One destination: the icon on the dock's 40px circle, filled while it is the
|
||||
* current one, its name underneath at the caption size. The name is never cut
|
||||
* 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) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<span
|
||||
data-rail-name=""
|
||||
className={`line-clamp-2 min-h-[calc(1.2em*var(--m-rail-lines,1))] w-full text-center font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] leading-[1.2] [hyphens:auto] [overflow-wrap:anywhere] [text-wrap:balance] ${
|
||||
active ? 'font-bold text-m-ink' : 'font-semibold text-m-muted'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface MSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface MSectionRowProps {
|
||||
section: MSection;
|
||||
active: boolean;
|
||||
/** Marks the row as the page on show, for the list that stands beside the page. */
|
||||
current?: boolean;
|
||||
onPick: () => void;
|
||||
}
|
||||
|
||||
/** One section of a settings-like screen, in its dropdown or in the list beside the page. */
|
||||
export function MSectionRow({ section, active, current = false, onPick }: MSectionRowProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
aria-current={current ? 'page' : undefined}
|
||||
className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] ${
|
||||
active ? 'bg-[color:var(--m-ic)]' : 'hover:bg-[color:var(--m-ic)]'
|
||||
}`}
|
||||
>
|
||||
<section.icon size={15} strokeWidth={2} className="flex-none" />
|
||||
<span className="min-w-0 flex-1 truncate">{section.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame of a settings-like screen (settings, administration), one rule for
|
||||
* both: the page column on a phone, a centred column on a wide window, and from
|
||||
* the split size class the list of sections beside a page that runs to the same
|
||||
* width on both screens.
|
||||
*/
|
||||
export const SECTION_SCREEN =
|
||||
'px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[var(--m-safe-top,12px)] wide:mx-auto wide:max-w-[1040px] wide:px-6 split:grid split:max-w-[1240px] split:grid-cols-[260px_minmax(0,1fr)] split:items-start split:gap-8';
|
||||
|
||||
interface MSectionNavProps {
|
||||
title: string;
|
||||
backLabel: string;
|
||||
onBack: () => void;
|
||||
sections: MSection[];
|
||||
activeId: string;
|
||||
onPick: (id: string) => void;
|
||||
/**
|
||||
* An action of the screen (admin: a new user), on a line of its own under the
|
||||
* title, so a long title in a long language keeps the whole width.
|
||||
*/
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections of a settings-like screen (settings, administration) as a list
|
||||
* of their own beside the page, the way Settings on an iPad shows them, once
|
||||
* the window has room for two panes. It stays in view while the page scrolls.
|
||||
* Narrower windows keep the dropdown in the screen's header.
|
||||
*/
|
||||
export default function MSectionNav({
|
||||
title,
|
||||
backLabel,
|
||||
onBack,
|
||||
sections,
|
||||
activeId,
|
||||
onPick,
|
||||
action,
|
||||
}: MSectionNavProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label={title}
|
||||
className="sticky top-[var(--m-safe-top,12px)] flex max-h-[calc(100dvh-var(--m-safe-top,12px)-16px)] flex-col gap-1 overflow-y-auto rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-[6px] shadow-[0_16px_40px_-24px_rgba(0,0,0,.3)] [scrollbar-width:none]"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 p-[4px]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={backLabel}
|
||||
onClick={onBack}
|
||||
className="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full text-m-ink outline-none hover:bg-[color:var(--m-ic)] focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)]"
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={2.2} className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate text-subtitle font-bold text-m-ink">{title}</span>
|
||||
</div>
|
||||
{action && <div className="mb-1 px-[4px]">{action}</div>}
|
||||
{sections.map((section) => (
|
||||
<MSectionRow
|
||||
key={section.id}
|
||||
section={section}
|
||||
active={section.id === activeId}
|
||||
current={section.id === activeId}
|
||||
onPick={() => onPick(section.id)}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -17,11 +17,17 @@ interface MSheetProps {
|
||||
/** card = centred floating card, bottom = docked panel above the nav, drawer = left drawer */
|
||||
variant?: MSheetVariant
|
||||
/**
|
||||
* glass = translucent sheet (--m-sheet), bar-glass = lighter bar glass
|
||||
* (--m-glass, default for the bottom variant), opaque = solid (--m-sheetop)
|
||||
* @deprecated Every sheet stands on the one opaque surface now (--m-sheetop),
|
||||
* whatever is asked for here; see SURFACE. Kept so the callers that still
|
||||
* name a material compile.
|
||||
*/
|
||||
material?: MSheetMaterial
|
||||
/** Transparent scrim (the "Mehr" sheet sits on the UI without dimming it) */
|
||||
/**
|
||||
* Transparent scrim (the "Mehr" sheet sits on the UI without dimming it). Not
|
||||
* on a window as short as a phone on its side: there the sheet takes most of
|
||||
* the height, and the strips of page left above and below it read as part of
|
||||
* it unless they are dimmed.
|
||||
*/
|
||||
dimTransparent?: boolean
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
@@ -32,35 +38,122 @@ interface MSheetProps {
|
||||
* throwing away a state that differs from the one at the first touch.
|
||||
*/
|
||||
discardGuard?: unknown
|
||||
/** Width as a centred form sheet on a wide window (default regular). */
|
||||
wideWidth?: MSheetWidth
|
||||
/** Where it stands on a wide window (default centre). */
|
||||
wideAnchor?: MSheetWideAnchor
|
||||
/**
|
||||
* A form: FormSheetHeader, FormSheetBody and FormSheetFooter (PlSheetChrome)
|
||||
* as its three children. On a window as short as a phone on its side it
|
||||
* fills the height instead of floating as a card a third of the screen
|
||||
* tall, and its actions move up beside the title (mobile.css, m-form-sheet),
|
||||
* so the fields get every line in between.
|
||||
*/
|
||||
form?: boolean
|
||||
}
|
||||
|
||||
const EXIT_MS = 300
|
||||
|
||||
const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold text-white' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
|
||||
|
||||
const POSITION: Record<MSheetVariant, string> = {
|
||||
card: 'absolute inset-x-[14px] top-1/2 -translate-y-1/2',
|
||||
bottom: 'absolute inset-x-4 bottom-[calc(var(--bottom-nav-h,84px)+16px)]',
|
||||
drawer: 'absolute left-0 top-0 bottom-0 w-[78%] max-w-[320px]',
|
||||
/**
|
||||
* How wide the sheet gets on a wide window, where it is a centred form sheet
|
||||
* rather than a panel across the screen: compact for a short list of actions,
|
||||
* regular for a form, large for a sheet that holds a page of its own.
|
||||
*/
|
||||
type MSheetWidth = 'compact' | 'regular' | 'large'
|
||||
const WIDE_WIDTH: Record<MSheetWidth, string> = {
|
||||
compact: 'wide:w-[min(320px,100%)]',
|
||||
regular: 'wide:w-[min(540px,100%)]',
|
||||
large: 'wide:w-[min(720px,100%)]',
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a sheet stands on a wide window. centre: a form sheet. rail-foot: a
|
||||
* popover beside the foot of the navigation rail, for a menu opened from there
|
||||
* (the trip's More), the way iPadOS shows a menu next to the button that
|
||||
* opened it rather than in the middle of the screen.
|
||||
*/
|
||||
type MSheetWideAnchor = 'center' | 'rail-foot'
|
||||
|
||||
/*
|
||||
* The region a sheet may take: a box over the screen that ignores the pointer
|
||||
* (a tap on it reaches the scrim) and lays the sheet out inside itself, so a
|
||||
* tall sheet is held by the region's edges instead of running past them.
|
||||
*
|
||||
* On a phone the card stands between the status bar and the dock, and the
|
||||
* docked sheet just above the dock; neither lies across the dock.
|
||||
*/
|
||||
const REGION: Record<MSheetVariant, string> = {
|
||||
card: 'inset-x-[14px] top-[var(--m-safe-top,12px)] bottom-[calc(var(--bottom-nav-h,84px)+12px)] justify-center',
|
||||
// On a phone on its side the gap it keeps at the top would leave it a strip
|
||||
// of a few rows, so there it may rise nearly to the top.
|
||||
bottom:
|
||||
'inset-x-4 top-[calc(var(--m-safe-top,12px)+56px)] bottom-[calc(var(--bottom-nav-h,84px)+16px)] justify-end short:top-[calc(var(--m-safe-top,12px)+8px)]',
|
||||
drawer: 'start-0 top-0 bottom-0 w-[78%] max-w-[320px]',
|
||||
}
|
||||
|
||||
// On a wide window (hooks/sizeClass.ts) a docked or floating sheet becomes
|
||||
// what iPadOS shows there: a form sheet over the dimmed page, centred in the
|
||||
// content beside the navigation rail (never over the rail), as wide as its
|
||||
// content needs and as tall as the window allows. The region starts after the
|
||||
// rail on the leading edge, so it needs no physical side.
|
||||
const WIDE_REGION: Record<MSheetWideAnchor, string> = {
|
||||
center:
|
||||
'wide:inset-0 wide:ps-[calc(var(--m-rail-w)+24px)] wide:pe-6 wide:pt-[calc(var(--m-safe-top,12px)+12px)] wide:pb-[calc(env(safe-area-inset-bottom,0px)+24px)] wide:items-center wide:justify-center',
|
||||
'rail-foot':
|
||||
'wide:inset-0 wide:ps-[calc(var(--m-rail-w)+6px)] wide:pt-[calc(var(--m-safe-top,12px)+12px)] wide:pb-[calc(env(safe-area-inset-bottom,0px)+12px)] wide:items-start wide:justify-end',
|
||||
}
|
||||
|
||||
// A form on a window as short as a phone on its side: a sheet the window's
|
||||
// full height, over the dock, as wide as a form reads well and no wider.
|
||||
const SHORT_FORM_REGION =
|
||||
'short:inset-x-4 short:top-[max(env(safe-area-inset-top,0px),8px)] short:bottom-0 short:items-center short:justify-stretch'
|
||||
const SHORT_FORM_BOX = 'short:max-w-[640px] short:flex-1'
|
||||
|
||||
/** The region the sheet stands in, on a phone and on a wide window. */
|
||||
function regionClass(variant: MSheetVariant, anchor: MSheetWideAnchor = 'center', form = false): string {
|
||||
const base = 'pointer-events-none absolute flex flex-col'
|
||||
if (variant === 'drawer') return `${base} ${REGION.drawer}`
|
||||
return `${base} ${REGION[variant]} ${WIDE_REGION[anchor]}${form ? ` ${SHORT_FORM_REGION}` : ''}`
|
||||
}
|
||||
|
||||
/** The sheet's own box inside the region: its width, and the arrow when it has one. */
|
||||
function boxClass(variant: MSheetVariant, width: MSheetWidth = 'regular', form = false): string {
|
||||
const base = 'pointer-events-auto relative flex min-h-0 w-full flex-col'
|
||||
if (variant === 'drawer') return `${base} h-full`
|
||||
return `${base} max-h-full ${WIDE_WIDTH[width]}${form ? ` ${SHORT_FORM_BOX}` : ''}`
|
||||
}
|
||||
|
||||
/** The popover's arrow, pointing at the More button at the foot of the rail (wide windows only). */
|
||||
function PopoverArrow({ variant, anchor }: { variant: MSheetVariant; anchor?: MSheetWideAnchor }) {
|
||||
if (anchor !== 'rail-foot' || variant === 'drawer') return null
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -start-[10px] bottom-[33px] hidden h-[18px] w-[10px] bg-[color:var(--m-sheetop)] [clip-path:polygon(100%_0,0_50%,100%_100%)] rtl:-scale-x-100 wide:block"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// The corner radius goes through --m-sheet-r, so mobile.css can keep it on the
|
||||
// focused panel, where the app-wide focus ring would square it off to 4px.
|
||||
const SHAPE: Record<MSheetVariant, string> = {
|
||||
card: 'rounded-[28px] shadow-[0_30px_80px_-24px_rgba(0,0,0,.6)] max-h-[calc(100dvh-var(--m-safe-top,12px)-96px)]',
|
||||
bottom: 'rounded-[26px] shadow-[0_-8px_40px_-14px_rgba(0,0,0,.45)] max-h-[calc(100dvh-var(--bottom-nav-h,84px)-var(--m-safe-top,12px)-72px)]',
|
||||
drawer: 'h-full shadow-[0_0_60px_-10px_rgba(0,0,0,.5)]',
|
||||
card: '[--m-sheet-r:28px] max-h-full shadow-[0_30px_80px_-24px_rgba(0,0,0,.6)]',
|
||||
bottom:
|
||||
'[--m-sheet-r:26px] max-h-full shadow-[0_-8px_40px_-14px_rgba(0,0,0,.45)] wide:[--m-sheet-r:28px] wide:shadow-[0_30px_80px_-24px_rgba(0,0,0,.6)]',
|
||||
drawer: '[--m-sheet-r:0px] h-full shadow-[0_0_60px_-10px_rgba(0,0,0,.5)]',
|
||||
}
|
||||
|
||||
// Deliberate normalisation: the demo borders a few opaque sheets (journey
|
||||
// create/entry/settings, drawer) with --rowbr instead of --shbr; both hairlines
|
||||
// are near-identical on the solid sheet surface, so every material keeps one
|
||||
// border token here.
|
||||
const MATERIAL: Record<MSheetMaterial, string> = {
|
||||
glass:
|
||||
'bg-[color:var(--m-sheet)] backdrop-blur-[40px] backdrop-saturate-[1.8] border border-[color:var(--m-shbr)]',
|
||||
'bar-glass':
|
||||
'bg-[color:var(--m-glass)] backdrop-blur-[30px] backdrop-saturate-[1.8] border border-[color:var(--m-gbr)]',
|
||||
opaque: 'bg-[color:var(--m-sheetop)] border border-[color:var(--m-shbr)]',
|
||||
}
|
||||
// The full-height form on a short window: square at the foot, where it meets
|
||||
// the edge of the screen, and no border there.
|
||||
const SHORT_FORM_SHAPE = 'm-form-sheet short:flex-1 short:border-b-0 short:[--m-sheet-r:28px_28px_0_0]'
|
||||
|
||||
// Every sheet stands on the one opaque surface. A translucent one let the page
|
||||
// behind read through as sharp ghost text: the frosting it counted on is
|
||||
// dropped whenever an ancestor animates its opacity (the scrim fading in),
|
||||
// which WebKit and Chromium both do, so the fields floated over the list behind.
|
||||
const SURFACE = 'bg-[color:var(--m-sheetop)] border border-[color:var(--m-shbr)]'
|
||||
|
||||
// The docked sheet rises from below the screen edge like a native sheet; the
|
||||
// card pops in place like an alert; the drawer slides from the side.
|
||||
@@ -81,12 +174,14 @@ export default function MSheet({
|
||||
open,
|
||||
onClose,
|
||||
variant = 'card',
|
||||
material,
|
||||
dimTransparent = false,
|
||||
ariaLabel,
|
||||
className = '',
|
||||
children,
|
||||
discardGuard,
|
||||
wideWidth,
|
||||
wideAnchor,
|
||||
form = false,
|
||||
}: MSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const { asking, markStart, requestClose, wouldAsk, keepEditing, discard, reset } = useDiscardGuard(discardGuard, onClose)
|
||||
@@ -170,66 +265,63 @@ export default function MSheet({
|
||||
const exitClass = EXIT[variant]
|
||||
const animation = open ? (entered ? '' : enterClass) : flung ? '' : exitClass
|
||||
|
||||
// One material for every sheet, docked or floating, so they all read the same.
|
||||
const resolvedMaterial = material ?? 'glass'
|
||||
|
||||
return createPortal(
|
||||
// m-root on the overlay so the --m-* tokens resolve even when the portal
|
||||
// has to fall back to document.body (sheet mounted in the same commit as
|
||||
// the shell).
|
||||
// A transparent scrim skips the overlay's opacity animation: an animating
|
||||
// opacity<1 ancestor becomes a backdrop-root that cancels the sheet's own
|
||||
// backdrop-blur (the "liquid glass" frost). The sheet still slides via m-sheet-in.
|
||||
<div
|
||||
className={`m-root fixed inset-0 z-[60] ${dimTransparent ? 'bg-transparent' : `bg-[color:var(--m-dim)] ${open ? 'm-fade-in' : 'm-fade-out'}`}`}
|
||||
className={`m-root fixed inset-0 z-[60] ${dimTransparent ? 'bg-transparent short:bg-[color:var(--m-dim)]' : 'bg-[color:var(--m-dim)]'} ${open ? 'm-fade-in' : 'm-fade-out'}`}
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) requestClose()
|
||||
}}
|
||||
>
|
||||
<div className={POSITION[variant]}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
data-m-sheet={open ? 'open' : 'closing'}
|
||||
tabIndex={-1}
|
||||
className={`relative flex flex-col overflow-hidden outline-none text-m-ink ${SHAPE[variant]} ${MATERIAL[resolvedMaterial]} ${animation} ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDownCapture={markStart}
|
||||
onPointerDownCapture={markStart}
|
||||
onAnimationEnd={(e) => {
|
||||
if (e.target === e.currentTarget && open) setEntered(true)
|
||||
}}
|
||||
>
|
||||
{variant === 'bottom' && (
|
||||
// Invisible grab strip along the top edge — the design shows no
|
||||
// handle, the panel starts directly with its content. The content
|
||||
// pulls the sheet too, once it is scrolled to its top.
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-[1] h-6 cursor-grab touch-none active:cursor-grabbing"
|
||||
{...sheetDrag.strip}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{asking && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-[color:var(--m-dim)] p-5 backdrop-blur-[2px]">
|
||||
<div role="alertdialog" aria-label={t('common.unsavedTitle')} className="w-full max-w-[320px] rounded-[22px] border border-[color:var(--m-shbr)] bg-[color:var(--m-sheetop)] p-5">
|
||||
<div className="text-subtitle font-semibold text-m-ink">{t('common.unsavedTitle')}</div>
|
||||
<p className="m-0 mt-1.5 text-body text-m-muted">{t('common.unsavedMessage')}</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" autoFocus onClick={keepEditing} className="rounded-full border border-[color:var(--m-shbr)] px-4 py-2 text-body font-semibold text-m-ink">
|
||||
{t('common.keepEditing')}
|
||||
</button>
|
||||
<button type="button" onClick={discard} className={DISCARD_BUTTON}>
|
||||
{t('common.discard')}
|
||||
</button>
|
||||
<div className={regionClass(variant, wideAnchor, form)}>
|
||||
<div className={boxClass(variant, wideWidth, form)}>
|
||||
<PopoverArrow variant={variant} anchor={wideAnchor} />
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
data-m-sheet={open ? 'open' : 'closing'}
|
||||
tabIndex={-1}
|
||||
className={`relative flex min-h-0 flex-col overflow-hidden rounded-[var(--m-sheet-r)] text-m-ink outline-none ${SHAPE[variant]}${form ? ` ${SHORT_FORM_SHAPE}` : ''} ${SURFACE} ${animation} ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDownCapture={markStart}
|
||||
onPointerDownCapture={markStart}
|
||||
onAnimationEnd={(e) => {
|
||||
if (e.target === e.currentTarget && open) setEntered(true)
|
||||
}}
|
||||
>
|
||||
{variant === 'bottom' && (
|
||||
// Invisible grab strip along the top edge — the design shows no
|
||||
// handle, the panel starts directly with its content. The content
|
||||
// pulls the sheet too, once it is scrolled to its top.
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-[1] h-6 cursor-grab touch-none active:cursor-grabbing"
|
||||
{...sheetDrag.strip}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{asking && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-[color:var(--m-dim)] p-5 backdrop-blur-[2px]">
|
||||
<div role="alertdialog" aria-label={t('common.unsavedTitle')} className="w-full max-w-[320px] rounded-[22px] border border-[color:var(--m-shbr)] bg-[color:var(--m-sheetop)] p-5">
|
||||
<div className="text-subtitle font-semibold text-m-ink">{t('common.unsavedTitle')}</div>
|
||||
<p className="m-0 mt-1.5 text-body text-m-muted">{t('common.unsavedMessage')}</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" autoFocus onClick={keepEditing} className="rounded-full border border-[color:var(--m-shbr)] px-4 py-2 text-body font-semibold text-m-ink">
|
||||
{t('common.keepEditing')}
|
||||
</button>
|
||||
<button type="button" onClick={discard} className={DISCARD_BUTTON}>
|
||||
{t('common.discard')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function MToastHost() {
|
||||
if (toasts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-[calc(var(--bottom-nav-h,84px)+16px)] z-[90] flex flex-col-reverse items-center gap-2 px-6">
|
||||
<div className="m-span-x pointer-events-none fixed bottom-[calc(var(--bottom-nav-h,84px)+16px)] z-[90] flex flex-col-reverse items-center gap-2 px-6">
|
||||
{toasts.map((toast) => (
|
||||
<MToastPill key={toast.id} toast={toast} onDismiss={dismissToast} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* A screen's own action at the head of the navigation rail, in the place the
|
||||
* "+" holds elsewhere: the vacation calendar's switch between the year and the
|
||||
* editor. On the phone the same screen draws it into the dock's centre slot.
|
||||
*/
|
||||
export interface RailAction {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** On: drawn as a ring rather than filled (the editor is open). */
|
||||
pressed?: boolean;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
const useRailActionStore = create<{ action: RailAction | null }>(() => ({ action: null }));
|
||||
|
||||
/** The action the screen on show has put at the head of the rail, if any. */
|
||||
export function useRailAction(): RailAction | null {
|
||||
return useRailActionStore((s) => s.action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the screen's action at the head of the rail while it is mounted; null
|
||||
* takes it away. Pressing it always runs the newest handler, and the rail only
|
||||
* redraws when what it shows changes.
|
||||
*/
|
||||
export function useScreenRailAction(action: RailAction | null): void {
|
||||
const run = useRef(action?.run);
|
||||
useLayoutEffect(() => {
|
||||
run.current = action?.run;
|
||||
});
|
||||
const shown = action !== null;
|
||||
const label = action?.label ?? '';
|
||||
const icon = action?.icon;
|
||||
const pressed = action?.pressed ?? false;
|
||||
useEffect(() => {
|
||||
if (!shown || !icon) return;
|
||||
useRailActionStore.setState({ action: { label, icon, pressed, run: () => run.current?.() } });
|
||||
return () => useRailActionStore.setState({ action: null });
|
||||
}, [shown, label, icon, pressed]);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface EdgeFade {
|
||||
/** Content is scrolled past the leading (or top) edge. */
|
||||
start: boolean;
|
||||
/** More content waits beyond the trailing (or bottom) edge. */
|
||||
end: boolean;
|
||||
}
|
||||
|
||||
/** Which way a scroller runs: a strip of chips sideways, a list or a card down. */
|
||||
export type FadeAxis = 'x' | 'y';
|
||||
|
||||
const NONE: EdgeFade = { start: false, end: false };
|
||||
|
||||
/**
|
||||
* Which ends of a scroller have more behind them, so it can fade out there
|
||||
* (m-fade-s / m-fade-e, m-fade-t / m-fade-b in mobile.css) instead of cutting a
|
||||
* chip or a row in half with nothing to say the content goes on. Follows
|
||||
* scrolling, every change of size (a rotation, a fold, the pane beside the map
|
||||
* opening) and every change of what it holds (a day switched, a message in).
|
||||
*/
|
||||
export function useEdgeFade(el: HTMLElement | null, axis: FadeAxis = 'x'): EdgeFade {
|
||||
const [fade, setFade] = useState<EdgeFade>(NONE);
|
||||
useEffect(() => {
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
// scrollLeft runs from 0 towards the negative in a right-to-left strip.
|
||||
const scrolled = Math.abs(axis === 'x' ? el.scrollLeft : el.scrollTop);
|
||||
const room = axis === 'x' ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
|
||||
const next = { start: scrolled > 1, end: room - scrolled > 1 };
|
||||
setFade((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
|
||||
};
|
||||
measure();
|
||||
el.addEventListener('scroll', measure, { passive: true });
|
||||
const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
|
||||
resize?.observe(el);
|
||||
// New content changes what lies past an edge without changing the box itself.
|
||||
let frame = 0;
|
||||
const changed =
|
||||
typeof MutationObserver === 'undefined'
|
||||
? null
|
||||
: new MutationObserver(() => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(measure);
|
||||
});
|
||||
changed?.observe(el, { childList: true, subtree: true, characterData: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', measure);
|
||||
resize?.disconnect();
|
||||
changed?.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [el, axis]);
|
||||
return fade;
|
||||
}
|
||||
|
||||
/** The mask classes for a fade state: sideways by default, `y` for a scroller that runs down. */
|
||||
export function edgeFadeClass(fade: EdgeFade, axis: FadeAxis = 'x'): string {
|
||||
const [both, start, end] =
|
||||
axis === 'x' ? ['m-fade-se', 'm-fade-s', 'm-fade-e'] : ['m-fade-tb', 'm-fade-t', 'm-fade-b'];
|
||||
if (fade.start && fade.end) return both;
|
||||
if (fade.start) return start;
|
||||
if (fade.end) return end;
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { isNativeApp, isNativeIos } from '../../native/platform';
|
||||
import { reportNativeFailure } from '../../native/reportFailure';
|
||||
import { TrekShell } from '../../native/trekShell';
|
||||
import { hasOpenOverlay, useHasOpenOverlay } from '../overlayStack';
|
||||
import { findShortcut, toNativeCommand, useShortcutRegistry } from './shortcuts';
|
||||
|
||||
/**
|
||||
* Whether a modal is in front of the page: a sheet (it registers on the overlay
|
||||
* stack) or any other dialog that marks itself aria-modal. The page's shortcuts
|
||||
* wait behind it. ⌘[ while a reservation was being edited used to leave the trip
|
||||
* and drop the form without the "discard changes?" a tap on the scrim asks.
|
||||
*/
|
||||
function modalOpen(): boolean {
|
||||
if (hasOpenOverlay()) return true;
|
||||
// A dialog kept mounted while closed (the assistant's) is hidden, not gone.
|
||||
return Array.from(document.querySelectorAll('[aria-modal="true"]')).some((el) => !el.closest('[hidden]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers the touch shell's keyboard shortcuts (useShortcuts), once for every
|
||||
* screen, in the app only. A browser keeps its own keys: there the touch shell
|
||||
* is a phone layout, and Ctrl+1 is a tab switch and Alt+← is Back.
|
||||
*
|
||||
* The binary takes the shortcuts over as native key commands when it can
|
||||
* (feature keyCommands): on iOS they show in the overlay a held ⌘ shows and in
|
||||
* the iPadOS menu bar, on Android in the system's keyboard shortcuts list. The
|
||||
* page then leaves those keys alone, so nothing runs twice. Only a shell that
|
||||
* has the feature reports it, so the feature alone decides, whatever the
|
||||
* platform. An older binary without it leaves every key to the page.
|
||||
*
|
||||
* While a sheet or dialog is open none of them run: the page listens to
|
||||
* nothing and the app's list is emptied, so a shortcut can neither navigate
|
||||
* away under an unsaved form nor open a second editor over it.
|
||||
*/
|
||||
export default function MKeyboardHost() {
|
||||
const lists = useShortcutRegistry((s) => s.lists);
|
||||
const { caps } = useNativeCapabilities();
|
||||
const app = isNativeApp();
|
||||
const native = app && caps.features.includes('keyCommands');
|
||||
const blocked = useHasOpenOverlay();
|
||||
|
||||
useEffect(() => {
|
||||
if (!app) return;
|
||||
const apple = isNativeIos();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented || e.isComposing || modalOpen()) return;
|
||||
const all = lists.map((l) => l.shortcuts);
|
||||
const hit = findShortcut(e, native ? all.map((l) => l.filter((s) => !s.mod && !s.ctrl)) : all, apple);
|
||||
if (!hit) return;
|
||||
e.preventDefault();
|
||||
hit.run();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [lists, native, app]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!native) return;
|
||||
const commands = blocked
|
||||
? []
|
||||
: lists.flatMap((l) => l.shortcuts.filter((s) => s.mod || s.ctrl).map((s) => toNativeCommand(l.owner, s)));
|
||||
TrekShell.setKeyCommands({ commands }).catch(reportNativeFailure);
|
||||
}, [lists, native, blocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!native) return;
|
||||
let cancelled = false;
|
||||
let remove: (() => Promise<void>) | null = null;
|
||||
TrekShell.addListener('keyCommand', ({ id }) => {
|
||||
// A command can still arrive from a list sent before a dialog opened.
|
||||
if (modalOpen()) return;
|
||||
const at = id.indexOf(':');
|
||||
const owner = id.slice(0, at);
|
||||
const list = useShortcutRegistry.getState().lists.find((l) => l.owner === owner);
|
||||
list?.shortcuts.find((s) => s.id === id.slice(at + 1))?.run();
|
||||
})
|
||||
.then((handle) => {
|
||||
if (cancelled) void handle.remove();
|
||||
else remove = handle.remove;
|
||||
})
|
||||
.catch(reportNativeFailure);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (remove) void remove();
|
||||
};
|
||||
}, [native]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useLocation, useMatch, useNavigate } from 'react-router';
|
||||
import { useNavItems } from '../../components/Layout/navItems';
|
||||
import { useCreateAction } from '../../components/Layout/useCreateAction';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { Shortcut } from './shortcuts';
|
||||
import { useShortcuts } from './useShortcuts';
|
||||
|
||||
/**
|
||||
* The app-wide shortcuts of the touch shell: ⌘1 to ⌘9 for the sections in the
|
||||
* order the rail shows them, ⌘N for what the "+" creates here, ⌘, for
|
||||
* settings. Inside a trip the numbers and ⌘N belong to the trip (MTripShell):
|
||||
* its tabs change without a new URL, and this component renders only on one,
|
||||
* so a ⌘N of its own would still create what the tab the trip opened on does.
|
||||
*/
|
||||
export default function MShellShortcuts() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const inTrip = useMatch('/trips/:id');
|
||||
const navItems = useNavItems();
|
||||
const create = useCreateAction(true);
|
||||
const noCreate = ['/settings', '/admin', '/vacay'].some((p) => location.pathname.startsWith(p));
|
||||
|
||||
const shortcuts: Shortcut[] = [
|
||||
...(inTrip
|
||||
? []
|
||||
: navItems.slice(0, 9).map((item, i) => ({
|
||||
id: `go-${item.id}`,
|
||||
title: item.label,
|
||||
key: String(i + 1),
|
||||
mod: true,
|
||||
run: () => void navigate(item.to),
|
||||
}))),
|
||||
...(noCreate || inTrip ? [] : [{ id: 'create', title: create.label, key: 'n', mod: true, run: create.run }]),
|
||||
{ id: 'settings', title: t('nav.bottomSettings'), key: ',', mod: true, run: () => void navigate('/settings') },
|
||||
];
|
||||
useShortcuts('shell', shortcuts);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { create } from 'zustand';
|
||||
import type { NativeKeyCommand } from '../../native/trekShell';
|
||||
|
||||
/**
|
||||
* A hardware keyboard shortcut of the touch shell. The shell is what an iPad
|
||||
* shows, and an iPad often has a keyboard attached: there the app should
|
||||
* answer ⌘1 and ⌘N the way any iPad app does.
|
||||
*/
|
||||
export interface Shortcut {
|
||||
/** Stable id, also what the app's native key command reports back. */
|
||||
id: string;
|
||||
/** Its name, for the overlay a held ⌘ shows and the iPadOS menu bar. */
|
||||
title: string;
|
||||
/**
|
||||
* KeyboardEvent.key, lower case for letters ('n', '1', ',', 'ArrowLeft'). A
|
||||
* digit is matched by the key's place as well (KeyboardEvent.code), since a
|
||||
* French keyboard types '&' on the 1 key without Shift.
|
||||
*/
|
||||
key: string;
|
||||
/** ⌘ on an iPad (and any Apple keyboard), Ctrl on Android. */
|
||||
mod?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
/** The physical Control key on top of ⌘ (⌃⌘S). */
|
||||
ctrl?: boolean;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
type KeyEventLike = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'> &
|
||||
Partial<Pick<KeyboardEvent, 'code'>>;
|
||||
|
||||
/**
|
||||
* Whether an event is this shortcut. The command key is the platform's own: ⌘
|
||||
* on Apple keyboards, Ctrl elsewhere, never either one. Taking both made Ctrl+N
|
||||
* in a Mac text field (the "next line" binding) run ⌘N.
|
||||
*/
|
||||
export function matchesShortcut(e: KeyEventLike, s: Shortcut, apple = true): boolean {
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
const digitAt = /^[0-9]$/.test(s.key) && e.code === `Digit${s.key}`;
|
||||
if (key !== s.key && !digitAt) return false;
|
||||
if (s.ctrl) {
|
||||
if (!e.ctrlKey || !e.metaKey) return false;
|
||||
} else {
|
||||
const command = apple ? e.metaKey : e.ctrlKey;
|
||||
const other = apple ? e.ctrlKey : e.metaKey;
|
||||
if (!!s.mod !== command || other) return false;
|
||||
}
|
||||
return !!s.shift === e.shiftKey && !!s.alt === e.altKey;
|
||||
}
|
||||
|
||||
/** Typing in a field keeps its keys: a shortcut without ⌘ never fires there. */
|
||||
export function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
const tag = target.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||
}
|
||||
|
||||
/** The first shortcut of the lists (later ones first) that the event is. */
|
||||
export function findShortcut(
|
||||
e: KeyEventLike & Pick<KeyboardEvent, 'target'>,
|
||||
lists: Shortcut[][],
|
||||
apple = true
|
||||
): Shortcut | null {
|
||||
for (let i = lists.length - 1; i >= 0; i--) {
|
||||
for (const s of lists[i]) {
|
||||
if (!s.mod && !s.ctrl && isTypingTarget(e.target)) continue;
|
||||
if (matchesShortcut(e, s, apple)) return s;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ShortcutRegistry {
|
||||
/** The lists that are live, by owner, in the order they were registered. */
|
||||
lists: { owner: string; shortcuts: Shortcut[] }[];
|
||||
set: (owner: string, shortcuts: Shortcut[]) => void;
|
||||
remove: (owner: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every screen's live shortcuts in one place, so a single keydown listener and
|
||||
* the app's native key commands (NativeKeyCommands) answer for all of them, a
|
||||
* trip's own shortcuts ahead of the shell's.
|
||||
*/
|
||||
export const useShortcutRegistry = create<ShortcutRegistry>((set) => ({
|
||||
lists: [],
|
||||
set: (owner, shortcuts) =>
|
||||
set((state) => {
|
||||
const at = state.lists.findIndex((l) => l.owner === owner);
|
||||
if (at === -1) return { lists: [...state.lists, { owner, shortcuts }] };
|
||||
const lists = state.lists.slice();
|
||||
lists[at] = { owner, shortcuts };
|
||||
return { lists };
|
||||
}),
|
||||
remove: (owner) => set((state) => ({ lists: state.lists.filter((l) => l.owner !== owner) })),
|
||||
}));
|
||||
|
||||
const NATIVE_KEYS: Record<string, string> = {
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right',
|
||||
ArrowUp: 'up',
|
||||
ArrowDown: 'down',
|
||||
Escape: 'escape',
|
||||
};
|
||||
|
||||
/** A shortcut as the app's UIKeyCommand, under an id unique across screens. */
|
||||
export function toNativeCommand(owner: string, s: Shortcut): NativeKeyCommand {
|
||||
const modifiers: string[] = [];
|
||||
if (s.mod || s.ctrl) modifiers.push('command');
|
||||
if (s.ctrl) modifiers.push('control');
|
||||
if (s.shift) modifiers.push('shift');
|
||||
if (s.alt) modifiers.push('option');
|
||||
return { id: `${owner}:${s.id}`, title: s.title, input: NATIVE_KEYS[s.key] ?? s.key, modifiers };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useShortcutRegistry, type Shortcut } from './shortcuts';
|
||||
|
||||
/**
|
||||
* Registers a screen's keyboard shortcuts while it is mounted (MKeyboardHost
|
||||
* answers them). The list may be rebuilt on every render: the registry only
|
||||
* changes when a shortcut's id, name or keys do, and pressing one always runs
|
||||
* the newest handler.
|
||||
*/
|
||||
export function useShortcuts(owner: string, shortcuts: Shortcut[]): void {
|
||||
const latest = useRef(shortcuts);
|
||||
useEffect(() => {
|
||||
latest.current = shortcuts;
|
||||
});
|
||||
const signature = shortcuts.map((s) => [s.id, s.title, s.key, s.mod, s.shift, s.alt, s.ctrl].join('|')).join('\n');
|
||||
const setList = useShortcutRegistry((s) => s.set);
|
||||
const remove = useShortcutRegistry((s) => s.remove);
|
||||
// Re-registered when the signature changes, never for a new handler alone:
|
||||
// each registered shortcut runs whatever handler is newest when pressed.
|
||||
useEffect(() => {
|
||||
setList(
|
||||
owner,
|
||||
latest.current.map((s) => ({ ...s, run: () => latest.current.find((x) => x.id === s.id)?.run() }))
|
||||
);
|
||||
}, [owner, signature, setList]);
|
||||
useEffect(() => () => remove(owner), [owner, remove]);
|
||||
}
|
||||
@@ -10,6 +10,9 @@
|
||||
--m-bg: #F3F2EF;
|
||||
--m-scr: radial-gradient(120% 80% at 50% -10%, #FBFAF7 0%, #F1EFEA 55%, #E9E7E1 100%);
|
||||
--m-glass: rgba(255,255,255,.6);
|
||||
/* The dock's glass: denser than --m-glass, so a photo scrolling under it
|
||||
* neither turns it muddy nor washes out its icons. */
|
||||
--m-dock: rgba(255,255,255,.8);
|
||||
--m-gbr: rgba(255,255,255,.75);
|
||||
--m-card: rgba(255,255,255,.55);
|
||||
--m-cbr: rgba(255,255,255,.75);
|
||||
@@ -31,6 +34,9 @@
|
||||
--m-mkr: #101013;
|
||||
--m-mkfg: #fff;
|
||||
--m-trackoff: rgba(16,16,19,.14);
|
||||
/* The edge of an empty checkbox: at least 3:1 against a row, so an open item
|
||||
* shows its box rather than reading as plain text. */
|
||||
--m-checkbr: rgba(16,16,19,.55);
|
||||
|
||||
/* Strength of a dayTintProvider plugin's colour on an inactive day chip. A notch
|
||||
* above the desktop badge: the chip has no neutral base under it, so a softer tint
|
||||
@@ -53,6 +59,22 @@
|
||||
* real devices derive them from the safe-area insets. */
|
||||
--bottom-nav-h: calc(84px + env(safe-area-inset-bottom, 0px));
|
||||
--m-safe-top: calc(env(safe-area-inset-top, 0px) + 12px);
|
||||
/* The navigation rail's share of the leading edge (wide windows only), what
|
||||
* every full-width fixed bar and the page column start after. */
|
||||
--m-rail-w: 0px;
|
||||
}
|
||||
|
||||
/* Wide size class (hooks/sizeClass.ts, written to the shell's root as
|
||||
* data-size): the rail on the leading edge takes the dock's place, so nothing
|
||||
* docks at the bottom any more and content only clears the home indicator.
|
||||
* Asked from <html>, so a sheet's own .m-root (portalled, perhaps to body)
|
||||
* reads the same values as the shell. */
|
||||
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
|
||||
--bottom-nav-h: env(safe-area-inset-bottom, 0px);
|
||||
--m-rail-w: calc(env(safe-area-inset-left, 0px) + 100px);
|
||||
}
|
||||
html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
|
||||
--m-rail-w: calc(env(safe-area-inset-right, 0px) + 100px);
|
||||
}
|
||||
|
||||
.dark .m-root {
|
||||
@@ -62,6 +84,7 @@
|
||||
--m-bg: #0A0A0C;
|
||||
--m-scr: radial-gradient(120% 80% at 50% -10%, #17171C 0%, #0C0C0F 55%, #08080A 100%);
|
||||
--m-glass: rgba(26,26,31,.6);
|
||||
--m-dock: rgba(26,26,31,.82);
|
||||
--m-gbr: rgba(255,255,255,.12);
|
||||
--m-card: rgba(24,24,29,.55);
|
||||
--m-cbr: rgba(255,255,255,.1);
|
||||
@@ -83,6 +106,7 @@
|
||||
--m-mkr: #F5F5F7;
|
||||
--m-mkfg: #101013;
|
||||
--m-trackoff: rgba(255,255,255,.14);
|
||||
--m-checkbr: rgba(255,255,255,.4);
|
||||
--day-tint-chip: 28%;
|
||||
}
|
||||
|
||||
@@ -142,7 +166,27 @@
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(6px); }
|
||||
}
|
||||
/* A panel at the trailing edge (the assistant on a wide window) slides in from
|
||||
that edge, the way Slide Over does. */
|
||||
@keyframes m-panel-in {
|
||||
from { opacity: 0; transform: translateX(28px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes m-panel-in-rtl {
|
||||
from { opacity: 0; transform: translateX(-28px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* A form sheet on a wide window sits mid-screen, so it rises from the bottom
|
||||
edge of the window rather than from just below its own resting place. */
|
||||
@keyframes m-sheet-up-wide {
|
||||
from { transform: translateY(calc(50dvh + 50%)); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
@keyframes m-sheet-down-wide {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(calc(50dvh + 50%)); }
|
||||
}
|
||||
.m-sheet-in { animation: m-sheet-in 260ms var(--ease-out-quint) both; }
|
||||
.m-sheet-out { animation: m-sheet-out 220ms var(--ease-drawer) both; }
|
||||
.m-sheet-up { animation: m-sheet-up 420ms var(--ease-drawer) both; }
|
||||
@@ -154,6 +198,15 @@
|
||||
.m-pop-in { animation: m-pop-in 220ms var(--ease-out-quint) both; }
|
||||
.m-toast-in { animation: m-toast-in 260ms var(--ease-out-quint) both; }
|
||||
.m-toast-out { animation: m-toast-out 220ms ease both; }
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-sheet-up { animation-name: m-sheet-up-wide; }
|
||||
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-sheet-down { animation-name: m-sheet-down-wide; }
|
||||
}
|
||||
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation: m-panel-in 320ms var(--ease-out-quint) both; }
|
||||
html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation-name: m-panel-in-rtl; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation: m-fade-in 120ms ease-out both; }
|
||||
}
|
||||
|
||||
/* Reduced motion: gentler, not zero (same rule the desktop keyframes follow). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -499,3 +552,257 @@
|
||||
.m-root [data-field-trigger='pad'] {
|
||||
padding-inline: 14px !important;
|
||||
}
|
||||
|
||||
/* ── The page column on a wide window ───────────────────────────────────────
|
||||
Fixed bars span the page, not the window: they start after the navigation
|
||||
rail (--m-rail-w, 0 on a phone, so these are the phone's insets there). */
|
||||
.m-bar-x {
|
||||
inset-inline-start: calc(var(--m-rail-w, 0px) + 16px);
|
||||
inset-inline-end: 16px;
|
||||
}
|
||||
.m-span-x {
|
||||
inset-inline-start: var(--m-rail-w, 0px);
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
/* A screen whose background is a map reaches under the rail: the map runs to
|
||||
the window's edge and the rail floats over it like the top controls do. Its
|
||||
own bars then keep to the page column with m-bar-x / m-span-x. */
|
||||
.m-bleed {
|
||||
margin-inline-start: calc(var(--m-rail-w, 0px) * -1);
|
||||
}
|
||||
/* The trip's pane beside the map (split size class, MTripShell): a third of
|
||||
the window, within what a list and its rows need. On a foldable that reports
|
||||
its fold (two horizontal viewport segments), the pane takes the first half
|
||||
to the fold exactly, so no row runs across the crease and the map has the
|
||||
other half to itself. */
|
||||
.m-trip {
|
||||
--trip-pane-open: clamp(360px, 38vw, 460px);
|
||||
}
|
||||
@media (horizontal-viewport-segments: 2) {
|
||||
.m-trip {
|
||||
--trip-pane-open: calc(env(viewport-segment-width 0 0, 50vw) - var(--m-rail-w, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* The leading pane of a two-pane screen (a tab's summary, a list beside its
|
||||
detail): about a third of the page, within what its cards need. On a
|
||||
foldable that reports its fold it ends at the fold, like the trip's pane. */
|
||||
.m-root {
|
||||
--m-pane-aside-w: clamp(320px, 34vw, 420px);
|
||||
--m-pane-trail-w: clamp(340px, 36vw, 440px);
|
||||
}
|
||||
@media (horizontal-viewport-segments: 2) {
|
||||
.m-root {
|
||||
--m-pane-aside-w: calc(env(viewport-segment-width 0 0, 50vw) - var(--m-rail-w, 0px));
|
||||
--m-pane-trail-w: env(viewport-segment-width 1 0, 50vw);
|
||||
}
|
||||
}
|
||||
/* The Android app reports a foldable's fold itself (native/windowFold.ts):
|
||||
MobileShell marks a vertical fold that separates the window with data-fold
|
||||
and its place in --m-fold-start/--m-fold-end, and the panes meet there. The
|
||||
leading side follows the reading direction, the fold's place does not. */
|
||||
.m-root[data-fold='vertical'] {
|
||||
--m-pane-aside-w: calc(var(--m-fold-start) - var(--m-rail-w, 0px));
|
||||
--m-pane-trail-w: calc(100vw - var(--m-fold-end));
|
||||
}
|
||||
.m-root[data-fold='vertical'] .m-trip {
|
||||
--trip-pane-open: calc(var(--m-fold-start) - var(--m-rail-w, 0px));
|
||||
}
|
||||
:root[dir='rtl'] .m-root[data-fold='vertical'] {
|
||||
--m-pane-aside-w: calc(100vw - var(--m-fold-end) - var(--m-rail-w, 0px));
|
||||
--m-pane-trail-w: var(--m-fold-start);
|
||||
}
|
||||
:root[dir='rtl'] .m-root[data-fold='vertical'] .m-trip {
|
||||
--trip-pane-open: calc(100vw - var(--m-fold-end) - var(--m-rail-w, 0px));
|
||||
}
|
||||
.m-pane-aside {
|
||||
width: var(--m-pane-aside-w);
|
||||
}
|
||||
/* The trailing pane: what accompanies the page rather than leads it, like the
|
||||
trip chat beside its notes and polls. */
|
||||
.m-pane-trail {
|
||||
width: var(--m-pane-trail-w);
|
||||
}
|
||||
|
||||
/* The phone rules of index.css, for the app. It shows this shell at every
|
||||
size, an iPad's included (mobile/useIsPhone.ts), where their media query does
|
||||
not match; the shell's root carries .m-shell, so they follow it there: the
|
||||
document is the scroller, the shell paints the background, no zoom buttons
|
||||
on a touch map, no scrollbars. */
|
||||
html:has(.m-shell) {
|
||||
overflow: visible;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
html:has(.m-shell) body {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
overflow-x: clip;
|
||||
background-color: transparent;
|
||||
}
|
||||
/* The few routes without a phone screen of their own (a plugin's page, help,
|
||||
a trip's files) still render through PageShell. Inside the touch shell its
|
||||
desktop navbar, shown from 768px up, would stand over the rail and repeat the
|
||||
navigation, and --nav-h would keep 64px of room for it: the shell has its own
|
||||
navigation, so neither is there. */
|
||||
html:has(.m-shell) {
|
||||
--nav-h: 0px;
|
||||
}
|
||||
html:has(.m-shell) [data-desktop-navbar] {
|
||||
display: none;
|
||||
}
|
||||
html:has(.m-shell) .leaflet-control-zoom {
|
||||
display: none !important;
|
||||
}
|
||||
html:has(.m-shell) * {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
html:has(.m-shell) ::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* While the trip's map runs under the rail (MTripShell, --trip-map-inset), the
|
||||
controls both engines draw in its corners step clear of the rail. A margin on
|
||||
the leading side moves only the corner next to the rail, in either reading
|
||||
direction: the corner on the far side is anchored there and does not move. */
|
||||
.m-trip .leaflet-control-container > div,
|
||||
.m-trip .maplibregl-control-container > div,
|
||||
.m-trip .mapboxgl-control-container > div {
|
||||
margin-inline-start: var(--trip-map-inset, 0px);
|
||||
}
|
||||
|
||||
/* The strip under a floating dock (components/MDockScrim.tsx): solid across
|
||||
the gap below the dock, clear from about its middle up. */
|
||||
.m-dock-scrim {
|
||||
-webkit-mask-image: linear-gradient(to top, #000 calc(env(safe-area-inset-bottom, 0px) + 12px), transparent calc(env(safe-area-inset-bottom, 0px) + 40px));
|
||||
mask-image: linear-gradient(to top, #000 calc(env(safe-area-inset-bottom, 0px) + 12px), transparent calc(env(safe-area-inset-bottom, 0px) + 40px));
|
||||
}
|
||||
|
||||
/* A horizontal strip with more behind an edge fades out there
|
||||
(components/useEdgeFade.ts), so a cut chip reads as "the row goes on". */
|
||||
.m-fade-s,
|
||||
.m-fade-e,
|
||||
.m-fade-se {
|
||||
/* A strip with a chevron over its faded end fades wider (--m-fade-reach). */
|
||||
--m-fade-w: var(--m-fade-reach, 28px);
|
||||
-webkit-mask-image: var(--m-fade-mask);
|
||||
mask-image: var(--m-fade-mask);
|
||||
}
|
||||
.m-fade-s {
|
||||
--m-fade-mask: linear-gradient(var(--m-fade-dir, 90deg), transparent 0, #000 var(--m-fade-w));
|
||||
}
|
||||
.m-fade-e {
|
||||
--m-fade-mask: linear-gradient(var(--m-fade-dir, 90deg), #000 calc(100% - var(--m-fade-w)), transparent 100%);
|
||||
}
|
||||
.m-fade-se {
|
||||
--m-fade-mask: linear-gradient(var(--m-fade-dir, 90deg), transparent 0, #000 var(--m-fade-w), #000 calc(100% - var(--m-fade-w)), transparent 100%);
|
||||
}
|
||||
:root[dir='rtl'] .m-fade-s,
|
||||
:root[dir='rtl'] .m-fade-e,
|
||||
:root[dir='rtl'] .m-fade-se {
|
||||
--m-fade-dir: 270deg;
|
||||
}
|
||||
|
||||
/* The same for a vertical scroller, a form sheet's body above all: it fades
|
||||
out at the top once scrolled and at the foot while more waits below, so a
|
||||
field cut by the footer reads as "the form goes on". Never mirrored. */
|
||||
.m-fade-t,
|
||||
.m-fade-b,
|
||||
.m-fade-tb {
|
||||
--m-fade-h: var(--m-fade-reach-y, 24px);
|
||||
-webkit-mask-image: var(--m-fade-vmask);
|
||||
mask-image: var(--m-fade-vmask);
|
||||
}
|
||||
.m-fade-t {
|
||||
--m-fade-vmask: linear-gradient(to bottom, transparent 0, #000 var(--m-fade-h));
|
||||
}
|
||||
.m-fade-b {
|
||||
--m-fade-vmask: linear-gradient(to bottom, #000 calc(100% - var(--m-fade-h)), transparent 100%);
|
||||
}
|
||||
.m-fade-tb {
|
||||
--m-fade-vmask: linear-gradient(to bottom, transparent 0, #000 var(--m-fade-h), #000 calc(100% - var(--m-fade-h)), transparent 100%);
|
||||
}
|
||||
|
||||
/* A hint longer than its field ends in an ellipsis instead of being cut off
|
||||
mid-word at the field's edge ("Код аэропорта или город (нап"). */
|
||||
.m-root input:placeholder-shown {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Sheets ─────────────────────────────────────────────────────────────────
|
||||
A sheet takes the focus itself when it opens (modalFocus.ts), so a screen
|
||||
reader starts inside it. The app-wide focus ring (index.css) is meant for
|
||||
controls: on the sheet it drew a 2px frame round the whole dialog and
|
||||
squared its corners off to 4px. */
|
||||
.m-root [data-m-sheet]:focus-visible {
|
||||
outline: none;
|
||||
border-radius: var(--m-sheet-r);
|
||||
}
|
||||
|
||||
/* A form sheet (MSheet `form`) on a window as short as a phone on its side
|
||||
fills the height, and its actions move up beside the title the way an
|
||||
iPhone form carries Cancel and Save in its bar, so the fields get every
|
||||
line in between instead of a slot 140px tall. Cancel then does what the
|
||||
header's close button did, so that one goes. */
|
||||
@media (max-height: 500px) {
|
||||
/* Row 1: the title and the actions. Row 2: whatever a form keeps fixed
|
||||
under its title (the transport's manual/automated switch). Row 3: the
|
||||
fields, which take the rest. Doubled class: the panel's own `flex`
|
||||
utility must not win the display back. */
|
||||
.m-form-sheet.m-form-sheet {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
.m-form-sheet > * {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.m-form-sheet > .m-fs-head {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.m-form-sheet > .m-fs-foot {
|
||||
grid-area: 1 / 2;
|
||||
border-top: 0;
|
||||
padding-block: 16px 8px;
|
||||
padding-inline: 0 18px;
|
||||
}
|
||||
.m-form-sheet > .m-fs-body {
|
||||
grid-row: 3;
|
||||
align-self: stretch;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
|
||||
}
|
||||
.m-form-sheet:has(> .m-fs-foot) > .m-fs-head .m-fs-close {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A segmented control in the trip's top bar that does not fit between the back
|
||||
button and the trailing button draws its segments tighter first
|
||||
(MTripShellParts GlassSegment), and scrolls only where that is not enough. */
|
||||
.m-seg[data-tight='true'] > * {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
/* A one-line Markdown caption (MarkdownText clamp: an address, a note in a
|
||||
row) ends in an ellipsis. The clamp sits on the wrapper, but the text is in
|
||||
the paragraph inside it, so the line was cut off mid-letter at the row's
|
||||
edge or under its buttons with nothing to say it went on. */
|
||||
.m-root .collab-note-md.truncate > * {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The trip's shared links in the touch shell (CollabLinks in MCollabTab). Their
|
||||
edit, pin and delete buttons are always out on a touch screen, so a link in
|
||||
one of two columns kept a sliver for its title ("JR seat / reservations") and
|
||||
cut its host. One column, until the board has room for two links side by
|
||||
side with their titles on one line. */
|
||||
.m-root .collab-link-grid__cells {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
@container (min-width: 720px) {
|
||||
.m-root .collab-link-grid__cells {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { _resetOverlayStack, closeTopOverlay, hasOpenOverlay, useOverlay } from './overlayStack';
|
||||
import { _resetOverlayStack, closeTopOverlay, hasOpenOverlay, useHasOpenOverlay, useOverlay } from './overlayStack';
|
||||
|
||||
beforeEach(() => _resetOverlayStack());
|
||||
|
||||
@@ -33,4 +33,13 @@ describe('overlayStack', () => {
|
||||
expect(hasOpenOverlay()).toBe(false);
|
||||
expect(closeTopOverlay()).toBe(false);
|
||||
});
|
||||
|
||||
it('tells a component when an overlay opens and when the last one closes', () => {
|
||||
const watch = renderHook(() => useHasOpenOverlay());
|
||||
expect(watch.result.current).toBe(false);
|
||||
const sheet = renderHook(({ open }) => useOverlay(open, () => {}), { initialProps: { open: true } });
|
||||
expect(watch.result.current).toBe(true);
|
||||
sheet.rerender({ open: false });
|
||||
expect(watch.result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* The overlays open on screen, newest last, so a way back closes the one on
|
||||
@@ -9,6 +9,16 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
type Close = () => void;
|
||||
const stack: { id: number; close: () => void }[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function changed(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Closes the overlay on top. False when none is open, so the caller goes back instead. */
|
||||
export function closeTopOverlay(): boolean {
|
||||
@@ -22,6 +32,11 @@ export function hasOpenOverlay(): boolean {
|
||||
return stack.length > 0;
|
||||
}
|
||||
|
||||
/** Whether an overlay is open, for a component that changes with it (the keyboard shortcuts). */
|
||||
export function useHasOpenOverlay(): boolean {
|
||||
return useSyncExternalStore(subscribe, hasOpenOverlay, () => false);
|
||||
}
|
||||
|
||||
/** Registers an overlay while `open`, with the way it closes. */
|
||||
export function useOverlay(open: boolean, close: Close): void {
|
||||
const closeRef = useRef(close);
|
||||
@@ -32,9 +47,11 @@ export function useOverlay(open: boolean, close: Close): void {
|
||||
if (!open) return;
|
||||
const entry = { id: nextId++, close: () => closeRef.current() };
|
||||
stack.push(entry);
|
||||
changed();
|
||||
return () => {
|
||||
const i = stack.findIndex((e) => e.id === entry.id);
|
||||
if (i >= 0) stack.splice(i, 1);
|
||||
changed();
|
||||
};
|
||||
}, [open]);
|
||||
}
|
||||
@@ -42,4 +59,5 @@ export function useOverlay(open: boolean, close: Close): void {
|
||||
/** Test helper. */
|
||||
export function _resetOverlayStack(): void {
|
||||
stack.length = 0;
|
||||
changed();
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ import MAdminStoragePanel from './MAdminStoragePanel'
|
||||
import MAdminUsersSection from './MAdminUsersSection'
|
||||
import { MAdminButton } from './MAdminUi'
|
||||
import { managedAdminTabs } from '../../../managed'
|
||||
import { useIsSplit } from '../../../hooks/sizeClass'
|
||||
import MSectionNav, { MSectionRow, SECTION_SCREEN, type MSection } from '../../components/MSectionNav'
|
||||
|
||||
// Stat card of the 2×2 grid (design §6.3): 34px icon tile, 19px/800 number,
|
||||
// small tracking label. Count-up animation matches the desktop stat cards.
|
||||
@@ -55,9 +57,9 @@ function MAdminStat({ label, value, icon: Icon }: { label: string; value: number
|
||||
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[11px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Icon size={16} strokeWidth={2} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[1.1875rem] font-extrabold leading-none text-m-ink">{animated}</div>
|
||||
<div className="mt-[2px] font-geist text-[0.59375rem] font-bold tracking-[0.06em] text-m-faint">{label}</div>
|
||||
<div className="mt-[2px] truncate font-geist text-[0.59375rem] font-bold tracking-[0.06em] text-m-faint" title={label}>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -80,10 +82,13 @@ export default function MAdmin() {
|
||||
saveDemoBaseline, toggleBagTracking, toggleCollabFeature,
|
||||
} = admin
|
||||
const [sectionsOpen, setSectionsOpen] = useState(false)
|
||||
// With room for two panes the sections stand in a list beside the page, as in
|
||||
// settings, instead of behind the header's dropdown.
|
||||
const split = useIsSplit()
|
||||
|
||||
// Same sections and gating as the desktop sidebar, ordered and iconed like
|
||||
// the design's dropdown (§6.1).
|
||||
const sections: { id: string; label: string; icon: LucideIcon }[] = [
|
||||
const sections: MSection[] = [
|
||||
{ id: 'users', label: t('admin.tabs.users'), icon: Users },
|
||||
{ id: 'defaults', label: t('admin.tabs.defaults'), icon: UserCog },
|
||||
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
|
||||
@@ -107,8 +112,30 @@ export default function MAdmin() {
|
||||
return (
|
||||
// Flow screen: scrolls with the document (#1809), so no height and no
|
||||
// scroll container of its own.
|
||||
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[var(--m-safe-top,12px)]">
|
||||
<div className={SECTION_SCREEN}>
|
||||
{split && (
|
||||
<MSectionNav
|
||||
title={t('admin.title')}
|
||||
backLabel={t('common.back')}
|
||||
onBack={() => void navigate('/dashboard')}
|
||||
sections={sections}
|
||||
activeId={activeSection.id}
|
||||
onPick={setActiveTab}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateUser(true)}
|
||||
className="flex h-[34px] w-full items-center justify-center gap-[7px] rounded-full bg-m-act px-3 text-[length:calc(12px*var(--fs-scale-body,1))] font-bold text-m-actfg outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] focus-visible:ring-offset-2"
|
||||
>
|
||||
<UserPlus size={14} strokeWidth={2.2} className="flex-none" />
|
||||
<span className="truncate">{t('admin.createUser')}</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/* Header: back · section switcher · create user */}
|
||||
{!split && (
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -116,7 +143,7 @@ export default function MAdmin() {
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="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)]"
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={2.2} />
|
||||
<ChevronLeft size={18} strokeWidth={2.2} className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -138,25 +165,21 @@ export default function MAdmin() {
|
||||
<UserPlus size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section dropdown */}
|
||||
{sectionsOpen && (
|
||||
{sectionsOpen && !split && (
|
||||
<div className="-mt-[6px] mb-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-[6px] shadow-[0_16px_40px_-20px_rgba(0,0,0,.4)]">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
<MSectionRow
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
section={section}
|
||||
active={section.id === activeSection.id}
|
||||
onPick={() => {
|
||||
setActiveTab(section.id)
|
||||
setSectionsOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start text-[0.8125rem] font-semibold text-m-ink ${
|
||||
section.id === activeSection.id ? 'bg-[color:var(--m-ic)]' : ''
|
||||
}`}
|
||||
>
|
||||
<section.icon size={15} strokeWidth={2} />
|
||||
{section.label}
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -192,7 +215,7 @@ export default function MAdmin() {
|
||||
|
||||
{/* Stats grid (§6.3) */}
|
||||
{stats && (
|
||||
<div className="mb-3 grid grid-cols-2 gap-[10px]">
|
||||
<div className="mb-3 grid grid-cols-2 gap-[10px] wide:grid-cols-4">
|
||||
<MAdminStat label={t('admin.stats.users', { count: stats.totalUsers })} value={stats.totalUsers} icon={Users} />
|
||||
<MAdminStat label={t('admin.stats.trips', { count: stats.totalTrips })} value={stats.totalTrips} icon={Briefcase} />
|
||||
<MAdminStat label={t('admin.stats.places', { count: stats.totalPlaces })} value={stats.totalPlaces} icon={Map} />
|
||||
@@ -234,6 +257,8 @@ export default function MAdmin() {
|
||||
activeTab === tab.id ? <div key={tab.id}>{tab.mobileElement ?? tab.element}</div> : null,
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<MAdminSheets admin={admin} t={t} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -36,10 +36,14 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MAdminCard>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-[0.875rem] font-extrabold text-m-ink">{t('admin.tabs.users')}</span>
|
||||
<span className="font-geist text-[0.625rem] font-bold text-m-faint">
|
||||
{users.length} {t('admin.stats.users', { count: users.length })}
|
||||
{/* Wraps rather than overflows: in a long language the button moves under
|
||||
the title and its count, inside the card, instead of past its edge. */}
|
||||
<div className="mb-1 flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="text-[0.875rem] font-extrabold text-m-ink">{t('admin.tabs.users')}</span>
|
||||
<span className="whitespace-nowrap font-geist text-[0.625rem] font-bold text-m-faint">
|
||||
{users.length} {t('admin.stats.users', { count: users.length })}
|
||||
</span>
|
||||
</span>
|
||||
<MAdminButton className="ms-auto" onClick={() => setShowCreateUser(true)}>
|
||||
<UserPlus size={12} strokeWidth={2.2} />
|
||||
@@ -77,9 +81,11 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
|
||||
</span>
|
||||
<span
|
||||
className={`flex-none whitespace-nowrap rounded-full px-[9px] py-[3px] font-geist text-[0.5625rem] font-extrabold ${
|
||||
// Dark mode lifts the words towards the ink: the plain status red on a
|
||||
// dark red ground and grey on grey were both below 4.5:1 there.
|
||||
u.role === 'admin'
|
||||
? 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_12%,transparent)] text-[color:var(--m-st-danger)]'
|
||||
: 'bg-[color:var(--m-ic)] text-m-muted'
|
||||
? 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_12%,transparent)] text-[color:var(--m-st-danger)] dark:bg-[color:color-mix(in_srgb,var(--m-st-danger)_18%,transparent)] dark:text-[color:color-mix(in_srgb,var(--m-st-danger)_55%,var(--m-ink))]'
|
||||
: 'bg-[color:var(--m-ic)] text-m-muted dark:text-m-ink'
|
||||
}`}
|
||||
>
|
||||
{u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleUser')}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { CalendarClock, X } from 'lucide-react'
|
||||
import { useAtlas } from '../../../pages/atlas/useAtlas'
|
||||
@@ -28,7 +28,21 @@ const removeBtnCls = 'mt-4 w-full rounded-full bg-[rgba(214,39,59,.12)] py-[11px
|
||||
* useAtlas hook — this file is presentation and wiring only.
|
||||
*/
|
||||
export default function MAtlas() {
|
||||
const atlas = useAtlas()
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const headerRef = useRef<HTMLDivElement>(null)
|
||||
// The map runs under the rail (m-bleed) and the header: the camera keeps clear
|
||||
// of both. The rail's share is the root's own negative margin, resolved by the
|
||||
// browser; the header's is how far down it reaches.
|
||||
const atlas = useAtlas({
|
||||
chromeInsets: () => {
|
||||
const root = rootRef.current
|
||||
if (!root) return { top: 0, start: 0 }
|
||||
const start = Math.max(0, -parseFloat(getComputedStyle(root).marginInlineStart) || 0)
|
||||
const header = headerRef.current?.getBoundingClientRect()
|
||||
const top = header ? Math.max(0, header.bottom - root.getBoundingClientRect().top) : 0
|
||||
return { top, start }
|
||||
},
|
||||
})
|
||||
const {
|
||||
t,
|
||||
navigate,
|
||||
@@ -109,7 +123,7 @@ export default function MAtlas() {
|
||||
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 className="relative h-dvh overflow-hidden">
|
||||
<div ref={rootRef} className="m-bleed relative h-dvh overflow-hidden">
|
||||
<div ref={mapRef} className="absolute inset-0 z-[1] bg-[color:var(--m-mapb)] [&_.leaflet-control-zoom]:hidden" />
|
||||
{/* Region hover tooltip — positioned imperatively by useAtlas. */}
|
||||
<div
|
||||
@@ -120,7 +134,7 @@ export default function MAtlas() {
|
||||
|
||||
{/* Full-width bucket-list button. The bottom nav makes a back button
|
||||
redundant on this main-nav screen, so the header spans the width. */}
|
||||
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-[5] flex items-center gap-2">
|
||||
<div ref={headerRef} className="m-bar-x absolute top-[var(--m-safe-top,12px)] z-[5] flex items-center gap-2 wide:mx-auto wide:max-w-[720px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBucketOpen(true)}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function MAtlasSearch({
|
||||
const q = query.trim().toLowerCase()
|
||||
const rows = q ? options.filter((o) => o.label.toLowerCase().includes(q)).slice(0, 6) : suggestions
|
||||
|
||||
const scrimCls = 'm-fade-in fixed inset-0 z-[70] flex flex-col bg-[rgba(16,16,19,.28)] px-[18px] pt-[calc(var(--m-safe-top,12px)+12px)] backdrop-blur-[22px] backdrop-saturate-[1.6]' // theme-lint-disable — fixed scrim value from the design
|
||||
const scrimCls = 'm-fade-in fixed inset-0 z-[70] flex flex-col bg-[rgba(16,16,19,.28)] px-[18px] pt-[calc(var(--m-safe-top,12px)+12px)] backdrop-blur-[22px] backdrop-saturate-[1.6] wide:pt-[calc(var(--m-safe-top,12px)+72px)] wide:[&>*]:mx-auto wide:[&>*]:w-full wide:[&>*]:max-w-[640px]' // theme-lint-disable — fixed scrim value from the design
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function MAtlasStatsCard({ stats }: MAtlasStatsCardProps) {
|
||||
const planned = stats.totalCountriesPlanned || 0
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-[calc(var(--bottom-nav-h,84px)+16px)] inset-x-4 z-[5]">
|
||||
<div className="m-bar-x absolute bottom-[calc(var(--bottom-nav-h,84px)+16px)] z-[5] wide:mx-auto wide:max-w-[720px]">
|
||||
<div className="flex w-full rounded-[22px] border border-[color:var(--m-shbr)] bg-[color:var(--m-sheet)] px-3 py-[14px] shadow-[0_18px_44px_-20px_rgba(0,0,0,.4)]">
|
||||
{cols.map(([n, l], i) => (
|
||||
<div key={l} className="flex-1 text-center">
|
||||
|
||||
@@ -99,8 +99,8 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
<MapPin size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{str(r.name)}</span>
|
||||
{str(r.address) && <span className="block truncate font-geist text-[0.625rem] text-m-muted">{str(r.address)}</span>}
|
||||
<span dir="auto" className="block truncate text-[0.8125rem] font-semibold text-m-ink">{str(r.name)}</span>
|
||||
{str(r.address) && <span dir="auto" className="block truncate font-geist text-[0.625rem] text-m-muted">{str(r.address)}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function MCollLinksEditor({ links, onChange, t }: MCollLinksEdito
|
||||
className={`w-24 flex-none ${rowInput}`}
|
||||
/>
|
||||
<input
|
||||
dir="ltr"
|
||||
value={l.url}
|
||||
onChange={e => setLink(i, { url: e.target.value })}
|
||||
placeholder="https://…"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Layers, Plus } from 'lucide-react';
|
||||
|
||||
import { ALL_SAVED } from '../../../store/collectionStore';
|
||||
|
||||
interface ListEntry {
|
||||
id: number;
|
||||
name: string;
|
||||
/** The list's own colour, already resolved to the default for a list without one. */
|
||||
color: string;
|
||||
place_count?: number | null;
|
||||
}
|
||||
|
||||
interface MCollListNavProps {
|
||||
lists: ListEntry[];
|
||||
allSaved: boolean;
|
||||
activeId: number | typeof ALL_SAVED | null;
|
||||
onSelect: (id: number | typeof ALL_SAVED) => void;
|
||||
onNewList: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lists a user keeps: all saved places, each list with its colour and its
|
||||
* count, and a new one. The phone opens them as the header's dropdown; with
|
||||
* room for two panes they stand in a column of their own beside the places,
|
||||
* the way Notes on an iPad keeps its folders in view.
|
||||
*/
|
||||
export default function MCollListNav({ lists, allSaved, activeId, onSelect, onNewList, t }: MCollListNavProps) {
|
||||
const row =
|
||||
'flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)]';
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(ALL_SAVED)}
|
||||
aria-current={allSaved ? 'page' : undefined}
|
||||
className={`${row} text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink ${allSaved ? 'bg-[color:var(--m-ic)]' : 'hover:bg-[color:var(--m-ic)]'}`}
|
||||
>
|
||||
<Layers size={15} strokeWidth={2} className="flex-none" />
|
||||
<span className="min-w-0 flex-1 truncate">{t('collections.allSaved')}</span>
|
||||
</button>
|
||||
<div className="my-[2px] h-px bg-[color:var(--m-rowbr)]" />
|
||||
{lists.map((l) => {
|
||||
const active = activeId === l.id;
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(l.id)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`${row} ${active ? 'bg-[color:var(--m-ic)]' : 'hover:bg-[color:var(--m-ic)]'}`}
|
||||
style={active ? { boxShadow: `inset 0 0 0 1.5px ${l.color}` } : undefined}
|
||||
>
|
||||
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: l.color }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
|
||||
{l.name}
|
||||
</span>
|
||||
<span className="font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-bold text-m-faint">
|
||||
{l.place_count ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="my-[2px] h-px bg-[color:var(--m-rowbr)]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewList}
|
||||
className={`${row} py-[11px] text-[length:calc(13px*var(--fs-scale-body,1))] font-bold text-m-muted hover:bg-[color:var(--m-ic)]`}
|
||||
>
|
||||
<Plus size={15} strokeWidth={2.2} className="flex-none" /> {t('collections.newList')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export default function MCollListPickerSheet({ mode, lists, count, onPick, onClo
|
||||
className="mb-1 flex w-full items-center gap-[10px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[11px] text-start disabled:opacity-60"
|
||||
>
|
||||
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: list.color || '#6366F1' }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.84375rem] font-semibold text-m-ink">{list.name}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.84375rem] font-semibold text-m-ink">{list.name}</span>
|
||||
{busyId === list.id
|
||||
? <Loader2 size={13} className="flex-none animate-spin text-m-faint" />
|
||||
: <span className="font-geist text-[0.6875rem] font-bold text-m-faint">{list.place_count ?? 0}</span>}
|
||||
|
||||
@@ -16,9 +16,12 @@ interface MCollPlaceRowProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved place: the main card (tinted category tile, name, address) next to
|
||||
* the status column with the status pill (tap = cycle Idea → Want to go →
|
||||
* Visited) and the category pill. In select mode a tap toggles the selection.
|
||||
* A saved place, one card: the tinted category tile, name and address, and at
|
||||
* its trailing end the status pill (tap = cycle Idea → Want to go → Visited)
|
||||
* over the category pill. In select mode a tap toggles the selection.
|
||||
*
|
||||
* The pills used to stand in a card of their own beside the place, mostly empty
|
||||
* and a third of a narrow window wide, which cut the names short.
|
||||
*/
|
||||
export default function MCollPlaceRow({
|
||||
place, selectMode, selected, canEdit, onOpen, onToggleSelect, onSetStatus, t,
|
||||
@@ -30,16 +33,24 @@ export default function MCollPlaceRow({
|
||||
const StatusIcon = status.icon
|
||||
|
||||
const pill =
|
||||
'inline-flex w-[78px] box-border items-center justify-center gap-1 truncate rounded-full py-[5px] font-geist text-[0.5625rem] font-extrabold'
|
||||
'inline-flex max-w-[92px] box-border items-center justify-center gap-1 rounded-full px-2 py-[4px] font-geist text-[0.5625rem] font-extrabold'
|
||||
// The words in the colour, pulled towards the ink so they read on the light
|
||||
// ground as well as on the dark one; the ground a stronger wash of it.
|
||||
const pillStyle = (color: string) => ({
|
||||
background: `color-mix(in srgb, ${color} 16%, transparent)`,
|
||||
color: `color-mix(in srgb, ${color} 62%, var(--m-ink))`,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-stretch gap-2">
|
||||
<div
|
||||
className="mt-2 flex items-center rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop"
|
||||
style={selected ? { boxShadow: 'inset 0 0 0 1.5px var(--m-act)' } : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpen(place.id))}
|
||||
aria-pressed={selectMode ? selected : undefined}
|
||||
className="flex min-w-0 flex-1 items-center gap-[11px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[11px] text-start"
|
||||
style={selected ? { boxShadow: 'inset 0 0 0 1.5px var(--m-act)' } : undefined}
|
||||
className="flex min-w-0 flex-1 items-center gap-[11px] self-stretch rounded-2xl py-[11px] pe-2 ps-3 text-start"
|
||||
>
|
||||
<span
|
||||
className={`flex h-10 w-10 flex-none items-center justify-center rounded-xl ${selected ? 'bg-m-act text-m-actfg' : ''}`}
|
||||
@@ -48,32 +59,32 @@ export default function MCollPlaceRow({
|
||||
{selected ? <Check size={17} strokeWidth={2.4} /> : <TileIcon size={17} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.78125rem] font-bold text-m-ink">{place.name}</span>
|
||||
<span dir="auto" className="block truncate text-[0.78125rem] font-bold text-m-ink">{place.name}</span>
|
||||
{place.address && (
|
||||
<span className="mt-[2px] flex items-center gap-[3px] truncate font-geist text-[0.625rem] text-m-muted">
|
||||
<MapPin size={9} strokeWidth={2.2} className="flex-none" />
|
||||
<span className="truncate">{place.address}</span>
|
||||
<span dir="auto" className="truncate">{place.address}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-none flex-col justify-center gap-[5px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-[10px] py-2">
|
||||
<div className="flex flex-none flex-col items-end justify-center gap-[5px] py-2 pe-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEdit || selectMode}
|
||||
onClick={() => onSetStatus(place.id, nextStatus(place.status))}
|
||||
aria-label={t(status.labelKey)}
|
||||
className={pill}
|
||||
style={{ background: tint(status.color, '18'), color: status.color }}
|
||||
style={pillStyle(status.color)}
|
||||
>
|
||||
<StatusIcon size={9} strokeWidth={2.6} className="flex-none" />
|
||||
<span className="truncate">{t(status.labelKey)}</span>
|
||||
</button>
|
||||
{cat && place.category?.name && (
|
||||
<span className={pill} style={{ background: tint(cat.color, '18'), color: cat.color }}>
|
||||
<span className={pill} style={pillStyle(cat.color)}>
|
||||
<TileIcon size={9} strokeWidth={2.6} className="flex-none" />
|
||||
<span className="truncate">{place.category.name}</span>
|
||||
<span dir="auto" className="truncate">{place.category.name}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function MCollPlaceSheet({
|
||||
{held.category?.name && (
|
||||
<span className={HERO_CAT_CHIP}>
|
||||
<MapPin size={9} strokeWidth={2.6} className="flex-none" />
|
||||
<span className="truncate">{held.category.name}</span>
|
||||
<span dir="auto" className="truncate">{held.category.name}</span>
|
||||
</span>
|
||||
)}
|
||||
{/* -me-1 keeps the buttons 14px off the edge, where close has always sat. */}
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function MCollTripPickerSheet({ open, count, onCopy, onClose, t }
|
||||
: <MapPin size={15} strokeWidth={2} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{trip.title}</span>
|
||||
<span dir="auto" className="block truncate text-[0.8125rem] font-semibold text-m-ink">{trip.title}</span>
|
||||
{dateRange(trip) && (
|
||||
<span className="mt-[1px] flex items-center gap-1 truncate font-geist text-[0.6875rem] text-m-muted">
|
||||
<CalendarDays size={11} strokeWidth={2} className="flex-none" /> {dateRange(trip)}
|
||||
|
||||
@@ -24,6 +24,8 @@ import MCollTours from './MCollTours'
|
||||
import MCollInvites from './MCollInvites'
|
||||
import { STATUS_SPEC } from './collectionsMobileModel'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import MCollListNav from './MCollListNav'
|
||||
import { useIsSplit } from '../../../hooks/sizeClass'
|
||||
|
||||
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)]'
|
||||
@@ -66,6 +68,9 @@ export default function MCollections() {
|
||||
const { t } = c
|
||||
|
||||
const [drop, setDrop] = useState(false)
|
||||
// With room for two panes the lists stand in a column beside the places
|
||||
// (MCollListNav) instead of behind the header's dropdown.
|
||||
const split = useIsSplit()
|
||||
const [statusDrop, setStatusDrop] = useState(false)
|
||||
const [labelDrop, setLabelDrop] = useState(false)
|
||||
|
||||
@@ -145,6 +150,20 @@ export default function MCollections() {
|
||||
c.setLabelFilter(c.labelFilter.includes(id) ? c.labelFilter.filter(x => x !== id) : [...c.labelFilter, id])
|
||||
}
|
||||
|
||||
const navLists = [...c.ownedLists, ...c.sharedLists].map(l => ({
|
||||
id: l.id, name: l.name, place_count: l.place_count, color: l.color || '#6366F1',
|
||||
}))
|
||||
const listNav = (
|
||||
<MCollListNav
|
||||
lists={navLists}
|
||||
allSaved={c.isAllSaved}
|
||||
activeId={c.activeId}
|
||||
onSelect={selectList}
|
||||
onNewList={openNewList}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
|
||||
const selectChip = (disabled: boolean) =>
|
||||
`flex flex-none items-center gap-[5px] rounded-full border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-2 text-[0.75rem] font-semibold text-m-ink ${disabled ? 'opacity-40' : ''}`
|
||||
|
||||
@@ -153,10 +172,28 @@ export default function MCollections() {
|
||||
// Map view fills the viewport like the trip and journal maps do (#2104);
|
||||
// h-dvh because the shell owns no scroll container (#1809). List view
|
||||
// keeps the document as the scroller.
|
||||
className={`px-4 pb-[calc(var(--bottom-nav-h,84px)+12px)] pt-[var(--m-safe-top,12px)] ${c.view === 'map' ? 'flex h-dvh flex-col overflow-hidden' : ''}`}
|
||||
className={`px-4 pb-[calc(var(--bottom-nav-h,84px)+12px)] pt-[var(--m-safe-top,12px)] wide:px-6 split:grid split:grid-cols-[260px_minmax(0,1fr)] split:gap-8 ${c.view === 'map' ? 'flex h-dvh flex-col overflow-hidden' : 'wide:mx-auto wide:max-w-[1040px] split:max-w-[1240px] split:items-start'}`}
|
||||
>
|
||||
{/* Header: back · list switcher · edit · share */}
|
||||
{split && (
|
||||
<nav
|
||||
aria-label={t('collections.title')}
|
||||
className="sticky top-[var(--m-safe-top,12px)] flex max-h-[calc(100dvh-var(--m-safe-top,12px)-16px)] flex-col gap-1 self-start overflow-y-auto rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-[6px] shadow-[0_16px_40px_-24px_rgba(0,0,0,.3)] [scrollbar-width:none]"
|
||||
>
|
||||
<div className="mb-1 px-[10px] pb-1 pt-[9px] text-subtitle font-bold text-m-ink">{t('collections.title')}</div>
|
||||
{listNav}
|
||||
</nav>
|
||||
)}
|
||||
<div className={`min-w-0 ${c.view === 'map' ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
|
||||
{/* Header: back · list switcher · edit · share. Beside the lists it names the list on show. */}
|
||||
<div className="mb-3 flex flex-none items-center gap-2 pt-2">
|
||||
{split ? (
|
||||
<h1 className="flex min-w-0 flex-1 items-center gap-2 text-title font-extrabold text-m-ink">
|
||||
{!c.isAllSaved && c.activeCollection && (
|
||||
<span className="h-[11px] w-[11px] flex-none rounded-full" style={{ background: navLists.find(l => l.id === c.activeId)?.color }} />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
</h1>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrop(v => !v)}
|
||||
@@ -167,6 +204,7 @@ export default function MCollections() {
|
||||
<span className="min-w-0 flex-1 truncate text-start">{title}</span>
|
||||
<ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
</button>
|
||||
)}
|
||||
{canEditList && (
|
||||
<button type="button" onClick={() => c.setEditorTarget(c.activeCollection!)} aria-label={t('collections.editListTitle')} className={HEADER_CIRCLE}>
|
||||
<Pencil size={15} strokeWidth={2.2} />
|
||||
@@ -182,40 +220,9 @@ export default function MCollections() {
|
||||
{/* List switcher dropdown (in flow, like the design). In map view it is a
|
||||
flex item of a viewport-high column, so it has to cap itself and give
|
||||
way rather than push its last entries under the dock (#2104). */}
|
||||
{drop && (
|
||||
{drop && !split && (
|
||||
<div className="-mt-[6px] mb-3 max-h-[50dvh] min-h-0 overflow-y-auto rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop p-[6px] shadow-[0_16px_40px_-20px_rgba(0,0,0,.4)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectList(ALL_SAVED)}
|
||||
className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.isAllSaved ? 'bg-[color:var(--m-ic)]' : ''}`}
|
||||
>
|
||||
<Layers size={15} strokeWidth={2} /> {t('collections.allSaved')}
|
||||
</button>
|
||||
<div className="my-[2px] h-px bg-[color:var(--m-rowbr)]" />
|
||||
{[...c.ownedLists, ...c.sharedLists].map(l => {
|
||||
const active = c.activeId === l.id
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => selectList(l.id)}
|
||||
className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start ${active ? 'bg-[color:var(--m-ic)]' : ''}`}
|
||||
style={active ? { boxShadow: `inset 0 0 0 1.5px ${l.color || '#6366F1'}` } : undefined}
|
||||
>
|
||||
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: l.color || '#6366F1' }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{l.name}</span>
|
||||
<span className="font-geist text-[0.6875rem] font-bold text-m-faint">{l.place_count ?? 0}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="my-[2px] h-px bg-[color:var(--m-rowbr)]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewList}
|
||||
className="flex w-full items-center gap-[9px] rounded-[11px] px-[10px] py-[11px] text-[0.8125rem] font-bold text-m-muted"
|
||||
>
|
||||
<Plus size={15} strokeWidth={2.2} /> {t('collections.newList')}
|
||||
</button>
|
||||
{listNav}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -360,7 +367,7 @@ export default function MCollections() {
|
||||
className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start ${on ? 'bg-[color:var(--m-ic)]' : ''}`}
|
||||
>
|
||||
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: l.color || '#6366F1' }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{l.name}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{l.name}</span>
|
||||
<span className="font-geist text-[0.6875rem] font-bold text-m-faint">{l.count}</span>
|
||||
{on && <Check size={14} strokeWidth={2.6} className="flex-none text-m-ink" />}
|
||||
</button>
|
||||
@@ -429,7 +436,8 @@ export default function MCollections() {
|
||||
) : c.visiblePlaces.length === 0 ? (
|
||||
<EmptyNote icon={<Search size={19} strokeWidth={1.8} />} title={t('collections.empty.noMatchTitle')} />
|
||||
) : (
|
||||
<div>
|
||||
// Two columns of rows where the page has the room for them.
|
||||
<div className="wide:grid wide:grid-cols-[repeat(auto-fill,minmax(min(100%,380px),1fr))] wide:gap-x-3">
|
||||
{c.visiblePlaces.map(p => (
|
||||
<MCollPlaceRow
|
||||
key={p.id}
|
||||
@@ -466,6 +474,8 @@ export default function MCollections() {
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Sheets */}
|
||||
<MCollPlaceSheet
|
||||
place={c.selectedPlace}
|
||||
|
||||
@@ -140,7 +140,7 @@ function MCurrencyWidget(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-[9px] font-geist text-[0.65625rem] text-m-muted">
|
||||
{rate != null ? `1 ${from} = ${rate.toFixed(4)} ${to}` : t('dashboard.fx.unavailable')}
|
||||
{rate != null ? <span dir="ltr">{`1 ${from} = ${rate.toFixed(4)} ${to}`}</span> : t('dashboard.fx.unavailable')}
|
||||
</div>
|
||||
</WidgetPanel>
|
||||
)
|
||||
@@ -195,7 +195,7 @@ function MCollectionsWidget(): React.ReactElement {
|
||||
className="mt-[11px] flex w-full items-center rounded-[14px] p-[15px_14px] text-start text-white"
|
||||
style={{ background: list.color || entityGradient(list.id) }}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.875rem] font-bold">{list.name}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.875rem] font-bold">{list.name}</span>
|
||||
<span className="ms-auto flex flex-none items-center gap-1 rounded-full bg-white/[.22] px-[10px] py-[3px] font-geist text-[0.625rem] font-bold">
|
||||
<MapPin size={10} strokeWidth={2.4} />
|
||||
{list.place_count ?? 0}
|
||||
@@ -307,7 +307,7 @@ function MUpcomingWidget({ items }: { items: UpcomingReservation[] }): React.Rea
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-[0.8125rem] font-semibold">{r.title}</span>
|
||||
<span dir="auto" className="truncate text-[0.8125rem] font-semibold">{r.title}</span>
|
||||
{r.status === 'pending' && (
|
||||
<span className="flex-none rounded-full bg-[color:var(--m-ic)] px-[6px] py-px font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-muted">
|
||||
{t('reservations.pending')}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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,
|
||||
@@ -28,6 +29,7 @@ import type { MobileDashToken } from '@trek/shared'
|
||||
import { usePullToRefresh } from '../../usePullToRefresh'
|
||||
import MPullIndicator from '../../components/MPullIndicator'
|
||||
import SharedConfirmSheet from '../settings/MConfirmSheet'
|
||||
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade'
|
||||
|
||||
type TripFilter = 'planned' | 'archive' | 'completed'
|
||||
|
||||
@@ -58,8 +60,13 @@ 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
|
||||
// buttons, and fade out where more of them waits.
|
||||
const [filterStrip, setFilterStrip] = useState<HTMLDivElement | null>(null)
|
||||
const filterFade = useEdgeFade(filterStrip)
|
||||
|
||||
// Plugin dashboard widgets + trip-card badges, mirroring the desktop page:
|
||||
// same slot filter (only true dashboard widgets), one badge fetch for all
|
||||
@@ -132,7 +139,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
forced zoom. min-w-max keeps the pill track wrapping its chips,
|
||||
and the box stays content-wide (no flex-1) so the pill still hugs
|
||||
them on wide phones while ms-auto parks the icons at the end. */}
|
||||
<div className="m-hscroll min-w-0">
|
||||
<div ref={setFilterStrip} className={`m-hscroll min-w-0 ${edgeFadeClass(filterFade)}`}>
|
||||
<MSegmented<TripFilter>
|
||||
value={tripFilter}
|
||||
onChange={setTripFilter}
|
||||
@@ -179,7 +186,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
)}
|
||||
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="mt-[10px] flex flex-col gap-3">
|
||||
<div className="mt-[10px] flex flex-col gap-3 wide:grid wide:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] wide:gap-4">
|
||||
{gridTrips.map(trip => (
|
||||
<MTripGridCard
|
||||
key={trip.id}
|
||||
@@ -193,7 +200,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-[10px] flex flex-col gap-3">
|
||||
<div className="mt-[10px] flex flex-col gap-3 wide:grid wide:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] wide:gap-x-4">
|
||||
{gridTrips.map(trip => (
|
||||
<MTripListCard
|
||||
key={trip.id}
|
||||
@@ -214,7 +221,8 @@ export default function MDashboard(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<>
|
||||
<MGlassBar floating>
|
||||
{/* In the column the cards below stand in, so their edges line up. */}
|
||||
<MGlassBar column="wide:max-w-[1280px]">
|
||||
<button
|
||||
type="button"
|
||||
// The page itself is the scroller since #1809, no inner container to walk up to.
|
||||
@@ -256,7 +264,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
{demoMode && <MDemoWelcome />}
|
||||
|
||||
<MPullIndicator state={pull} label={t('common.loading')} />
|
||||
<div ref={contentRef} className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
|
||||
<div ref={contentRef} className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)] wide:mx-auto wide:max-w-[1280px] wide:px-6">
|
||||
{loadError && (
|
||||
<div role="alert" className="mb-3 flex items-center gap-3 rounded-[20px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[14px]">
|
||||
<span className="min-w-0 flex-1 text-[0.8125rem] font-medium text-m-ink">{t('dashboard.loadErrorBanner')}</span>
|
||||
@@ -271,24 +279,36 @@ export default function MDashboard(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{spotlight && (
|
||||
<MSpotlightCard
|
||||
trip={spotlight}
|
||||
t={t}
|
||||
onOpen={() => navigate(`/trips/${spotlight.id}`)}
|
||||
actions={actionsFor(spotlight, 'list')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dashOrder.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} />
|
||||
{/* 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">
|
||||
{spotlight && (
|
||||
<MSpotlightCard
|
||||
trip={spotlight}
|
||||
t={t}
|
||||
onOpen={() => navigate(`/trips/${spotlight.id}`)}
|
||||
actions={actionsFor(spotlight, 'list')}
|
||||
/>
|
||||
)}
|
||||
{(split ? dashOrder.filter(id => id === 'trips') : dashOrder).map(id => (
|
||||
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<MNewTripSheet
|
||||
@@ -377,7 +397,9 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onOpen() }}
|
||||
className="relative h-[300px] cursor-pointer overflow-hidden rounded-[26px] shadow-[0_24px_56px_-22px_rgba(0,0,0,.5)]"
|
||||
// A phone on its side has no 300px to spare between the top bar and the dock:
|
||||
// the card ends above the dock rather than under it.
|
||||
className="relative h-[clamp(200px,calc(100svh-var(--m-safe-top,12px)-82px-var(--bottom-nav-h,84px)-14px),300px)] cursor-pointer overflow-hidden rounded-[26px] shadow-[0_24px_56px_-22px_rgba(0,0,0,.5)] outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] wide:h-[clamp(300px,42vh,440px)]"
|
||||
>
|
||||
{trip.cover_image
|
||||
? <img src={trip.cover_image} alt={trip.title} className="absolute inset-0 h-full w-full object-cover" />
|
||||
@@ -385,7 +407,7 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
|
||||
<div className="absolute end-[10px] top-[10px] flex gap-[6px]">
|
||||
{actions.map(a => <CoverActionBtn key={a.key} action={a} />)}
|
||||
</div>
|
||||
<div className="absolute bottom-[10px] inset-x-[10px] rounded-[18px] border border-white/[.16] bg-[rgba(14,14,17,.52)] p-[12px_14px] text-white backdrop-blur-[22px] backdrop-saturate-[1.6]">{/* theme-lint-disable — fixed dark glass on the cover photo */}
|
||||
<div className="absolute bottom-[10px] inset-x-[10px] rounded-[18px] border border-white/[.16] bg-[rgba(14,14,17,.52)] p-[12px_14px] text-white backdrop-blur-[22px] backdrop-saturate-[1.6] wide:bottom-4 wide:end-auto wide:start-4 wide:w-[min(560px,calc(100%-32px))] wide:p-[16px_18px]">{/* theme-lint-disable — fixed dark glass on the cover photo */}
|
||||
<span className="flex gap-[6px]">
|
||||
<span className="rounded-full bg-white/[.92] px-2 py-[3px] text-[0.625rem] font-bold uppercase tracking-[.07em] text-[#101013]">{/* theme-lint-disable — fixed on-photo badge */}
|
||||
{primaryBadge}
|
||||
@@ -396,13 +418,13 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="mt-[7px] truncate text-[1.4375rem] font-bold">{trip.title}</div>
|
||||
<div dir="auto" className="mt-[7px] truncate text-[1.4375rem] font-bold">{trip.title}</div>
|
||||
{ongoing && (
|
||||
<div className="relative mb-2 mt-[9px] h-1 rounded-full bg-white/25">
|
||||
<span className="absolute bottom-0 left-0 top-0 rounded-full bg-white" style={{ width: `${progress}%` }} />
|
||||
<span className="absolute bottom-0 start-0 top-0 rounded-full bg-white" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex gap-[6px] ${ongoing ? '' : 'mt-[9px]'}`}>
|
||||
<div className={`flex flex-wrap gap-[6px] ${ongoing ? '' : 'mt-[9px]'}`}>
|
||||
<SpotlightPill icon={<CalendarDays size={11} strokeWidth={2.2} />} label={t('dashboard.mobile.spotlightDays', { count: days })} />
|
||||
<SpotlightPill icon={<MapPin size={11} strokeWidth={2.2} />} label={t('dashboard.hero.destinations', { count: places })} />
|
||||
<SpotlightPill icon={<Users size={11} strokeWidth={2.2} />} label={t('dashboard.hero.travelers', { count: people })} />
|
||||
@@ -425,7 +447,8 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
|
||||
*/
|
||||
function SpotlightPill({ icon, label }: { icon: React.ReactElement; label: string }): React.ReactElement {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/[.28] px-[9px] py-[3px] font-geist text-[0.5625rem] font-bold tracking-[.07em] text-white uppercase">
|
||||
// One line per chip: a narrow card wraps whole chips rather than breaking the number from its word.
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-white/[.28] px-[9px] py-[3px] font-geist text-[0.5625rem] font-bold tracking-[.07em] text-white uppercase">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
@@ -439,7 +462,9 @@ function CoverActionBtn({ action }: { action: CardAction }): React.ReactElement
|
||||
type="button"
|
||||
aria-label={action.label}
|
||||
onClick={e => { e.stopPropagation(); action.onClick() }}
|
||||
className="flex h-[34px] w-[34px] items-center justify-center rounded-full border border-white/30 bg-white/[.22] text-white backdrop-blur-[8px]"
|
||||
// A dark ground, not a white veil: over a bright sky the veil and its white
|
||||
// icon disappeared together.
|
||||
className="flex h-[34px] w-[34px] items-center justify-center rounded-full border border-white/25 bg-[color:var(--overlay)] text-white backdrop-blur-[8px]"
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
@@ -515,14 +540,13 @@ function MTripGridCard({ trip, locale, badge, pluginBadges, actions, onOpen }: {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-[9px_12px_12px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-[0.875rem] font-semibold">{trip.title}</div>
|
||||
<span className="inline-flex flex-none items-center gap-[6px] rounded-full bg-[color:var(--m-ic)] px-[10px] py-1 font-geist text-[0.6875rem] font-semibold text-m-ink">
|
||||
<span>{fullDate(trip.start_date, locale) ?? '—'}</span>
|
||||
<ArrowRight size={12} strokeWidth={2.2} className="text-m-faint" />
|
||||
<span>{fullDate(trip.end_date, locale) ?? '—'}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* The name has the card's whole width; the dates take the line under it. */}
|
||||
<div dir="auto" className="truncate text-[0.875rem] font-semibold">{trip.title}</div>
|
||||
<span className="mt-[6px] inline-flex max-w-full items-center gap-[6px] rounded-full bg-[color:var(--m-ic)] px-[10px] py-1 font-geist text-[0.6875rem] font-semibold text-m-ink">
|
||||
<span className="truncate">{fullDate(trip.start_date, locale) ?? '—'}</span>
|
||||
<ArrowRight size={12} strokeWidth={2.2} className="flex-none text-m-faint" />
|
||||
<span className="truncate">{fullDate(trip.end_date, locale) ?? '—'}</span>
|
||||
</span>
|
||||
<MTripBadges items={pluginBadges} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -548,7 +572,7 @@ function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen }
|
||||
<div className="absolute end-3 top-3 flex gap-[7px]">
|
||||
{actions.map(a => <CoverActionBtn key={a.key} action={a} />)}
|
||||
</div>
|
||||
<div className="absolute bottom-[14px] inset-x-4 truncate text-[1.625rem] font-extrabold text-white [text-shadow:0_2px_12px_rgba(0,0,0,.4)]">
|
||||
<div dir="auto" className="absolute bottom-[14px] inset-x-4 truncate text-[1.625rem] font-extrabold text-white [text-shadow:0_2px_12px_rgba(0,0,0,.4)]">
|
||||
{trip.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,14 +5,13 @@ import { useCanDo } from '../../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../../store/settingsStore'
|
||||
import { useAuthStore } from '../../../store/authStore'
|
||||
import MChip from '../../components/MChip'
|
||||
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
|
||||
import CustomSelect from '../../../components/shared/CustomSelect'
|
||||
import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.constants'
|
||||
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
|
||||
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import { FIELD_AREA_CLS } from '../trip/sheets/PlSheetChrome'
|
||||
import { Eyebrow, SHEET_FIELD, SHEET_PRIMARY, SHEET_ROUND_PRIMARY, SHEET_SECONDARY, SheetSection, TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader, SheetDateField } from '../trip/sheets/PlSheetChrome'
|
||||
import { SHEET_FIELD, SHEET_ROUND_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
|
||||
import MDayImpactList from '../../components/MDayImpactList'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard'
|
||||
@@ -31,9 +30,6 @@ interface MNewTripSheetProps {
|
||||
onArchive?: () => void
|
||||
}
|
||||
|
||||
/** A date picker in the sheets' round field. */
|
||||
const DATE_FIELD = 'flex h-[42px] items-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]'
|
||||
|
||||
/** The reminder presets the desktop form offers; anything else is a custom number of days. */
|
||||
const REMINDER_PRESETS = [0, 1, 3, 9]
|
||||
|
||||
@@ -151,108 +147,100 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
||||
|
||||
return (
|
||||
<>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Luggage size={19} strokeWidth={1.8} />}
|
||||
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
||||
sub={isEditing ? trip?.title : undefined}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
</div>
|
||||
<MSheet open={open} onClose={onClose} form ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
|
||||
<FormSheetHeader
|
||||
icon={Luggage}
|
||||
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
||||
subtitle={isEditing ? trip?.title : undefined}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
|
||||
<div className="m-scroll-fade mt-2 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{/* Flat fields under their labels, as in every other form sheet: no
|
||||
cards inside the card. */}
|
||||
<FormSheetBody>
|
||||
{error && (
|
||||
<div className="flex-none rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-caption font-semibold text-[color:var(--m-st-danger)]">
|
||||
<div className="mt-2 rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-caption font-semibold text-[color:var(--m-st-danger)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SheetSection title={t('dashboard.tripTitle')}>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
|
||||
readOnly={!canEditTrip}
|
||||
placeholder={t('dashboard.tripTitlePlaceholder')}
|
||||
aria-label={t('dashboard.tripTitle')}
|
||||
className={`${SHEET_FIELD} w-full flex-none font-semibold`}
|
||||
/>
|
||||
<Eyebrow className="mt-1 uppercase">{t('dashboard.tripDescription')}</Eyebrow>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => { if (canEditTrip) { setError(''); setDescription(e.target.value) } }}
|
||||
readOnly={!canEditTrip}
|
||||
placeholder={t('dashboard.tripDescriptionPlaceholder')}
|
||||
aria-label={t('dashboard.tripDescription')}
|
||||
rows={2}
|
||||
className={FIELD_AREA_CLS}
|
||||
/>
|
||||
</SheetSection>
|
||||
<Eyebrow className="mb-[5px] mt-2 uppercase">{t('dashboard.tripTitle')}</Eyebrow>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
|
||||
readOnly={!canEditTrip}
|
||||
placeholder={t('dashboard.tripTitlePlaceholder')}
|
||||
aria-label={t('dashboard.tripTitle')}
|
||||
className={`${FIELD_CLS} font-semibold`}
|
||||
/>
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dashboard.tripDescription')}</Eyebrow>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => { if (canEditTrip) { setError(''); setDescription(e.target.value) } }}
|
||||
readOnly={!canEditTrip}
|
||||
placeholder={t('dashboard.tripDescriptionPlaceholder')}
|
||||
aria-label={t('dashboard.tripDescription')}
|
||||
rows={2}
|
||||
className={FIELD_AREA_CLS}
|
||||
/>
|
||||
|
||||
<SheetSection>
|
||||
<div className="flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.startDate')}</Eyebrow>
|
||||
<div className={DATE_FIELD}>
|
||||
<CustomDatePicker
|
||||
value={startDate}
|
||||
onChange={v => { if (canEditTrip) changeStart(v) }}
|
||||
placeholder={t('dashboard.startDate')}
|
||||
borderless
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.endDate')}</Eyebrow>
|
||||
<div className={DATE_FIELD}>
|
||||
<CustomDatePicker
|
||||
value={endDate}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
|
||||
placeholder={t('dashboard.endDate')}
|
||||
borderless
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Side by side. A placeholder longer than its field ("Дата окончания")
|
||||
ends in an ellipsis inside it rather than running past its edge. */}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[5px] truncate uppercase">{t('dashboard.startDate')}</Eyebrow>
|
||||
<SheetDateField
|
||||
value={startDate}
|
||||
onChange={v => { if (canEditTrip) changeStart(v) }}
|
||||
placeholder={t('dashboard.startDate')}
|
||||
/>
|
||||
</div>
|
||||
{!startDate && !endDate && (
|
||||
<>
|
||||
<Eyebrow className="mt-1 uppercase">{t('dashboard.dayCount')}</Eyebrow>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_TRIP_DAYS}
|
||||
value={dayCount}
|
||||
onChange={e => {
|
||||
if (!canEditTrip) return
|
||||
setError('')
|
||||
const raw = e.target.value
|
||||
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
|
||||
}}
|
||||
readOnly={!canEditTrip}
|
||||
aria-label={t('dashboard.dayCount')}
|
||||
className={`${SHEET_FIELD} w-full flex-none`}
|
||||
/>
|
||||
{!isEditing && <p className="px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</p>}
|
||||
</>
|
||||
)}
|
||||
</SheetSection>
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[5px] truncate uppercase">{t('dashboard.endDate')}</Eyebrow>
|
||||
<SheetDateField
|
||||
value={endDate}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
|
||||
placeholder={t('dashboard.endDate')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!startDate && !endDate && (
|
||||
<>
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dashboard.dayCount')}</Eyebrow>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_TRIP_DAYS}
|
||||
value={dayCount}
|
||||
onChange={e => {
|
||||
if (!canEditTrip) return
|
||||
setError('')
|
||||
const raw = e.target.value
|
||||
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
|
||||
}}
|
||||
readOnly={!canEditTrip}
|
||||
aria-label={t('dashboard.dayCount')}
|
||||
className={FIELD_CLS}
|
||||
/>
|
||||
{!isEditing && <p className="mt-[6px] px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SheetSection title={t('dashboard.currency')}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
|
||||
disabled={!canEditTrip}
|
||||
searchable
|
||||
size="sm"
|
||||
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</SheetSection>
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dashboard.currency')}</Eyebrow>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
|
||||
disabled={!canEditTrip}
|
||||
searchable
|
||||
size="sm"
|
||||
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
{showReminder && (
|
||||
<SheetSection>
|
||||
<div className="mt-3">
|
||||
<ReminderField
|
||||
enabled={remindersEnabled}
|
||||
days={reminderDays}
|
||||
@@ -261,35 +249,35 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
||||
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
|
||||
onDays={setReminderDays}
|
||||
/>
|
||||
</SheetSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUploadCover && (
|
||||
<SheetSection>
|
||||
<>
|
||||
<Eyebrow className="mt-3 uppercase">{t('dashboard.coverImage')}</Eyebrow>
|
||||
<CoverField cover={cover} title={title} />
|
||||
</SheetSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditing && onArchive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onArchive(); onClose() }}
|
||||
className={`${SHEET_SECONDARY} flex-none`}
|
||||
className={`${SHEET_SECONDARY} mt-4 w-full`}
|
||||
>
|
||||
{trip?.is_archived ? <ArchiveRestore size={14} strokeWidth={2.2} /> : <Archive size={14} strokeWidth={2.2} />}
|
||||
{trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} ms-auto`}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={busy} className={SHEET_PRIMARY}>
|
||||
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
|
||||
</button>
|
||||
</div>
|
||||
<FormSheetFooter
|
||||
onCancel={onClose}
|
||||
cancelLabel={t('common.cancel')}
|
||||
onSubmit={handleSave}
|
||||
submitLabel={busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
|
||||
submitDisabled={busy}
|
||||
/>
|
||||
</MSheet>
|
||||
|
||||
{/* After the sheet, so it opens on top of it. */}
|
||||
|
||||
@@ -8,6 +8,8 @@ import MDancingTrek from '../../components/MDancingTrek'
|
||||
import { journeyCoverSrc, journeyCoverStyle } from './mobileJourneyMeta'
|
||||
import { usePullToRefresh } from '../../usePullToRefresh'
|
||||
import MPullIndicator from '../../components/MPullIndicator'
|
||||
import MGlassBar from '../../components/MGlassBar'
|
||||
import { useIsWide } from '../../../hooks/sizeClass'
|
||||
|
||||
type JourneyListItem = Journey & {
|
||||
entry_count?: number
|
||||
@@ -25,6 +27,7 @@ export default function MJourney() {
|
||||
openCreateModal, handleCreate, activeJourney,
|
||||
} = useJourney()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const wide = useIsWide()
|
||||
const pull = usePullToRefresh(scrollRef, loadJourneys, true, scrollRef)
|
||||
|
||||
const list = journeys as JourneyListItem[]
|
||||
@@ -36,20 +39,22 @@ export default function MJourney() {
|
||||
// The feed below keeps its own scroller, so this screen does not move the
|
||||
// document and Safari's address bar stays put here.
|
||||
<div className="relative h-dvh">
|
||||
{/* Floating header: back + create pill */}
|
||||
<div className="fixed inset-x-4 top-[var(--m-safe-top,12px)] z-30 flex items-center gap-[10px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCreateModal()}
|
||||
className="flex h-[38px] flex-1 items-center justify-center gap-[6px] whitespace-nowrap rounded-full bg-m-act px-[15px] text-[0.78125rem] font-bold text-m-actfg"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.4} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Floating header: the create pill. On a wide window it is the glass bar
|
||||
the dashboard and the notifications have, in the column of cards below
|
||||
it, and names the page at its start, as an iPad title bar does. */}
|
||||
{wide ? (
|
||||
<MGlassBar column="wide:max-w-[1088px]">
|
||||
<h1 className="min-w-0 flex-1 truncate ps-1 text-subtitle font-extrabold text-m-ink">{t('journey.title')}</h1>
|
||||
<CreateButton label={t('journey.frontpage.createJourney')} onClick={() => openCreateModal()} />
|
||||
</MGlassBar>
|
||||
) : (
|
||||
<div className="m-bar-x fixed top-[var(--m-safe-top,12px)] z-30 flex items-center gap-[10px]">
|
||||
<CreateButton label={t('journey.frontpage.createJourney')} onClick={() => openCreateModal()} grow />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MPullIndicator state={pull} label={t('common.loading')} />
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto px-4 pt-[calc(var(--m-safe-top,12px)+52px)] pb-[calc(var(--bottom-nav-h,84px)+16px)]">
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto px-4 pt-[calc(var(--m-safe-top,12px)+52px)] pb-[calc(var(--bottom-nav-h,84px)+16px)] wide:px-6 wide:pt-[calc(var(--m-safe-top,12px)+72px)] wide:[&>*]:mx-auto wide:[&>*]:max-w-[1040px]">
|
||||
{loading && list.length === 0 ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-ink" />
|
||||
@@ -65,14 +70,16 @@ export default function MJourney() {
|
||||
|
||||
{rest.length > 0 && (
|
||||
<>
|
||||
{/* On a wide window it stands at the start, over the cards it
|
||||
introduces, like every section label; the phone centres it. */}
|
||||
<div className="mx-[2px] mb-3 mt-[2px] flex items-center gap-[10px]">
|
||||
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
|
||||
<span className="h-px flex-1 bg-[color:var(--m-rowbr)] wide:hidden" />
|
||||
<span className="font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
|
||||
{t('mobileJourney.otherJourneys')}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 split:grid-cols-3 split:gap-4">
|
||||
{rest.map(j => (
|
||||
<GridCard key={j.id} journey={j} onOpen={() => navigate(`/journey/${j.id}`)} />
|
||||
))}
|
||||
@@ -104,6 +111,21 @@ export default function MJourney() {
|
||||
)
|
||||
}
|
||||
|
||||
function CreateButton({ label, onClick, grow = false }: { label: string; onClick: () => void; grow?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex h-[38px] items-center justify-center gap-[6px] whitespace-nowrap rounded-full bg-m-act text-[0.78125rem] font-bold text-m-actfg ${
|
||||
grow ? 'flex-1 px-[15px]' : 'flex-none px-5'
|
||||
}`}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.4} />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const src = journeyCoverSrc(journey.cover_image)
|
||||
@@ -116,7 +138,7 @@ function HeroCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () =>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="relative mb-3 block h-[220px] w-full overflow-hidden rounded-[26px] text-start shadow-[0_24px_56px_-22px_rgba(0,0,0,.5)]"
|
||||
className="relative mb-3 block h-[220px] w-full overflow-hidden rounded-[26px] wide:h-[clamp(260px,38vh,420px)] text-start shadow-[0_24px_56px_-22px_rgba(0,0,0,.5)]"
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
||||
@@ -129,7 +151,7 @@ function HeroCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () =>
|
||||
{t('mobileJourney.latestJourney')}
|
||||
</span>
|
||||
</span>
|
||||
<div className="mt-[7px] truncate text-[1.4375rem] font-bold">{journey.title}</div>
|
||||
<div dir="auto" className="mt-[7px] truncate text-[1.4375rem] font-bold">{journey.title}</div>
|
||||
<div className="mt-[9px] flex gap-[6px]">
|
||||
<HeroPill icon={<NotebookPen size={10} strokeWidth={2.2} />} label={t('mobileJourney.entriesCount', { count: journey.entry_count ?? 0 })} />
|
||||
<HeroPill icon={<Image size={10} strokeWidth={2.2} />} label={t('mobileJourney.photosCount', { count: journey.photo_count ?? 0 })} />
|
||||
@@ -164,7 +186,7 @@ function GridCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () =>
|
||||
>
|
||||
<div className="h-[74px]" style={journeyCoverStyle(journey)} />
|
||||
<div className="px-3 pb-[13px] pt-[11px]">
|
||||
<div className="truncate text-[0.875rem] font-extrabold">{journey.title}</div>
|
||||
<div dir="auto" className="truncate text-[0.875rem] font-extrabold">{journey.title}</div>
|
||||
{hasStats && (
|
||||
<div className="mt-[9px] flex gap-[5px]">
|
||||
<span className="inline-flex items-center gap-[3px] rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[0.5625rem] font-bold text-m-muted">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
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 JourneyLayeredMap from '../../../components/Journey/JourneyLayeredMap'
|
||||
@@ -27,6 +27,8 @@ import { useJourneyCarouselSync } from '../../../components/Journey/useJourneyCa
|
||||
import { useGalleryUpload } from '../../../components/Journey/useGalleryUpload'
|
||||
import { dayPrepText, dayPrepUnsettled } from '../../../components/Journey/journeyRecordingPrepModel'
|
||||
import MJourneyEntrySheet from './MJourneyEntrySheet'
|
||||
import { useIsSplit, useIsWide } from '../../../hooks/sizeClass'
|
||||
import MJourneyFeed from './MJourneyFeed'
|
||||
import MJourneySettingsSheet from './MJourneySettingsSheet'
|
||||
import MJourneyDayImportSheet from './MJourneyDayImportSheet'
|
||||
import MJourneyRecordingSheet from './MJourneyRecordingSheet'
|
||||
@@ -40,7 +42,31 @@ 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)'
|
||||
function subscribeShort(onChange: () => void) {
|
||||
const mq = window.matchMedia(SHORT_WINDOW)
|
||||
mq.addEventListener('change', onChange)
|
||||
return () => mq.removeEventListener('change', onChange)
|
||||
}
|
||||
const readShort = () => window.matchMedia(SHORT_WINDOW).matches
|
||||
|
||||
export default function MJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
|
||||
// Larger cards on a wide window, where the strip has the room for the whole name.
|
||||
const wide = useIsWide()
|
||||
// With room for two panes the entries stand as a feed beside the map (MJourneyFeed)
|
||||
// instead of the strip of cards over it.
|
||||
const split = useIsSplit()
|
||||
// On a short window the cards are smaller and the map is framed above them.
|
||||
const short = useSyncExternalStore(subscribeShort, readShort, () => false)
|
||||
// The map runs under the rail (m-bleed); framing keeps the journey clear of it.
|
||||
// The rail's share is the root's own negative margin, as the browser resolves it.
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [railInset, setRailInset] = useState(0)
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (root) setRailInset(Math.max(0, -parseFloat(getComputedStyle(root).marginInlineStart) || 0))
|
||||
}, [wide])
|
||||
const {
|
||||
id, navigate, toast, t, locale,
|
||||
current, loading,
|
||||
@@ -104,7 +130,16 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
const {
|
||||
mapRef, carouselRef, cardRefs, activeIndex, focusIndex, setActiveIndex, scrubberDays,
|
||||
syncMapToCard, handleMarkerClick, handleCardTap, openEntryById, jumpToDay,
|
||||
} = useJourneyCarouselSync<JourneyEntry>({ entries, mapEntries: sidebarMapItems, onOpenEntry: setEditingEntry, syncMapOnTap: true })
|
||||
} = useJourneyCarouselSync<JourneyEntry>({
|
||||
entries, mapEntries: sidebarMapItems, onOpenEntry: setEditingEntry, syncMapOnTap: true, layoutKey: split,
|
||||
})
|
||||
const feed = split && view === 'timeline' && entries.length > 0
|
||||
|
||||
// The map's box changes when the feed comes or goes (a rotation, Split View).
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => mapRef.current?.invalidateSize(), 320)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [feed, mapRef])
|
||||
|
||||
// Initial focus — give Leaflet time to initialise and fit bounds first. Opens on
|
||||
// today when today is part of the journey (see openAtEntryId), rather than always
|
||||
@@ -195,7 +230,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
observer.observe(node)
|
||||
setRailHeight(node.offsetHeight)
|
||||
return () => observer.disconnect()
|
||||
}, [view, entries.length])
|
||||
}, [view, entries.length, split])
|
||||
|
||||
const openUpload = useCallback(() => {
|
||||
if (availableProviders.length > 0) setShowUploadMenu(true)
|
||||
@@ -299,9 +334,14 @@ 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 className="relative h-dvh overflow-hidden">
|
||||
{/* Integrated map — always mounted, the gallery overlays it */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div ref={rootRef} className="m-bleed relative h-dvh overflow-hidden">
|
||||
{/* 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. */}
|
||||
<div
|
||||
className="absolute inset-y-0 end-0 start-0 z-0"
|
||||
style={feed ? { insetInlineStart: 'calc(var(--m-rail-w, 0px) + var(--m-pane-aside-w))' } : undefined}
|
||||
>
|
||||
<JourneyLayeredMap
|
||||
ref={mapRef}
|
||||
layers={mapLayers}
|
||||
@@ -320,20 +360,21 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMarkerCardClick={openEntryById}
|
||||
fullScreen
|
||||
paddingBottom={250}
|
||||
paddingBottom={short ? 280 : 250}
|
||||
paddingStart={feed ? 0 : railInset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gallery tab overlay */}
|
||||
{view === 'gallery' && (
|
||||
<div className="absolute inset-0 z-[5] overflow-y-auto bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] px-4 pt-[calc(var(--m-safe-top,12px)+56px)] pb-[calc(var(--bottom-nav-h,84px)+16px)]">
|
||||
<div className="m-span-x absolute inset-y-0 z-[5] overflow-y-auto bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] px-4 pt-[calc(var(--m-safe-top,12px)+56px)] pb-[calc(var(--bottom-nav-h,84px)+16px)] wide:px-6">
|
||||
{gallery.length === 0 ? (
|
||||
<div className="flex min-h-full flex-col items-center justify-center px-8 py-10 text-center">
|
||||
<MDancingTrek scene="journey" className="mb-2" />
|
||||
<p className="font-geist text-[0.8125rem] font-medium text-m-muted">{t('journey.detail.noPhotos')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 wide:grid-cols-[repeat(auto-fill,minmax(180px,1fr))] wide:gap-3">
|
||||
{gallery.map((photo, i) => (
|
||||
// A wrapper, not one button: the delete sits beside the tile's own
|
||||
// button instead of inside it (#2593).
|
||||
@@ -373,8 +414,37 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header: back — segment — upload / overflow menu */}
|
||||
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
|
||||
{/* The entries as a feed beside the map, on a window with room for two panes. */}
|
||||
{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.
|
||||
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))',
|
||||
}}
|
||||
>
|
||||
<MJourneyFeed
|
||||
entries={entries}
|
||||
days={scrubberDays}
|
||||
activeIndex={activeIndex}
|
||||
cardRefs={cardRefs}
|
||||
onCardTap={handleCardTap}
|
||||
showMood={current.show_mood !== 0}
|
||||
showWeather={current.show_weather !== 0}
|
||||
t={t}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
>
|
||||
{embedded ? (
|
||||
<span className="w-[38px] flex-none" aria-hidden />
|
||||
) : (
|
||||
@@ -384,7 +454,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={2.2} />
|
||||
<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)]">
|
||||
@@ -489,16 +559,20 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
inside the 84px the variable reserves, so +16 left a band of map doing
|
||||
nothing between the cards and it; two pixels keeps a hair of daylight and
|
||||
gives the strip the rest. */}
|
||||
{view === 'timeline' && entries.length > 0 && (
|
||||
<div ref={railRef} className="absolute inset-x-0 z-[8] bottom-[calc(var(--bottom-nav-h,84px)+2px)]">
|
||||
<JourneyDayScrubber
|
||||
days={scrubberDays}
|
||||
activeDate={entries[activeIndex]?.entry_date ?? null}
|
||||
onPick={jumpToDay}
|
||||
/>
|
||||
{view === 'timeline' && entries.length > 0 && !feed && (
|
||||
<div ref={railRef} className="m-span-x absolute z-[8] bottom-[calc(var(--bottom-nav-h,84px)+2px)]">
|
||||
{/* The days and the date on a plate of their own, so the map's names do
|
||||
not run through them. */}
|
||||
<div className="mx-3 rounded-[18px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] pt-[6px] backdrop-blur-[18px] backdrop-saturate-[1.4] empty:hidden">
|
||||
<JourneyDayScrubber
|
||||
days={scrubberDays}
|
||||
activeDate={entries[activeIndex]?.entry_date ?? null}
|
||||
onPick={jumpToDay}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex items-end gap-[22px] overflow-x-auto px-6 pb-1 pt-[30px] [-webkit-overflow-scrolling:touch] [scrollbar-width:none]"
|
||||
className={`flex items-end gap-[22px] overflow-x-auto px-6 pb-1 ${short ? 'pt-[22px]' : 'pt-[30px]'} [-webkit-overflow-scrolling:touch] [scrollbar-width:none]`}
|
||||
style={{ scrollSnapType: 'x mandatory' }}
|
||||
>
|
||||
{entries.map((entry, i) => (
|
||||
@@ -515,6 +589,7 @@ 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'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -4,9 +4,10 @@ import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import { SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
|
||||
import { FormSheetBody, SheetDateField } from '../trip/sheets/PlSheetChrome'
|
||||
import { useAutoGrow } from '../../../hooks/useAutoGrow'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import CustomTimePicker from '../../../components/shared/CustomTimePicker'
|
||||
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
|
||||
import { isVideoFile } from '../../../utils/videoPoster'
|
||||
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
|
||||
@@ -108,6 +109,9 @@ export default function MJourneyEntrySheet({
|
||||
const [captureOnly, setCaptureOnly] = useState(quickCapture)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
// The story grows with its lines instead of slicing the fourth one in half.
|
||||
const storyRef = useRef<HTMLTextAreaElement>(null)
|
||||
useAutoGrow(storyRef, story)
|
||||
|
||||
const activeProvider = externalProvider || providers[0]?.id || null
|
||||
const providerExistingAssetIds = providerAssetIds(activeProvider)
|
||||
@@ -125,15 +129,15 @@ export default function MJourneyEntrySheet({
|
||||
open
|
||||
onClose={handleClose}
|
||||
variant="card"
|
||||
material="glass"
|
||||
form
|
||||
ariaLabel={entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
|
||||
>
|
||||
{/* TileHeader's shape, with the move buttons between the title and the close. */}
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
|
||||
{/* FormSheetHeader's shape, with the move buttons between the title and the close. */}
|
||||
<div className="m-fs-head flex flex-none items-center gap-3 px-[18px] pb-2 pt-4">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<NotebookPen size={19} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold leading-tight">
|
||||
<span className="line-clamp-2 min-w-0 flex-1 break-words text-[1.0625rem] font-bold leading-tight">
|
||||
{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
|
||||
</span>
|
||||
{(onMoveEarlier || onMoveLater) && (
|
||||
@@ -146,21 +150,22 @@ export default function MJourneyEntrySheet({
|
||||
</MIconBtn>
|
||||
</span>
|
||||
)}
|
||||
<MIconBtn variant="neutral" size={34} onClick={handleClose} ariaLabel={t('common.cancel')}>
|
||||
<MIconBtn variant="neutral" size={34} onClick={handleClose} ariaLabel={t('common.cancel')} className="m-fs-close">
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<FormSheetBody>
|
||||
{/* Quick capture had no title field at all: it asked for a note and nothing
|
||||
else, so every entry caught on the move arrived nameless and the day's
|
||||
list read as a column of identical placeholders (discussion #2299). The
|
||||
name is the one thing that makes an entry findable later, and it is one
|
||||
line to type, so it comes first here too. */}
|
||||
{readOnly ? (
|
||||
<div className="pb-[10px] pt-1 text-[1.25rem] font-extrabold">{title || t('journey.editor.titlePlaceholder')}</div>
|
||||
<div className="pb-[10px] pt-1 text-[1.25rem] font-extrabold"><bdi>{title || t('journey.editor.titlePlaceholder')}</bdi></div>
|
||||
) : (
|
||||
<input
|
||||
dir="auto"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder={t('journey.editor.titlePlaceholder')}
|
||||
@@ -400,6 +405,7 @@ export default function MJourneyEntrySheet({
|
||||
{captureOnly && !readOnly && (
|
||||
<textarea
|
||||
rows={2}
|
||||
dir="auto"
|
||||
value={story}
|
||||
onChange={e => setStory(e.target.value)}
|
||||
placeholder={t('journey.editor.writeStory')}
|
||||
@@ -416,7 +422,9 @@ export default function MJourneyEntrySheet({
|
||||
)
|
||||
) : (
|
||||
<textarea
|
||||
ref={storyRef}
|
||||
rows={3}
|
||||
dir="auto"
|
||||
value={story}
|
||||
onChange={e => setStory(e.target.value)}
|
||||
placeholder={t('journey.editor.writeStory')}
|
||||
@@ -512,7 +520,7 @@ export default function MJourneyEntrySheet({
|
||||
{/* TREK's own picker, like the time field beside it: a native
|
||||
`<input type="date">` paints itself from the OS locale and takes
|
||||
no theme, so the two sat in one row looking like two apps. */}
|
||||
<CustomDatePicker value={entryDate} onChange={setEntryDate} disabled={readOnly} />
|
||||
<SheetDateField value={entryDate} onChange={setEntryDate} disabled={readOnly} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={`${eyebrow} mb-[5px]`}>{t('mobileJourney.time')}</div>
|
||||
@@ -542,6 +550,7 @@ export default function MJourneyEntrySheet({
|
||||
<div className={`${eyebrow} mb-[5px]`}>{t('journey.editor.location')}</div>
|
||||
<div className={`flex items-center gap-2 px-3 py-[10px] ${fieldShell}`}>
|
||||
<input
|
||||
dir="auto"
|
||||
value={locationQuery || locationName}
|
||||
readOnly={readOnly}
|
||||
onChange={e => searchLocation(e.target.value)}
|
||||
@@ -575,8 +584,8 @@ export default function MJourneyEntrySheet({
|
||||
>
|
||||
<MapPin size={13} className="mt-[2px] flex-none text-m-faint" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.78125rem] font-semibold">{r.name}</span>
|
||||
{r.address && <span className="block truncate font-geist text-[0.65625rem] text-m-muted">{r.address}</span>}
|
||||
<span dir="auto" className="block truncate text-[0.78125rem] font-semibold">{r.name}</span>
|
||||
{r.address && <span dir="auto" className="block truncate font-geist text-[0.65625rem] text-m-muted">{r.address}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -693,27 +702,26 @@ export default function MJourneyEntrySheet({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* A suggestion is not deleted, it is put down: the row survives so the trip
|
||||
sync does not offer the same place again (discussion #2299).
|
||||
|
||||
{/* A suggestion is not deleted, it is put down: the row survives so the trip
|
||||
sync does not offer the same place again (discussion #2299).
|
||||
Its own row at the foot of the fields rather than among the buttons: the
|
||||
sentence is longer than a button label and wrapped into two lines between
|
||||
Delete and Cancel. Full width, it reads as what it is, an alternative to
|
||||
saving this suggestion rather than a fourth thing competing with them. */}
|
||||
{!readOnly && onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="mt-4 flex w-full items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[10px] text-start"
|
||||
>
|
||||
<EyeOff size={15} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1 text-[0.8125rem] font-semibold">{t('journey.suggestions.dismiss')}</span>
|
||||
</button>
|
||||
)}
|
||||
</FormSheetBody>
|
||||
|
||||
Its own row above the buttons rather than among them: the sentence is
|
||||
longer than a button label and wrapped into two lines between Delete and
|
||||
Cancel. Full width, it reads as what it is, an alternative to saving this
|
||||
suggestion rather than a fourth thing competing with them. */}
|
||||
{!readOnly && onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="mx-[18px] mt-1 mb-[10px] flex flex-none items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[10px] text-start"
|
||||
>
|
||||
<EyeOff size={15} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1 text-[0.8125rem] font-semibold">{t('journey.suggestions.dismiss')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<div className="m-fs-foot flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
{!readOnly && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user