refactor(journey): share the journey settings logic between desktop and phone

useJourneySettings holds the name, cover, archive, the switches saved on the
spot, delete, unlinking a trip and removing a contributor for both the settings
dialog and the settings sheet. useJourneyTripLinking loads the linkable trips
and links one for the add trip dialog and the sheet, and useJourneyShareLink
manages the public share link for the share section and the sheet. The dialog
still closes after a new cover or an unlinked trip, the sheet still stays open.
This commit is contained in:
Maurice
2026-10-09 10:54:55 +02:00
parent e40bc98a33
commit 78ddb1b0b8
8 changed files with 785 additions and 450 deletions
@@ -1,11 +1,10 @@
import { useEffect, useId, useState } from 'react'
import { useId, useState } from 'react'
import { Link2, Search } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { journeyApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { INPUT } from '../shared/dialogParts'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { useJourneyTripLinking } from './useJourneySettings'
export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
journeyId: number
@@ -15,14 +14,9 @@ export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }:
}) {
const { t } = useTranslation()
const labelId = useId()
const [trips, setTrips] = useState<{ id: number; title: string; destination?: string; start_date?: string; end_date?: string }[]>([])
const [search, setSearch] = useState('')
const [adding, setAdding] = useState<number | null>(null)
const toast = useToast()
useEffect(() => {
journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {})
}, [])
const { availableTrips: trips, linkingTripId: adding, linkTrip: handleAdd } =
useJourneyTripLinking({ journeyId, onLinked: onAdded, loadOnMount: true })
const filtered = trips.filter(trip => {
if (existingTripIds.includes(trip.id)) return false
@@ -31,19 +25,6 @@ export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }:
return trip.title.toLowerCase().includes(q) || (trip.destination || '').toLowerCase().includes(q)
})
const handleAdd = async (tripId: number) => {
setAdding(tripId)
try {
await journeyApi.addTrip(journeyId, tripId)
toast.success(t('journey.trips.tripLinked'))
onAdded()
} catch {
toast.error(t('journey.trips.linkFailed'))
} finally {
setAdding(null)
}
}
return (
<DialogShell
onClose={onClose}
@@ -1,19 +1,15 @@
import { useId, useState, useRef, type ChangeEvent, type ReactNode } from 'react'
import { useNavigate } from 'react-router'
import { useId, useState, useRef, type ReactNode } from 'react'
import { X, ImagePlus, Trash2, Archive, ArchiveRestore, Undo2, Settings, Crown } from 'lucide-react'
import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n'
import { journeyApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
import JourneyShareSection from './JourneyShareSection'
import { useJourneySettings } from './useJourneySettings'
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
import { normalizeImageFile } from '../../utils/convertHeic'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { TripMemberAvatar } from '../Trips/TripMemberAvatar'
import { avatarSrc } from '../../utils/avatarSrc'
@@ -46,155 +42,17 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
}) {
const { t } = useTranslation()
const labelId = useId()
const [title, setTitle] = useState(journey.title)
const [subtitle, setSubtitle] = useState(journey.subtitle || '')
const [saving, setSaving] = useState(false)
const {
title, setTitle, subtitle, setSubtitle, saving, isDirty, archiving, unlinkTarget, setUnlinkTarget,
showDeleteConfirm, setShowDeleteConfirm, savingTracks, savingField, savingPhotoLocation, savingStatus, statusChoice,
handleSave, handleCoverUpload, handleArchiveToggle, handleTracksToggle, handleFieldToggle, handlePhotoLocationToggle,
handleStatusChange, handleDelete, handleRemoveContributor, confirmUnlink,
} = useJourneySettings({ journey, onSaved, onRefresh, onContentChanged: onSaved })
const [showAddTrip, setShowAddTrip] = useState(false)
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '')
const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() }
const coverRef = useRef<HTMLInputElement>(null)
const toast = useToast()
const navigate = useNavigate()
const { updateJourney, deleteJourney } = useJourneyStore()
const handleSave = async () => {
setSaving(true)
try {
await updateJourney(journey.id, { title, subtitle: subtitle || null })
onSaved()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSaving(false)
}
}
const handleCoverUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append('cover', await normalizeImageFile(file))
try {
await journeyApi.uploadCover(journey.id, formData)
toast.success(t('journey.settings.coverUpdated'))
onSaved()
} catch {
toast.error(t('journey.settings.coverFailed'))
}
}
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [archiving, setArchiving] = useState(false)
const handleArchiveToggle = async () => {
setArchiving(true)
try {
const newStatus = journey.status === 'archived' ? 'active' : 'archived'
await updateJourney(journey.id, { status: newStatus })
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'))
onSaved()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setArchiving(false)
}
}
// Saved on the spot rather than on Save, like the archive switch above it:
// it is a view setting, and the point of it is seeing the map change (#2194).
const [savingTracks, setSavingTracks] = useState(false)
const handleTracksToggle = async () => {
setSavingTracks(true)
try {
await updateJourney(journey.id, { show_trip_tracks: !journey.show_trip_tracks })
// onRefresh, not onSaved: onSaved closes the dialog, which would destroy the
// switch the moment it is flipped AND skip handleClose's unsaved-changes
// guard, silently dropping a title the owner had typed but not saved yet.
onRefresh()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSavingTracks(false)
}
}
/**
* Turn one of the optional entry fields off for this journey.
*
* Saved on the spot for the same reason the tracks switch is, and through
* `onRefresh` rather than `onSaved` so flipping a switch does not close the
* dialog out from under a half-typed title.
*
* Nothing is erased: an entry that already carries a mood keeps it in the
* database, the form simply stops asking. Switching back on brings it into
* view again.
*/
const [savingField, setSavingField] = useState<string | null>(null)
const handleFieldToggle = async (field: 'show_verdict' | 'show_mood' | 'show_weather') => {
setSavingField(field)
try {
await updateJourney(journey.id, { [field]: journey[field] === 0 })
onRefresh()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSavingField(null)
}
}
// Entries placed from their photos (#1003), saved on the spot like the tracks switch.
const [savingPhotoLocation, setSavingPhotoLocation] = useState(false)
const handlePhotoLocationToggle = async () => {
setSavingPhotoLocation(true)
try {
await updateJourney(journey.id, { photo_location: !journey.photo_location })
onRefresh()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSavingPhotoLocation(false)
}
}
// The state shown for the journey, set by hand (#762). Saved on the spot like
// the switches; 'auto' stores null and hands it back to the trip dates.
const [savingStatus, setSavingStatus] = useState(false)
const statusChoice = journey.status_override ?? 'auto'
const handleStatusChange = async (next: 'auto' | 'draft' | 'live' | 'completed') => {
if (next === statusChoice || savingStatus) return
setSavingStatus(true)
try {
await updateJourney(journey.id, { status_override: next === 'auto' ? null : next })
onRefresh()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSavingStatus(false)
}
}
const handleDelete = async () => {
try {
await deleteJourney(journey.id)
navigate('/journey')
} catch {
toast.error(t('journey.settings.failedToDelete'))
}
}
const handleRemoveContributor = async (c: JourneyContributor) => {
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return
try {
await journeyApi.removeContributor(journey.id, c.user_id)
toast.success(t('journey.contributors.removed'))
onRefresh()
} catch {
toast.error(t('journey.contributors.removeFailed'))
}
}
const archived = journey.status === 'archived'
const archiveLabel = archived ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')
@@ -428,17 +286,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
<ConfirmDialog
isOpen={!!unlinkTarget}
onClose={() => setUnlinkTarget(null)}
onConfirm={async () => {
if (!unlinkTarget) return
try {
await journeyApi.removeTrip(journey.id, unlinkTarget.trip_id)
toast.success(t('journey.trips.tripUnlinked'))
setUnlinkTarget(null)
onSaved()
} catch {
toast.error(t('journey.trips.unlinkFailed'))
}
}}
onConfirm={confirmUnlink}
title={t('journey.trips.unlinkTrip')}
message={t('journey.trips.unlinkMessage', { title: unlinkTarget?.title })}
confirmLabel={t('journey.trips.unlink')}
@@ -1,64 +1,10 @@
import { useEffect, useState } from 'react'
import { Link, List, Grid, MapPin, Check } from 'lucide-react'
import { journeyApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { copyText } from '../../utils/clipboard'
import { useJourneyShareLink } from './useJourneyShareLink'
export default function JourneyShareSection({ journeyId }: { journeyId: number }) {
const { t } = useTranslation()
const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
/** The share token is the owner's to manage; a contributor is refused. */
const [manageable, setManageable] = useState(true)
const toast = useToast()
useEffect(() => {
journeyApi.getShareLink(journeyId)
.then(d => setLink(d.link || null))
.catch((err: { response?: { status?: number } }) => {
// A 403 is the server saying this is not yours to manage. Showing the
// section anyway would offer an editor a "create link" button that is
// refused the moment they press it.
if (err?.response?.status === 403) setManageable(false)
})
.finally(() => setLoading(false))
}, [journeyId])
const createLink = async () => {
try {
const res = await journeyApi.createShareLink(journeyId, { share_timeline: true, share_gallery: true, share_map: true })
setLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true })
toast.success(t('journey.share.linkCreated'))
} catch { toast.error(t('journey.share.createFailed')) }
}
const togglePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
if (!link) return
const previous = link
const updated = { ...link, [key]: !link[key] }
setLink(updated)
try {
await journeyApi.createShareLink(journeyId, { share_timeline: updated.share_timeline, share_gallery: updated.share_gallery, share_map: updated.share_map })
} catch { setLink(previous); toast.error(t('journey.share.updateFailed')) }
}
const deleteLink = async () => {
try {
await journeyApi.deleteShareLink(journeyId)
setLink(null)
toast.success(t('journey.share.linkDeleted'))
} catch { toast.error(t('journey.share.deleteFailed')) }
}
const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
const copyLink = async () => {
if (!(await copyText(shareUrl))) return
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const { link, loading, manageable, copied, shareUrl, createLink, togglePerm, deleteLink, copyLink } = useJourneyShareLink(journeyId)
if (loading || !manageable) return null
@@ -0,0 +1,252 @@
// FE-JRN-SETTINGSHOOK-001 to FE-JRN-SETTINGSHOOK-014: the journey settings logic behind
// both the desktop dialog and the phone sheet, and the trip linking behind the desktop
// add trip dialog and the phone sheet.
import { act, renderHook, waitFor } from '@testing-library/react';
import type React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { journeyApi } from '../../api/client';
import { useJourneyStore, type JourneyContributor, type JourneyDetail } from '../../store/journeyStore';
import { useJourneySettings, useJourneyTripLinking, type JourneySettingsOptions } from './useJourneySettings';
const mockNavigate = vi.fn();
vi.mock('react-router', async () => {
const actual = await vi.importActual<typeof import('react-router')>('react-router');
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (k: string, p?: Record<string, unknown>) => (p ? `${k}:${JSON.stringify(p)}` : k),
}),
}));
vi.mock('../../utils/convertHeic', () => ({ normalizeImageFile: (f: File) => Promise.resolve(f) }));
const updateJourney = vi.fn(async (_id: number, _data: Record<string, unknown>) => {});
const deleteJourney = vi.fn(async (_id: number) => {});
let addToast: Mock<NonNullable<Window['__addToast']>>;
function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
return {
id: 3,
user_id: 1,
title: 'Italy 2026',
subtitle: 'Rome',
status: 'active',
cover_image: null,
cover_gradient: null,
created_at: 0,
updated_at: 0,
entries: [],
gallery: [],
trips: [],
contributors: [],
stats: { entries: 0, photos: 0, places: 0 },
...overrides,
};
}
function setup(over: Partial<JourneySettingsOptions> = {}) {
const props: JourneySettingsOptions = {
journey: buildJourney(),
onSaved: vi.fn(),
onRefresh: vi.fn(),
onContentChanged: vi.fn(),
...over,
};
const hook = renderHook((p: JourneySettingsOptions) => useJourneySettings(p), { initialProps: props });
return { ...hook, props };
}
function fileEvent(file: File | undefined) {
return { target: { files: file ? [file] : [] } } as unknown as React.ChangeEvent<HTMLInputElement>;
}
beforeEach(() => {
updateJourney.mockReset().mockResolvedValue(undefined);
deleteJourney.mockReset().mockResolvedValue(undefined);
mockNavigate.mockClear();
useJourneyStore.setState({ updateJourney, deleteJourney });
addToast = vi.fn<NonNullable<Window['__addToast']>>();
window.__addToast = addToast;
});
afterEach(() => {
vi.restoreAllMocks();
delete window.__addToast;
});
describe('useJourneySettings', () => {
it('FE-JRN-SETTINGSHOOK-001: seeds the name, tracks dirtiness and saves it', async () => {
const { result, props } = setup();
expect(result.current.isDirty).toBe(false);
act(() => {
result.current.setTitle('Italy 2027');
result.current.setSubtitle('');
});
expect(result.current.isDirty).toBe(true);
await act(() => result.current.handleSave());
expect(updateJourney).toHaveBeenCalledWith(3, { title: 'Italy 2027', subtitle: null });
expect(props.onSaved).toHaveBeenCalledTimes(1);
expect(result.current.saving).toBe(false);
});
it('FE-JRN-SETTINGSHOOK-002: a failed save toasts and keeps the shell open', async () => {
updateJourney.mockRejectedValueOnce(new Error('x'));
const { result, props } = setup();
await act(() => result.current.handleSave());
expect(addToast).toHaveBeenCalledWith('journey.settings.saveFailed', 'error', undefined);
expect(props.onSaved).not.toHaveBeenCalled();
});
it('FE-JRN-SETTINGSHOOK-003: a new cover goes up and hands over to onContentChanged', async () => {
const upload = vi.spyOn(journeyApi, 'uploadCover').mockResolvedValueOnce({}).mockRejectedValueOnce(new Error('x'));
const { result, props } = setup();
await act(() => result.current.handleCoverUpload(fileEvent(undefined)));
expect(upload).not.toHaveBeenCalled();
const file = new File(['x'], 'c.png');
await act(() => result.current.handleCoverUpload(fileEvent(file)));
expect((upload.mock.calls[0][1] as FormData).get('cover')).toBe(file);
expect(addToast).toHaveBeenCalledWith('journey.settings.coverUpdated', 'success', undefined);
expect(props.onContentChanged).toHaveBeenCalledTimes(1);
await act(() => result.current.handleCoverUpload(fileEvent(file)));
expect(addToast).toHaveBeenLastCalledWith('journey.settings.coverFailed', 'error', undefined);
expect(props.onContentChanged).toHaveBeenCalledTimes(1);
});
it('FE-JRN-SETTINGSHOOK-004: archiving flips the status both ways', async () => {
const active = setup();
await act(() => active.result.current.handleArchiveToggle());
expect(updateJourney).toHaveBeenLastCalledWith(3, { status: 'archived' });
expect(addToast).toHaveBeenLastCalledWith('journey.settings.archived', 'success', undefined);
expect(active.props.onSaved).toHaveBeenCalledTimes(1);
const archived = setup({ journey: buildJourney({ status: 'archived' }) });
await act(() => archived.result.current.handleArchiveToggle());
expect(updateJourney).toHaveBeenLastCalledWith(3, { status: 'active' });
expect(addToast).toHaveBeenLastCalledWith('journey.settings.reopened', 'success', undefined);
});
it('FE-JRN-SETTINGSHOOK-005: the tracks switch takes the new value or flips the current one, and only refreshes', async () => {
const { result, props } = setup({ journey: buildJourney({ show_trip_tracks: 1 }) });
await act(() => result.current.handleTracksToggle());
expect(updateJourney).toHaveBeenLastCalledWith(3, { show_trip_tracks: false });
await act(() => result.current.handleTracksToggle(true));
expect(updateJourney).toHaveBeenLastCalledWith(3, { show_trip_tracks: true });
expect(props.onRefresh).toHaveBeenCalledTimes(2);
expect(props.onSaved).not.toHaveBeenCalled();
expect(result.current.savingTracks).toBe(false);
});
it('FE-JRN-SETTINGSHOOK-006: an entry field switch takes the new value or flips the stored one', async () => {
const { result } = setup({ journey: buildJourney({ show_mood: 0 } as Partial<JourneyDetail>) });
await act(() => result.current.handleFieldToggle('show_mood'));
expect(updateJourney).toHaveBeenLastCalledWith(3, { show_mood: true });
await act(() => result.current.handleFieldToggle('show_weather', false));
expect(updateJourney).toHaveBeenLastCalledWith(3, { show_weather: false });
updateJourney.mockRejectedValueOnce(new Error('x'));
await act(() => result.current.handleFieldToggle('show_verdict'));
expect(addToast).toHaveBeenLastCalledWith('journey.settings.saveFailed', 'error', undefined);
expect(result.current.savingField).toBeNull();
});
it('FE-JRN-SETTINGSHOOK-007: photo locations flip on the spot', async () => {
const { result, props } = setup();
await act(() => result.current.handlePhotoLocationToggle());
expect(updateJourney).toHaveBeenCalledWith(3, { photo_location: true });
expect(props.onRefresh).toHaveBeenCalledTimes(1);
});
it('FE-JRN-SETTINGSHOOK-008: the shown status is auto by default, stored as null, and the same choice is a no op', async () => {
const { result } = setup();
expect(result.current.statusChoice).toBe('auto');
await act(() => result.current.handleStatusChange('auto'));
expect(updateJourney).not.toHaveBeenCalled();
await act(() => result.current.handleStatusChange('live'));
expect(updateJourney).toHaveBeenCalledWith(3, { status_override: 'live' });
const live = setup({ journey: buildJourney({ status_override: 'live' } as Partial<JourneyDetail>) });
await act(() => live.result.current.handleStatusChange('auto'));
expect(updateJourney).toHaveBeenLastCalledWith(3, { status_override: null });
});
it('FE-JRN-SETTINGSHOOK-009: deleting leaves for the journey list, a failure toasts', async () => {
const { result } = setup();
await act(() => result.current.handleDelete());
expect(deleteJourney).toHaveBeenCalledWith(3);
expect(mockNavigate).toHaveBeenCalledWith('/journey');
deleteJourney.mockRejectedValueOnce(new Error('x'));
await act(() => result.current.handleDelete());
expect(addToast).toHaveBeenCalledWith('journey.settings.failedToDelete', 'error', undefined);
expect(mockNavigate).toHaveBeenCalledTimes(1);
});
it('FE-JRN-SETTINGSHOOK-010: removing a contributor asks first', async () => {
const remove = vi.spyOn(journeyApi, 'removeContributor').mockResolvedValue({});
const confirm = vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true);
const { result, props } = setup();
const c = { journey_id: 3, user_id: 2, role: 'editor', added_at: 0, username: 'julien' } as JourneyContributor;
await act(() => result.current.handleRemoveContributor(c));
expect(remove).not.toHaveBeenCalled();
await act(() => result.current.handleRemoveContributor(c));
expect(confirm).toHaveBeenLastCalledWith('journey.contributors.removeConfirm:{"username":"julien"}');
expect(remove).toHaveBeenCalledWith(3, 2);
expect(addToast).toHaveBeenCalledWith('journey.contributors.removed', 'success', undefined);
expect(props.onRefresh).toHaveBeenCalledTimes(1);
});
it('FE-JRN-SETTINGSHOOK-011: unlinking the picked trip clears the pick and hands over, a failure keeps it', async () => {
const remove = vi.spyOn(journeyApi, 'removeTrip').mockRejectedValueOnce(new Error('x')).mockResolvedValueOnce({});
const { result, props } = setup();
await act(() => result.current.confirmUnlink());
expect(remove).not.toHaveBeenCalled();
act(() => result.current.setUnlinkTarget({ trip_id: 5, title: 'Italy Trip' }));
await act(() => result.current.confirmUnlink());
expect(addToast).toHaveBeenCalledWith('journey.trips.unlinkFailed', 'error', undefined);
expect(result.current.unlinkTarget).not.toBeNull();
await act(() => result.current.confirmUnlink());
expect(remove).toHaveBeenLastCalledWith(3, 5);
expect(result.current.unlinkTarget).toBeNull();
expect(props.onContentChanged).toHaveBeenCalledTimes(1);
});
});
describe('useJourneyTripLinking', () => {
it('FE-JRN-SETTINGSHOOK-012: the desktop picker loads the trips on mount, the phone sheet when asked', async () => {
const available = vi.spyOn(journeyApi, 'availableTrips').mockResolvedValue({ trips: [{ id: 9, title: 'Oslo' }] });
const eager = renderHook(() => useJourneyTripLinking({ journeyId: 3, onLinked: vi.fn(), loadOnMount: true }));
await waitFor(() => expect(eager.result.current.availableTrips).toEqual([{ id: 9, title: 'Oslo' }]));
const lazy = renderHook(() => useJourneyTripLinking({ journeyId: 3, onLinked: vi.fn() }));
expect(available).toHaveBeenCalledTimes(1);
await act(() => lazy.result.current.loadAvailableTrips());
expect(lazy.result.current.availableTrips).toEqual([{ id: 9, title: 'Oslo' }]);
});
it('FE-JRN-SETTINGSHOOK-013: a failed lookup leaves the list empty', async () => {
vi.spyOn(journeyApi, 'availableTrips').mockRejectedValue(new Error('x'));
const { result } = renderHook(() => useJourneyTripLinking({ journeyId: 3, onLinked: vi.fn() }));
await act(() => result.current.loadAvailableTrips());
expect(result.current.availableTrips).toEqual([]);
});
it('FE-JRN-SETTINGSHOOK-014: linking toasts and hands over, a failure only toasts', async () => {
const add = vi.spyOn(journeyApi, 'addTrip').mockResolvedValueOnce({}).mockRejectedValueOnce(new Error('x'));
const onLinked = vi.fn();
const { result } = renderHook(() => useJourneyTripLinking({ journeyId: 3, onLinked }));
await act(() => result.current.linkTrip(9));
expect(add).toHaveBeenCalledWith(3, 9);
expect(addToast).toHaveBeenCalledWith('journey.trips.tripLinked', 'success', undefined);
expect(onLinked).toHaveBeenCalledTimes(1);
await act(() => result.current.linkTrip(9));
expect(addToast).toHaveBeenLastCalledWith('journey.trips.linkFailed', 'error', undefined);
expect(onLinked).toHaveBeenCalledTimes(1);
expect(result.current.linkingTripId).toBeNull();
});
});
@@ -0,0 +1,273 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { journeyApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useJourneyStore, type JourneyContributor, type JourneyDetail } from '../../store/journeyStore';
import { normalizeImageFile } from '../../utils/convertHeic';
import { useToast } from '../shared/Toast';
export type JourneyEntryField = 'show_verdict' | 'show_mood' | 'show_weather';
export type JourneyStatusChoice = 'auto' | 'draft' | 'live' | 'completed';
export interface AvailableTrip {
id: number;
title: string;
destination?: string;
start_date?: string;
end_date?: string;
}
export interface JourneySettingsOptions {
journey: JourneyDetail;
/** Saved: the shell closes and reloads. */
onSaved: () => void;
/** Reload without closing, so a half typed title survives a switch being flipped. */
onRefresh: () => void;
/**
* Called once a new cover or an unlinked trip went through: the desktop dialog
* passes onSaved (it closes), the phone sheet onRefresh (it stays open).
*/
onContentChanged: () => void;
}
/**
* A journey's settings, behind both the desktop dialog and the phone sheet, which render
* their own markup over it: name and subtitle, cover, the switches that are saved on the
* spot (trip tracks, the optional entry fields, photo locations, the status shown),
* archive, delete, unlinking a trip and removing a contributor.
*/
export function useJourneySettings({ journey, onSaved, onRefresh, onContentChanged }: JourneySettingsOptions) {
const { t } = useTranslation();
const toast = useToast();
const navigate = useNavigate();
const { updateJourney, deleteJourney } = useJourneyStore();
const [title, setTitle] = useState(journey.title);
const [subtitle, setSubtitle] = useState(journey.subtitle || '');
const [saving, setSaving] = useState(false);
const [archiving, setArchiving] = useState(false);
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [savingTracks, setSavingTracks] = useState(false);
const [savingField, setSavingField] = useState<string | null>(null);
const [savingPhotoLocation, setSavingPhotoLocation] = useState(false);
const [savingStatus, setSavingStatus] = useState(false);
const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '');
const handleSave = async () => {
setSaving(true);
try {
await updateJourney(journey.id, { title, subtitle: subtitle || null });
onSaved();
} catch {
toast.error(t('journey.settings.saveFailed'));
} finally {
setSaving(false);
}
};
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('cover', await normalizeImageFile(file));
try {
await journeyApi.uploadCover(journey.id, formData);
toast.success(t('journey.settings.coverUpdated'));
onContentChanged();
} catch {
toast.error(t('journey.settings.coverFailed'));
}
};
const handleArchiveToggle = async () => {
setArchiving(true);
try {
const newStatus = journey.status === 'archived' ? 'active' : 'archived';
await updateJourney(journey.id, { status: newStatus });
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'));
onSaved();
} catch {
toast.error(t('journey.settings.saveFailed'));
} finally {
setArchiving(false);
}
};
// The switches below are saved on the spot rather than on Save, like the archive
// button: they are view settings, and the point of them is seeing the journey change
// (#2194). Through onRefresh, not onSaved: onSaved closes the shell the moment a
// switch is flipped and would drop a title the owner has typed but not saved yet.
// Nothing an entry field stored is erased when it is switched off (#2299): the form
// stops asking, the values stay, and switching back on brings them into view.
/** `next` is the switch's new value; without one the current value flips. */
const handleTracksToggle = async (next: boolean = !journey.show_trip_tracks) => {
setSavingTracks(true);
try {
await updateJourney(journey.id, { show_trip_tracks: next });
onRefresh();
} catch {
toast.error(t('journey.settings.saveFailed'));
} finally {
setSavingTracks(false);
}
};
/** `next` is the switch's new value; without one the current value flips. */
const handleFieldToggle = async (field: JourneyEntryField, next: boolean = journey[field] === 0) => {
setSavingField(field);
try {
await updateJourney(journey.id, { [field]: next });
onRefresh();
} catch {
toast.error(t('journey.settings.saveFailed'));
} finally {
setSavingField(null);
}
};
// Entries placed from their photos (#1003).
const handlePhotoLocationToggle = async () => {
setSavingPhotoLocation(true);
try {
await updateJourney(journey.id, { photo_location: !journey.photo_location });
onRefresh();
} catch {
toast.error(t('journey.settings.saveFailed'));
} finally {
setSavingPhotoLocation(false);
}
};
// The state shown for the journey, set by hand (#762); 'auto' stores null and hands
// it back to the trip dates.
const statusChoice: JourneyStatusChoice = journey.status_override ?? 'auto';
const handleStatusChange = async (next: JourneyStatusChoice) => {
if (next === statusChoice || savingStatus) return;
setSavingStatus(true);
try {
await updateJourney(journey.id, { status_override: next === 'auto' ? null : next });
onRefresh();
} catch {
toast.error(t('journey.settings.saveFailed'));
} finally {
setSavingStatus(false);
}
};
const handleDelete = async () => {
try {
await deleteJourney(journey.id);
navigate('/journey');
} catch {
toast.error(t('journey.settings.failedToDelete'));
}
};
const handleRemoveContributor = async (c: JourneyContributor) => {
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return;
try {
await journeyApi.removeContributor(journey.id, c.user_id);
toast.success(t('journey.contributors.removed'));
onRefresh();
} catch {
toast.error(t('journey.contributors.removeFailed'));
}
};
const confirmUnlink = async () => {
if (!unlinkTarget) return;
try {
await journeyApi.removeTrip(journey.id, unlinkTarget.trip_id);
toast.success(t('journey.trips.tripUnlinked'));
setUnlinkTarget(null);
onContentChanged();
} catch {
toast.error(t('journey.trips.unlinkFailed'));
}
};
return {
title,
setTitle,
subtitle,
setSubtitle,
saving,
isDirty,
archiving,
unlinkTarget,
setUnlinkTarget,
showDeleteConfirm,
setShowDeleteConfirm,
savingTracks,
savingField,
savingPhotoLocation,
savingStatus,
statusChoice,
handleSave,
handleCoverUpload,
handleArchiveToggle,
handleTracksToggle,
handleFieldToggle,
handlePhotoLocationToggle,
handleStatusChange,
handleDelete,
handleRemoveContributor,
confirmUnlink,
};
}
export interface JourneyTripLinkingOptions {
journeyId: number;
/** Called after a trip was linked (and its toast shown). */
onLinked: () => void;
/** Load the linkable trips as soon as the picker mounts (the desktop dialog). */
loadOnMount?: boolean;
}
/**
* Linking another trip to a journey, behind the desktop add trip dialog and the phone
* settings sheet: the trips the user could link, and the link itself.
*/
export function useJourneyTripLinking({ journeyId, onLinked, loadOnMount = false }: JourneyTripLinkingOptions) {
const { t } = useTranslation();
const toast = useToast();
const [availableTrips, setAvailableTrips] = useState<AvailableTrip[]>([]);
const [linkingTripId, setLinkingTripId] = useState<number | null>(null);
useEffect(() => {
if (!loadOnMount) return;
journeyApi
.availableTrips()
.then((d) => setAvailableTrips(d.trips || []))
.catch(() => {});
}, [loadOnMount]);
const loadAvailableTrips = async () => {
try {
const data = await journeyApi.availableTrips();
setAvailableTrips(data.trips || []);
} catch {
/* the list stays empty */
}
};
const linkTrip = async (tripId: number) => {
setLinkingTripId(tripId);
try {
await journeyApi.addTrip(journeyId, tripId);
toast.success(t('journey.trips.tripLinked'));
onLinked();
} catch {
toast.error(t('journey.trips.linkFailed'));
} finally {
setLinkingTripId(null);
}
};
return { availableTrips, linkingTripId, loadAvailableTrips, linkTrip };
}
@@ -0,0 +1,126 @@
// FE-JRN-SHAREHOOK-001 to FE-JRN-SHAREHOOK-008: the public share link logic behind
// both the desktop share section and the phone settings sheet.
import { act, renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { journeyShareUrl, useJourneyShareLink } from './useJourneyShareLink';
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
const LINK = { token: 'tok', share_timeline: true, share_gallery: false, share_map: true };
let addToast: Mock<NonNullable<Window['__addToast']>>;
let posted: unknown[];
beforeEach(() => {
addToast = vi.fn<NonNullable<Window['__addToast']>>();
window.__addToast = addToast;
posted = [];
server.use(
http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: LINK })),
http.post('/api/journeys/7/share-link', async ({ request }) => {
posted.push(await request.json());
return HttpResponse.json({ token: 'fresh' });
}),
http.delete('/api/journeys/7/share-link', () => HttpResponse.json({ success: true }))
);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
delete window.__addToast;
});
async function loaded() {
const hook = renderHook(() => useJourneyShareLink(7));
await waitFor(() => expect(hook.result.current.loading).toBe(false));
return hook;
}
describe('journeyShareUrl', () => {
it('FE-JRN-SHAREHOOK-001: the public journey address of a token, empty without a link', () => {
expect(journeyShareUrl(LINK, 'https://trek.example')).toBe('https://trek.example/public/journey/tok');
expect(journeyShareUrl(null, 'https://trek.example')).toBe('');
});
});
describe('useJourneyShareLink', () => {
it('FE-JRN-SHAREHOOK-002: loads the existing link', async () => {
const { result } = await loaded();
expect(result.current.link).toEqual(LINK);
expect(result.current.manageable).toBe(true);
expect(result.current.shareUrl).toBe(`${window.location.origin}/public/journey/tok`);
});
it('FE-JRN-SHAREHOOK-003: a 403 marks the link as not this user to manage, other errors do not', async () => {
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'no' }, { status: 403 })));
const refused = await loaded();
expect(refused.result.current.manageable).toBe(false);
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'down' }, { status: 500 })));
const failed = await loaded();
expect(failed.result.current.manageable).toBe(true);
expect(failed.result.current.link).toBeNull();
});
it('FE-JRN-SHAREHOOK-004: creating shares every section', async () => {
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: null })));
const { result } = await loaded();
await act(() => result.current.createLink());
expect(posted).toEqual([{ share_timeline: true, share_gallery: true, share_map: true }]);
expect(result.current.link).toEqual({ token: 'fresh', share_timeline: true, share_gallery: true, share_map: true });
expect(addToast).toHaveBeenCalledWith('journey.share.linkCreated', 'success', undefined);
});
it('FE-JRN-SHAREHOOK-005: a failed create only toasts', async () => {
server.use(
http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: null })),
http.post('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'x' }, { status: 500 }))
);
const { result } = await loaded();
await act(() => result.current.createLink());
expect(result.current.link).toBeNull();
expect(addToast).toHaveBeenCalledWith('journey.share.createFailed', 'error', undefined);
});
it('FE-JRN-SHAREHOOK-006: a section switch saves the whole set and rolls back when refused', async () => {
const { result } = await loaded();
await act(() => result.current.togglePerm('share_gallery'));
expect(posted).toEqual([{ share_timeline: true, share_gallery: true, share_map: true }]);
expect(result.current.link?.share_gallery).toBe(true);
server.use(http.post('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'x' }, { status: 500 })));
await act(() => result.current.togglePerm('share_map'));
expect(result.current.link?.share_map).toBe(true);
expect(addToast).toHaveBeenCalledWith('journey.share.updateFailed', 'error', undefined);
});
it('FE-JRN-SHAREHOOK-007: deleting drops the link, a failure keeps it', async () => {
const { result } = await loaded();
server.use(http.delete('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'x' }, { status: 500 })));
await act(() => result.current.deleteLink());
expect(result.current.link).toEqual(LINK);
expect(addToast).toHaveBeenCalledWith('journey.share.deleteFailed', 'error', undefined);
server.use(http.delete('/api/journeys/7/share-link', () => HttpResponse.json({ success: true })));
await act(() => result.current.deleteLink());
expect(result.current.link).toBeNull();
expect(addToast).toHaveBeenLastCalledWith('journey.share.linkDeleted', 'success', undefined);
});
it('FE-JRN-SHAREHOOK-008: copying shows "copied" for two seconds', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true });
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true, writable: true });
const { result } = await loaded();
vi.useFakeTimers();
await act(() => result.current.copyLink());
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/public/journey/tok`);
expect(result.current.copied).toBe(true);
act(() => vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false);
});
});
@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import { journeyApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { copyText } from '../../utils/clipboard';
import { useToast } from '../shared/Toast';
export interface JourneyShareLink {
token: string;
share_timeline: boolean;
share_gallery: boolean;
share_map: boolean;
}
export type JourneySharePermission = 'share_timeline' | 'share_gallery' | 'share_map';
/** The public address of a journey share token. */
export function journeyShareUrl(link: JourneyShareLink | null, origin: string): string {
return link ? `${origin}/public/journey/${link.token}` : '';
}
/**
* A journey's public share link, behind both the desktop share section and the phone
* settings sheet, which render their own markup over it: load it, create it with every
* section shared, switch a section on or off (rolled back when the server refuses),
* delete it and copy its address.
*/
export function useJourneyShareLink(journeyId: number) {
const { t } = useTranslation();
const toast = useToast();
const [link, setLink] = useState<JourneyShareLink | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
/** The share token is the owner's to manage; a contributor is refused. */
const [manageable, setManageable] = useState(true);
useEffect(() => {
journeyApi
.getShareLink(journeyId)
.then((d) => setLink(d.link || null))
.catch((err: { response?: { status?: number } }) => {
// A 403 is the server saying this is not yours to manage. Showing the
// section anyway would offer an editor a "create link" button that is
// refused the moment they press it.
if (err?.response?.status === 403) setManageable(false);
})
.finally(() => setLoading(false));
}, [journeyId]);
const createLink = async () => {
try {
const res = await journeyApi.createShareLink(journeyId, {
share_timeline: true,
share_gallery: true,
share_map: true,
});
setLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true });
toast.success(t('journey.share.linkCreated'));
} catch {
toast.error(t('journey.share.createFailed'));
}
};
const togglePerm = async (key: JourneySharePermission) => {
if (!link) return;
const previous = link;
const updated = { ...previous, [key]: !previous[key] };
setLink(updated);
try {
await journeyApi.createShareLink(journeyId, {
share_timeline: updated.share_timeline,
share_gallery: updated.share_gallery,
share_map: updated.share_map,
});
} catch {
setLink(previous);
toast.error(t('journey.share.updateFailed'));
}
};
const deleteLink = async () => {
try {
await journeyApi.deleteShareLink(journeyId);
setLink(null);
toast.success(t('journey.share.linkDeleted'));
} catch {
toast.error(t('journey.share.deleteFailed'));
}
};
const shareUrl = journeyShareUrl(link, window.location.origin);
const copyLink = async () => {
if (!(await copyText(shareUrl))) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return { link, loading, manageable, copied, shareUrl, createLink, togglePerm, deleteLink, copyLink };
}
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { useRef, useState } from 'react'
import {
X, Image, Plus, Trash2, UserPlus, Link as LinkIcon,
List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2,
@@ -9,29 +8,12 @@ import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import { journeyApi } from '../../../api/client'
import { useJourneyStore } from '../../../store/journeyStore'
import type { JourneyDetail } from '../../../store/journeyStore'
import { normalizeImageFile } from '../../../utils/convertHeic'
import { copyText } from '../../../utils/clipboard'
import { useJourneySettings, useJourneyTripLinking } from '../../../components/Journey/useJourneySettings'
import { useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink'
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { journeyCoverSrc } from './mobileJourneyMeta'
interface ShareLink {
token: string
share_timeline: boolean
share_gallery: boolean
share_map: boolean
}
interface AvailableTrip {
id: number
title: string
destination?: string
start_date?: string
}
interface MJourneySettingsSheetProps {
journey: JourneyDetail
onClose: () => void
@@ -50,181 +32,27 @@ export default function MJourneySettingsSheet({
journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions,
}: MJourneySettingsSheetProps) {
const { t } = useTranslation()
const toast = useToast()
const navigate = useNavigate()
const { updateJourney, deleteJourney } = useJourneyStore()
const [title, setTitle] = useState(journey.title)
const [subtitle, setSubtitle] = useState(journey.subtitle || '')
const [saving, setSaving] = useState(false)
const [archiving, setArchiving] = useState(false)
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const {
title, setTitle, subtitle, setSubtitle, saving, archiving, unlinkTarget, setUnlinkTarget, showDeleteConfirm,
setShowDeleteConfirm, savingTracks, savingField, handleSave, handleCoverUpload, handleArchiveToggle,
handleTracksToggle, handleFieldToggle, handleDelete, handleRemoveContributor, confirmUnlink,
} = useJourneySettings({ journey, onSaved, onRefresh, onContentChanged: onRefresh })
const [addTripOpen, setAddTripOpen] = useState(false)
const [availableTrips, setAvailableTrips] = useState<AvailableTrip[]>([])
const [linkingTripId, setLinkingTripId] = useState<number | null>(null)
const [shareLink, setShareLink] = useState<ShareLink | null>(null)
const [savingTracks, setSavingTracks] = useState(false)
const [copied, setCopied] = useState(false)
const { availableTrips, linkingTripId, loadAvailableTrips, linkTrip } = useJourneyTripLinking({
journeyId: journey.id,
onLinked: () => { setAddTripOpen(false); onRefresh() },
})
const {
link: shareLink, copied, shareUrl, createLink: createShareLink, togglePerm: toggleSharePerm,
deleteLink: deleteShareLink, copyLink: copyShareUrl,
} = useJourneyShareLink(journey.id)
const coverRef = useRef<HTMLInputElement>(null)
useEffect(() => {
journeyApi.getShareLink(journey.id).then(d => setShareLink(d.link || null)).catch(() => {})
}, [journey.id])
const coverSrc = journeyCoverSrc(journey.cover_image)
const shareUrl = shareLink ? `${window.location.origin}/public/journey/${shareLink.token}` : ''
const handleSave = async () => {
setSaving(true)
try {
await updateJourney(journey.id, { title, subtitle: subtitle || null })
onSaved()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSaving(false)
}
}
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append('cover', await normalizeImageFile(file))
try {
await journeyApi.uploadCover(journey.id, formData)
toast.success(t('journey.settings.coverUpdated'))
onRefresh()
} catch {
toast.error(t('journey.settings.coverFailed'))
}
}
// Written on the spot rather than on Save, like the archive button: it is a view
// setting and the point of it is watching the map change (#2194). onRefresh, not
// onSaved, because onSaved closes the sheet the instant the switch is flipped and
// would drop a title the owner has typed but not saved yet.
const handleTracksToggle = async (next: boolean) => {
setSavingTracks(true)
try {
await updateJourney(journey.id, { show_trip_tracks: next })
onRefresh()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSavingTracks(false)
}
}
/**
* Turn one of the optional entry fields off for this journey (discussion #2299).
*
* Written on the spot and through onRefresh, for the same two reasons the tracks
* switch above is. Nothing stored is erased: the form stops asking, the values
* stay, and switching back on brings them into view.
*/
const [savingField, setSavingField] = useState<string | null>(null)
const handleFieldToggle = async (field: 'show_verdict' | 'show_mood' | 'show_weather', next: boolean) => {
setSavingField(field)
try {
await updateJourney(journey.id, { [field]: next })
onRefresh()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSavingField(null)
}
}
const openAddTrip = async () => {
setAddTripOpen(v => !v)
if (availableTrips.length === 0) {
try {
const data = await journeyApi.availableTrips()
setAvailableTrips(data.trips || [])
} catch { /* row stays empty */ }
}
}
const linkTrip = async (tripId: number) => {
setLinkingTripId(tripId)
try {
await journeyApi.addTrip(journey.id, tripId)
toast.success(t('journey.trips.tripLinked'))
setAddTripOpen(false)
onRefresh()
} catch {
toast.error(t('journey.trips.linkFailed'))
} finally {
setLinkingTripId(null)
}
}
const createShareLink = async () => {
try {
const res = await journeyApi.createShareLink(journey.id, { share_timeline: true, share_gallery: true, share_map: true })
setShareLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true })
toast.success(t('journey.share.linkCreated'))
} catch {
toast.error(t('journey.share.createFailed'))
}
}
const copyShareUrl = async () => {
if (!(await copyText(shareUrl))) return
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const toggleSharePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
if (!shareLink) return
const previous = shareLink
const updated = { ...previous, [key]: !previous[key] }
setShareLink(updated)
try {
await journeyApi.createShareLink(journey.id, {
share_timeline: updated.share_timeline,
share_gallery: updated.share_gallery,
share_map: updated.share_map,
})
} catch {
setShareLink(previous)
toast.error(t('journey.share.updateFailed'))
}
}
const deleteShareLink = async () => {
try {
await journeyApi.deleteShareLink(journey.id)
setShareLink(null)
toast.success(t('journey.share.linkDeleted'))
} catch {
toast.error(t('journey.share.deleteFailed'))
}
}
const handleArchiveToggle = async () => {
setArchiving(true)
try {
const newStatus = journey.status === 'archived' ? 'active' : 'archived'
await updateJourney(journey.id, { status: newStatus })
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'))
onSaved()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setArchiving(false)
}
}
const handleDelete = async () => {
try {
await deleteJourney(journey.id)
navigate('/journey')
} catch {
toast.error(t('journey.settings.failedToDelete'))
}
if (availableTrips.length === 0) await loadAvailableTrips()
}
const eyebrow = 'font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint'
@@ -395,16 +223,7 @@ export default function MJourneySettingsSheet({
{c.role !== 'owner' && (
<button
type="button"
onClick={async () => {
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return
try {
await journeyApi.removeContributor(journey.id, c.user_id)
toast.success(t('journey.contributors.removed'))
onRefresh()
} catch {
toast.error(t('journey.contributors.removeFailed'))
}
}}
onClick={() => handleRemoveContributor(c)}
aria-label={t('journey.contributors.remove')}
className="flex h-7 w-7 flex-none items-center justify-center rounded-full text-m-faint"
>
@@ -495,17 +314,7 @@ export default function MJourneySettingsSheet({
<ConfirmDialog
isOpen={!!unlinkTarget}
onClose={() => setUnlinkTarget(null)}
onConfirm={async () => {
if (!unlinkTarget) return
try {
await journeyApi.removeTrip(journey.id, unlinkTarget.trip_id)
toast.success(t('journey.trips.tripUnlinked'))
setUnlinkTarget(null)
onRefresh()
} catch {
toast.error(t('journey.trips.unlinkFailed'))
}
}}
onConfirm={confirmUnlink}
title={t('journey.trips.unlinkTrip')}
message={t('journey.trips.unlinkMessage', { title: unlinkTarget?.title })}
confirmLabel={t('journey.trips.unlink')}