mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(mobile): phone styling for tours list, trip settings and admin cards
The tours list in the places browser now uses the same rows as places: a grade or type tile, the day plan's figure badges and a day badge or plus on the right. A shared "Planned on desktop" badge replaces the grey hint box there and sits under the road trip's empty stage. Trip settings and trip edit open phone sheets instead of the desktop dialogs; the edit sheet gains the day count of a dateless trip and the reminder. Questions are asked in phone sheets, not the browser. Admin on the phone: the paddle regions and school holiday cards are phone cards on their shared hooks, long addon descriptions fold to two lines, the addon header keeps the wordmark inside its sentence, the group bands drop the dash and the permissions actions get a row of their own. The Tours addon gets its catalog name and description in every locale. The journey map on the phone shows every layer without the switch bar.
This commit is contained in:
@@ -49,14 +49,16 @@ export function isPhotosAddon(addon: Addon): boolean {
|
||||
|
||||
/** The catalog name and description for an addon, falling back to what the server sent. */
|
||||
export function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
const nameKey = `admin.addons.catalog.${addon.id}.name`;
|
||||
const descKey = `admin.addons.catalog.${addon.id}.description`;
|
||||
const translatedName = t(nameKey);
|
||||
const translatedDescription = t(descKey);
|
||||
// Spelled out inside t() so the key check sees which catalog keys this reaches.
|
||||
const translatedName = t(`admin.addons.catalog.${addon.id}.name`);
|
||||
const translatedDescription = t(`admin.addons.catalog.${addon.id}.description`);
|
||||
|
||||
return {
|
||||
name: translatedName !== nameKey ? translatedName : addon.name,
|
||||
description: translatedDescription !== descKey ? translatedDescription : addon.description,
|
||||
name: translatedName !== `admin.addons.catalog.${addon.id}.name` ? translatedName : addon.name,
|
||||
description:
|
||||
translatedDescription !== `admin.addons.catalog.${addon.id}.description`
|
||||
? translatedDescription
|
||||
: addon.description,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,24 +5,17 @@ import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } f
|
||||
import { LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { AUTO_LOAD_MODES, autoLoadSizes, formatMegabytes } from './paddleRegionsModel';
|
||||
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
|
||||
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
|
||||
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
|
||||
const SIZES = [100, 250, 500, 1000, 2000] as const;
|
||||
|
||||
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
|
||||
/** `formatMegabytes` in the reader's language. */
|
||||
function useMegabytes() {
|
||||
const { language } = useTranslation();
|
||||
return (mb: number) =>
|
||||
new Intl.NumberFormat(language, {
|
||||
style: 'unit',
|
||||
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
|
||||
maximumFractionDigits: mb >= 1000 ? 1 : 0,
|
||||
}).format(mb >= 1000 ? mb / 1000 : mb);
|
||||
return (mb: number) => formatMegabytes(mb, language);
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
@@ -36,9 +29,7 @@ export function AutoLoadSettings({ admin }: { admin: Admin }) {
|
||||
const megabytes = useMegabytes();
|
||||
const { settings, saving, save } = admin.autoLoad;
|
||||
if (!settings) return null;
|
||||
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
|
||||
? SIZES
|
||||
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
|
||||
const sizes = autoLoadSizes(settings.max_mb);
|
||||
return (
|
||||
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="min-w-0">
|
||||
@@ -49,7 +40,7 @@ export function AutoLoadSettings({ admin }: { admin: Admin }) {
|
||||
onChange={(mode) => void save({ mode })}
|
||||
label={t('admin.paddleRegions.auto.title')}
|
||||
fill
|
||||
options={MODES.map((mode) => {
|
||||
options={AUTO_LOAD_MODES.map((mode) => {
|
||||
const Icon = MODE_ICONS[mode];
|
||||
return {
|
||||
value: mode,
|
||||
|
||||
@@ -25,8 +25,8 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
|
||||
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
|
||||
* place a paddler plans in by itself, with the regions that wait for an admin.
|
||||
* Both the desktop and the phone admin show this one card under the addons,
|
||||
* while the Tours addon is on.
|
||||
* The desktop admin shows this card under the addons while the Tours addon
|
||||
* is on; the phone has MAdminPaddleRegionsPanel on the same hook.
|
||||
*/
|
||||
export default function PaddleRegionsPanel() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
import type { PaddleRegion } from '@trek/shared';
|
||||
import type { PaddleRegion, PaddleRegionAutoMode } from '@trek/shared';
|
||||
|
||||
/** How TREK loads a paddler's region by itself, in the order the switch shows them. */
|
||||
export const AUTO_LOAD_MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
|
||||
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
|
||||
export const AUTO_LOAD_SIZES = [100, 250, 500, 1000, 2000] as const;
|
||||
|
||||
/** The size choices, with a stored size outside the presets kept among them in its place. */
|
||||
export function autoLoadSizes(current: number): number[] {
|
||||
return (AUTO_LOAD_SIZES as readonly number[]).includes(current)
|
||||
? [...AUTO_LOAD_SIZES]
|
||||
: [...AUTO_LOAD_SIZES, current].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
|
||||
export function formatMegabytes(mb: number, language: string): string {
|
||||
return new Intl.NumberFormat(language, {
|
||||
style: 'unit',
|
||||
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
|
||||
maximumFractionDigits: mb >= 1000 ? 1 : 0,
|
||||
}).format(mb >= 1000 ? mb / 1000 : mb);
|
||||
}
|
||||
|
||||
/** A region waiting for its read or being read: the list asks again until none is. */
|
||||
export function isReading(region: Pick<PaddleRegion, 'status'>): boolean {
|
||||
|
||||
@@ -89,6 +89,12 @@ describe('JourneyLayeredMap', () => {
|
||||
expect(last().photos).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-LAYEREDMAP-002b: the phone draws the layers without their switches', () => {
|
||||
render(<JourneyLayeredMap layers={layers()} hideLayerControl checkins={[]} entries={[]} photos={PHOTOS} />);
|
||||
expect(last()).toMatchObject({ marks: [MARK], layerVisibility: ALL_VISIBLE });
|
||||
expect(screen.queryByRole('group', { name: 'Map layers' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-LAYEREDMAP-003: a picked mark opens its card, which goes when its layer is switched off', () => {
|
||||
const onOpenEntry = vi.fn();
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -20,8 +20,8 @@ interface Props extends MapProps {
|
||||
layers?: JourneyMapLayersView | null;
|
||||
/** A shared page and the phone timeline draw with Leaflet whatever a setting says. */
|
||||
engine?: 'auto' | 'leaflet';
|
||||
/** Icon-only switches, for a phone. */
|
||||
compactControls?: boolean;
|
||||
/** No layer switches: the phone keeps its map clear and shows every layer (the picked card still opens). */
|
||||
hideLayerControl?: boolean;
|
||||
/** How far from the map's top the switches sit, clear of a bar laid over the map. */
|
||||
controlsTop?: number | string;
|
||||
/** Leads from a picked place or stay to its entry. */
|
||||
@@ -40,7 +40,7 @@ export default function JourneyLayeredMap({
|
||||
ref,
|
||||
layers,
|
||||
engine = 'auto',
|
||||
compactControls = false,
|
||||
hideLayerControl = false,
|
||||
controlsTop = 12,
|
||||
onOpenEntry,
|
||||
entryTitleOf,
|
||||
@@ -88,9 +88,11 @@ export default function JourneyLayeredMap({
|
||||
className="pointer-events-none absolute inset-x-3 z-10 flex flex-col items-start gap-2"
|
||||
style={{ top: controlsTop }}
|
||||
>
|
||||
<div className="pointer-events-auto max-w-full">
|
||||
<JourneyMapLayerControl layers={layers} compact={compactControls} />
|
||||
</div>
|
||||
{!hideLayerControl && (
|
||||
<div className="pointer-events-auto max-w-full">
|
||||
<JourneyMapLayerControl layers={layers} />
|
||||
</div>
|
||||
)}
|
||||
{card && (
|
||||
<JourneyMapPickCard
|
||||
key={card.key}
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function MobileMapTimeline({
|
||||
ref={mapRef}
|
||||
engine="leaflet"
|
||||
layers={layers}
|
||||
compactControls
|
||||
hideLayerControl
|
||||
controlsTop={controlsTop}
|
||||
onOpenEntry={handleMarkerClick}
|
||||
entryTitleOf={entryTitleOf}
|
||||
@@ -155,7 +155,7 @@ export default function MobileMapTimeline({
|
||||
ref={mapRef}
|
||||
engine="leaflet"
|
||||
layers={layers}
|
||||
compactControls
|
||||
hideLayerControl
|
||||
controlsTop={controlsTop}
|
||||
onOpenEntry={handleMarkerClick}
|
||||
entryTitleOf={entryTitleOf}
|
||||
|
||||
@@ -111,6 +111,33 @@ describe('useJourneyMapLayers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-MAPLAYERS-HOOK-002b: the phone shows every layer whatever was kept, and keeps no switch', () => {
|
||||
vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
|
||||
places: { status: 'ok', items: [] },
|
||||
routes: { status: 'ok', items: [] },
|
||||
stays: { status: 'ok', items: [] },
|
||||
});
|
||||
localStorage.setItem('trek:journey-map-layers:8', JSON.stringify({ plan: false, entries: false }));
|
||||
const { result } = renderHook(() =>
|
||||
useJourneyMapLayers({
|
||||
source: { journeyId: 8 },
|
||||
entryIdsOnMap: NO_ENTRIES,
|
||||
photoCount: 0,
|
||||
colorOfDay,
|
||||
dateOfEntry,
|
||||
allLayers: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.visibility).toMatchObject({ plan: true, entries: true, recorded: true, photos: true });
|
||||
act(() => result.current.toggle('photos'));
|
||||
expect(result.current.visibility.photos).toBe(false);
|
||||
expect(JSON.parse(localStorage.getItem('trek:journey-map-layers:8') ?? '{}')).toEqual({
|
||||
plan: false,
|
||||
entries: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-MAPLAYERS-HOOK-003: a read that fails says so on the plan and the entries, and the recordings still draw', async () => {
|
||||
vi.spyOn(journeyMapRepo, 'layers').mockRejectedValue(new Error('down'));
|
||||
const recorded: JourneyTrack = {
|
||||
|
||||
@@ -50,6 +50,12 @@ interface Options {
|
||||
colorOfDay: (date: string) => string | null;
|
||||
/** The day an entry is on, for the colour of its stays. */
|
||||
dateOfEntry: (entryId: number) => string | null;
|
||||
/**
|
||||
* Every layer on, whatever this browser kept: the phone shows no switches,
|
||||
* so a layer once switched off there would stay off with no way back. A
|
||||
* switch that is still offered then holds only for the visit.
|
||||
*/
|
||||
allLayers?: boolean;
|
||||
}
|
||||
|
||||
const NO_TRACKS: JourneyTrack[] = [];
|
||||
@@ -119,13 +125,14 @@ export function useJourneyMapLayers({
|
||||
recordedRead = 'ready',
|
||||
colorOfDay,
|
||||
dateOfEntry,
|
||||
allLayers = false,
|
||||
}: Options): JourneyMapLayersView {
|
||||
const { t, locale } = useTranslation();
|
||||
const journeyId = source && 'journeyId' in source ? source.journeyId : null;
|
||||
const token = source && 'token' in source ? source.token : null;
|
||||
const storageKey =
|
||||
journeyId !== null ? layersStorageKey({ journeyId }) : token !== null ? layersStorageKey({ token }) : null;
|
||||
const { visibility, toggle } = useLayerSwitches(storageKey);
|
||||
const { visibility, toggle } = useLayerSwitches(allLayers ? null : storageKey);
|
||||
|
||||
const sourceKey = journeyId !== null ? `j:${journeyId}` : token !== null ? `s:${token}` : null;
|
||||
// A shared page reads once; a journey's own map only while the plan or the entries are shown.
|
||||
|
||||
@@ -1108,77 +1108,3 @@ describe('TripMembersModal', () => {
|
||||
view.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TripMembersModal on a phone', () => {
|
||||
let width: number;
|
||||
beforeEach(() => {
|
||||
width = window.innerWidth;
|
||||
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true, writable: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-058b: the public link toggles say whether they are on (#2604)', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||||
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({
|
||||
token: 'abc123', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false,
|
||||
})));
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Map & Plan' })).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-058: keeps the sheet it had, with "(you)" beside the name', async () => {
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
expect(await screen.findByText(/\(you\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Access \(1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-059: asks with the browser before a member goes, and only then removes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
|
||||
let deletes = 0;
|
||||
server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({ success: true }); }));
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
|
||||
await user.click(await screen.findByTitle('Remove access'));
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Remove access for this user?');
|
||||
expect(deletes).toBe(0);
|
||||
|
||||
confirmSpy.mockReturnValue(true);
|
||||
await user.click(screen.getByTitle('Remove access'));
|
||||
await waitFor(() => expect(deletes).toBe(1));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-060: a guest is renamed and removed through the same questions', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
mockRoster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]);
|
||||
let renamed: string | null = null;
|
||||
let deleted = false;
|
||||
server.use(
|
||||
http.put('/api/trips/1/guests/:userId', async ({ request }) => {
|
||||
renamed = ((await request.json()) as { name: string }).name;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.delete('/api/trips/1/guests/:userId', () => { deleted = true; return HttpResponse.json({ success: true }); }),
|
||||
);
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
|
||||
await user.click(await screen.findByTitle('Rename'));
|
||||
const input = screen.getByDisplayValue('Grandma');
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
await user.click(screen.getByTitle('Rename'));
|
||||
fireEvent.change(screen.getByDisplayValue('Grandma'), { target: { value: 'Granny' } });
|
||||
fireEvent.keyDown(screen.getByDisplayValue('Granny'), { key: 'Enter' });
|
||||
await waitFor(() => expect(renamed).toBe('Granny'));
|
||||
|
||||
await user.click(screen.getByTitle('Remove access'));
|
||||
await waitFor(() => expect(deleted).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,148 +1,4 @@
|
||||
import { TripJournalLinkSection } from './TripJournalLinkSection'
|
||||
import Modal from '../shared/Modal'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { TripMemberAvatar as Avatar } from './TripMemberAvatar'
|
||||
import TripShareDialog from './TripShareDialog'
|
||||
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers } from './useTripShare'
|
||||
|
||||
function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
const { loading, url: shareUrl, perms, copied, create: handleCreate, setPerm, remove: handleDelete, copy: handleCopy } = useShareLink(tripId)
|
||||
const handleUpdatePerms = (key: string, val: boolean) => setPerm(key as Parameters<typeof setPerm>[0], val)
|
||||
|
||||
if (loading) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Link2 size={14} className="text-content-muted" />
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('share.linkTitle')}</span>
|
||||
</div>
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginBottom: 10, lineHeight: 1.5 }}>{t('share.linkHint')}</p>
|
||||
|
||||
{/* Permission checkboxes */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
{[...SHARE_SECTIONS, ...SHARE_OPTIONS].map(opt => ({ ...opt, label: t(opt.label), always: 'always' in opt && !!opt.always })).map(opt => (
|
||||
<button type="button" key={opt.key} aria-pressed={!!perms[opt.key]} aria-disabled={opt.always || undefined} onClick={() => !opt.always && handleUpdatePerms(opt.key, !perms[opt.key])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 20,
|
||||
border: '1.5px solid', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, cursor: opt.always ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: perms[opt.key] ? 'var(--text-primary)' : 'transparent',
|
||||
borderColor: perms[opt.key] ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: perms[opt.key] ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
opacity: opt.always ? 0.7 : 1,
|
||||
}}>
|
||||
{perms[opt.key] ? <Check size={10} /> : null}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shareUrl ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="bg-surface-tertiary border border-edge-faint" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
}}>
|
||||
<input type="text" value={shareUrl} readOnly className="text-content" style={{
|
||||
flex: 1, border: 'none', background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
outline: 'none', fontFamily: 'monospace',
|
||||
}} />
|
||||
<button type="button" onClick={handleCopy} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
|
||||
border: 'none', background: copied ? '#16a34a' : 'var(--accent)', color: copied ? 'white' : 'var(--accent-text)',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
|
||||
}}>
|
||||
{copied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={handleDelete} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Trash2 size={11} /> {t('share.deleteLink')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={handleCreate} className="border border-dashed border-edge text-content-muted" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8,
|
||||
background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={12} /> {t('share.createLink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trip invite link (#1143). One rotating token per trip that an existing,
|
||||
* logged-in user opens to join the trip as a member. Mirrors ShareLinkSection
|
||||
* but the link points at /join/:token (login-required, no registration).
|
||||
*/
|
||||
function TripInviteLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
const { loading, url: inviteUrl, busy, copied, create, remove, copy } = useTripInviteLink(tripId)
|
||||
|
||||
if (loading) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 20, paddingTop: 20, borderTop: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<UserPlus size={14} className="text-content-muted" />
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('trip.invite.linkTitle')}</span>
|
||||
</div>
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginBottom: 12, lineHeight: 1.5 }}>{t('trip.invite.linkHint')}</p>
|
||||
|
||||
{inviteUrl ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="bg-surface-tertiary border border-edge-faint" style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', borderRadius: 8 }}>
|
||||
<input type="text" value={inviteUrl} readOnly className="text-content" style={{ flex: 1, border: 'none', background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', outline: 'none', fontFamily: 'monospace' }} />
|
||||
<button type="button" onClick={copy} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
|
||||
border: 'none', background: copied ? '#16a34a' : 'var(--accent)', color: copied ? 'white' : 'var(--accent-text)',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
|
||||
}}>
|
||||
{copied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={create} disabled={busy} className="border border-edge text-content-muted" style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={11} /> {t('trip.invite.regenerate')}
|
||||
</button>
|
||||
<button type="button" onClick={remove} disabled={busy} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Trash2 size={11} /> {t('trip.invite.disable')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={create} disabled={busy} className="border border-dashed border-edge text-content-muted" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8,
|
||||
background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<UserPlus size={12} /> {t('trip.invite.create')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TripMembersModalProps {
|
||||
isOpen: boolean
|
||||
@@ -154,259 +10,7 @@ interface TripMembersModalProps {
|
||||
onMembersChanged?: () => void
|
||||
}
|
||||
|
||||
/** Share: the new dialog on the desktop; the phone, which opens this from its More sheet, keeps the sheet below. */
|
||||
/** Share: the trip's members, guests and links. The phone has its own sheet (MTripSettingsSheet) on the same hooks. */
|
||||
export default function TripMembersModal(props: TripMembersModalProps) {
|
||||
const isPhone = useIsPhone()
|
||||
return isPhone ? <TripMembersSheet {...props} /> : <TripShareDialog {...props} />
|
||||
}
|
||||
|
||||
function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged }: TripMembersModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const m = useTripMembers({ isOpen, tripId, onClose, onMembersChanged })
|
||||
const {
|
||||
user, loading, realMembers, guests, allUsers, availableUsers, isCurrentOwner, canManageMembers, canManageShare,
|
||||
selectedUserId, setSelectedUserId, adding, transferringId, removingId,
|
||||
newGuestName, setNewGuestName, addingGuest, renamingGuestId, renameValue, setRenameValue,
|
||||
} = m
|
||||
const handleAdd = () => void m.add()
|
||||
const handleAddGuest = () => void m.addGuest()
|
||||
const handleRenameGuest = (userId: number) => void m.commitRename(userId)
|
||||
const handleTransfer = (newOwnerId: number, username: string) => {
|
||||
if (confirm(t('members.confirmTransfer', { name: username }))) void m.transfer(newOwnerId)
|
||||
}
|
||||
const handleRemove = (userId: number, isSelf: boolean) => {
|
||||
if (confirm(isSelf ? t('members.confirmLeave') : t('members.confirmRemove'))) void m.remove(userId, isSelf)
|
||||
}
|
||||
const handleDeleteGuest = (userId: number) => {
|
||||
if (confirm(t('members.confirmRemoveGuest'))) void m.removeGuest(userId)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('trip.settings.title')} size="3xl">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
|
||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||
|
||||
{/* Left column: Members */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
|
||||
{/* Trip name */}
|
||||
<div className="bg-surface-secondary border border-edge-secondary" style={{ padding: '10px 14px', borderRadius: 10 }}>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>{t('nav.trip')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{tripTitle}</div>
|
||||
</div>
|
||||
|
||||
{/* Add member dropdown */}
|
||||
{canManageMembers && <div>
|
||||
<label className="text-content-secondary" style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, marginBottom: 8 }}>
|
||||
{t('members.inviteUser')}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<CustomSelect
|
||||
value={selectedUserId}
|
||||
onChange={value => setSelectedUserId(String(value))}
|
||||
placeholder={t('members.selectUser')}
|
||||
options={[
|
||||
{ value: '', label: t('members.selectUser') },
|
||||
// As a string: the select compares values strictly, and the chosen id is
|
||||
// kept as a string, so a number here left the trigger on its placeholder.
|
||||
...availableUsers.map(u => ({
|
||||
value: String(u.id),
|
||||
label: u.username,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
style={{ flex: 1 }}
|
||||
size="sm"
|
||||
/>
|
||||
<button type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={adding || !selectedUserId}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: adding || !selectedUserId ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
|
||||
</button>
|
||||
</div>
|
||||
{availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && (
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* Members list */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Users size={13} className="text-content-faint" />
|
||||
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
|
||||
{t('members.access')} ({realMembers.length} {t('members.persons', { count: realMembers.length })})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="bg-surface-tertiary" style={{ height: 48, borderRadius: 10, animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{realMembers.map(member => {
|
||||
const isSelf = member.id === user?.id
|
||||
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
|
||||
return (
|
||||
<div key={member.id} className="bg-surface-secondary border border-edge-secondary" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 12px', borderRadius: 10,
|
||||
}}>
|
||||
<Avatar username={member.username} avatarUrl={member.avatar_url} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{member.username}</span>
|
||||
{isSelf && <span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }}>({t('members.you')})</span>}
|
||||
{member.role === 'owner' && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#d97706', background: '#fef9c3', padding: '1px 6px', borderRadius: 99 }}>
|
||||
<Crown size={9} /> {t('members.owner')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentOwner && member.role !== 'owner' && (
|
||||
<button type="button"
|
||||
onClick={() => handleTransfer(member.id, member.username)}
|
||||
disabled={transferringId === member.id}
|
||||
title={t('members.makeOwner')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: transferringId === member.id ? 0.4 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#d97706'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
<Crown size={14} />
|
||||
</button>
|
||||
)}
|
||||
{canRemove && (
|
||||
<button type="button"
|
||||
onClick={() => handleRemove(member.id, isSelf)}
|
||||
disabled={removingId === member.id}
|
||||
title={isSelf ? t('members.leaveTrip') : t('members.removeAccess')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: removingId === member.id ? 0.4 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
{isSelf ? <LogOut size={14} /> : <UserMinus size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guests (#1362) — accountless participants, managed by the owner */}
|
||||
{(isCurrentOwner || guests.length > 0) && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<UserRound size={13} className="text-content-faint" />
|
||||
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
|
||||
{t('members.guests')}{guests.length > 0 ? ` (${guests.length})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', margin: '0 0 10px', lineHeight: 1.5 }}>{t('members.guestsHint')}</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{guests.map(g => (
|
||||
<div key={g.id} className="bg-surface-secondary border border-edge-secondary" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', borderRadius: 10,
|
||||
}}>
|
||||
<Avatar username={g.username} avatarUrl={null} />
|
||||
{renamingGuestId === g.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameGuest(g.id); if (e.key === 'Escape') m.cancelRename() }}
|
||||
onBlur={() => handleRenameGuest(g.id)}
|
||||
maxLength={50}
|
||||
className="bg-surface border border-edge text-content"
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 8px', borderRadius: 8, outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{g.username}</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99 }}>
|
||||
<UserRound size={9} /> {t('members.guest')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isCurrentOwner && renamingGuestId !== g.id && (
|
||||
<>
|
||||
<button type="button"
|
||||
onClick={() => m.startRename(g)}
|
||||
title={t('common.rename')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleDeleteGuest(g.id)}
|
||||
disabled={removingId === g.id}
|
||||
title={t('members.removeAccess')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: removingId === g.id ? 0.4 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isCurrentOwner && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: guests.length > 0 ? 8 : 0 }}>
|
||||
<input
|
||||
value={newGuestName}
|
||||
onChange={e => setNewGuestName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddGuest() }}
|
||||
placeholder={t('members.guestNamePlaceholder')}
|
||||
maxLength={50}
|
||||
className="bg-surface border border-edge text-content"
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '8px 10px', borderRadius: 10, outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
<button type="button"
|
||||
onClick={handleAddGuest}
|
||||
disabled={addingGuest || !newGuestName.trim()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
|
||||
background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: addingGuest || !newGuestName.trim() ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: addingGuest || !newGuestName.trim() ? 0.4 : 1, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Plus size={13} /> {addingGuest ? '…' : t('members.addGuest')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right column: Share Link */}
|
||||
{canManageShare && <div className="border-s border-edge-faint" style={{ paddingInlineStart: 24 }}>
|
||||
<ShareLinkSection tripId={tripId} t={t} />
|
||||
<TripInviteLinkSection tripId={tripId} t={t} />
|
||||
</div>}
|
||||
|
||||
<TripJournalLinkSection tripId={tripId} />
|
||||
|
||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
return <TripShareDialog {...props} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user