fix: mobile public share — remove map tab (#828), cap timeline width (#827), wire entry click (#826)

- #828: exclude 'map' from availableViews on mobile; MobileMapTimeline already
  shows combined map+timeline so the standalone map tab is redundant
- #827: cap timeline feed column at xl:max-w-[50%] on ≥1280px viewports so the
  map aside is not dwarfed on wide monitors; applies to both desktop two-column
  layouts (JourneyPublicPage)
- #826: wire MobileMapTimeline onEntryClick to setViewingEntry; render
  MobileEntryView with readOnly + public photo URL builder so photos load via
  the share token endpoint; add publicPhotoUrl prop to MobileEntryView so
  photo URLs are routable for both authenticated and public-share contexts
This commit is contained in:
jubnl
2026-04-22 15:56:20 +02:00
parent 9e3041305c
commit f6b3931bc4
3 changed files with 106 additions and 15 deletions
@@ -25,20 +25,22 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
cold: { icon: Snowflake, label: 'Cold' }, cold: { icon: Snowflake, label: 'Cold' },
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string { function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
if (builder) return builder(p.photo_id)
return `/api/photos/${p.photo_id}/${size}` return `/api/photos/${p.photo_id}/${size}`
} }
interface Props { interface Props {
entry: JourneyEntry entry: JourneyEntry
readOnly?: boolean readOnly?: boolean
publicPhotoUrl?: (photoId: number) => string
onClose: () => void onClose: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void onPhotoClick: (photos: JourneyPhoto[], index: number) => void
} }
export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) { export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
const photos = entry.photos || [] const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
@@ -85,7 +87,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
{photos.length > 0 && ( {photos.length > 0 && (
<div className="relative"> <div className="relative">
<img <img
src={photoUrl(photos[0])} src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt="" alt=""
className="w-full max-h-[50vh] object-cover cursor-pointer" className="w-full max-h-[50vh] object-cover cursor-pointer"
onClick={() => onPhotoClick(photos, 0)} onClick={() => onPhotoClick(photos, 0)}
@@ -102,7 +104,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
{photos.map((p, i) => ( {photos.map((p, i) => (
<img <img
key={p.id || i} key={p.id || i}
src={photoUrl(p, 'thumbnail')} src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt="" alt=""
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all" className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
onClick={() => onPhotoClick(photos, i)} onClick={() => onPhotoClick(photos, i)}
@@ -56,6 +56,21 @@ vi.mock('../components/Journey/PhotoLightbox', () => ({
), ),
})); }));
vi.mock('../components/Journey/MobileMapTimeline', () => ({
default: ({ onEntryClick }: any) => (
<div data-testid="mobile-map-timeline">
<button onClick={() => onEntryClick({ id: 10, title: 'Shibuya Crossing', story: 'The most famous crossing in the world.', entry_date: '2026-03-15', entry_time: '14:00', location_name: 'Shibuya, Tokyo', photos: [] })}>
Open Entry
</button>
</div>
),
}));
const mockIsMobile = { value: false };
vi.mock('../hooks/useIsMobile', () => ({
useIsMobile: () => mockIsMobile.value,
}));
import JourneyPublicPage from './JourneyPublicPage'; import JourneyPublicPage from './JourneyPublicPage';
// ── Fixtures ───────────────────────────────────────────────────────────────── // ── Fixtures ─────────────────────────────────────────────────────────────────
@@ -106,6 +121,9 @@ const mockJourneyData = {
share_gallery: true, share_gallery: true,
share_map: true, share_map: true,
}, },
gallery: [
{ id: 100, journey_id: 1, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance', shared: 1, sort_order: 0, created_at: 0 },
],
stats: { stats: {
entries: 2, entries: 2,
photos: 1, photos: 1,
@@ -136,6 +154,7 @@ function setup404() {
beforeEach(() => { beforeEach(() => {
resetAllStores(); resetAllStores();
vi.clearAllMocks(); vi.clearAllMocks();
mockIsMobile.value = false;
}); });
// ── Tests ──────────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────────
@@ -340,6 +359,11 @@ describe('JourneyPublicPage', () => {
], ],
}, },
], ],
gallery: [
{ id: 200, journey_id: 1, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A', shared: 1, sort_order: 0, created_at: 0 },
{ id: 201, journey_id: 1, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B', shared: 1, sort_order: 1, created_at: 0 },
{ id: 202, journey_id: 1, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C', shared: 1, sort_order: 2, created_at: 0 },
],
stats: { entries: 1, photos: 3, places: 0 }, stats: { entries: 1, photos: 3, places: 0 },
}; };
@@ -391,6 +415,40 @@ describe('JourneyPublicPage', () => {
expect(statsContainer!.textContent).toContain('7'); expect(statsContainer!.textContent).toContain('7');
}); });
// FE-PAGE-PUBLICJOURNEY-019 — bug #828
it('FE-PAGE-PUBLICJOURNEY-019: mobile public share does not show standalone Map tab', async () => {
mockIsMobile.value = true;
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
const buttons = screen.getAllByRole('button');
const mapBtn = buttons.find(btn => btn.textContent && /^map$/i.test(btn.textContent.trim()));
expect(mapBtn).toBeUndefined();
});
// FE-PAGE-PUBLICJOURNEY-020 — bug #826
it('FE-PAGE-PUBLICJOURNEY-020: mobile public share opens entry details on card click', async () => {
const user = userEvent.setup();
mockIsMobile.value = true;
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
// The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked
const openBtn = screen.getByText('Open Entry');
await user.click(openBtn);
// MobileEntryView should slide in with the entry title
await waitFor(() => {
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
});
});
// FE-PAGE-PUBLICJOURNEY-016 // FE-PAGE-PUBLICJOURNEY-016
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => { it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
+42 -11
View File
@@ -14,6 +14,7 @@ import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody' import JournalBody from '../components/Journey/JournalBody'
import PhotoLightbox from '../components/Journey/PhotoLightbox' import PhotoLightbox from '../components/Journey/PhotoLightbox'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import { formatLocationName } from '../utils/formatters' import { formatLocationName } from '../utils/formatters'
import { DAY_COLORS } from '../components/Journey/dayColors' import { DAY_COLORS } from '../components/Journey/dayColors'
@@ -44,6 +45,17 @@ interface PublicPhoto {
caption?: string | null caption?: string | null
} }
interface PublicGalleryPhoto {
id: number
journey_id: number
photo_id: number
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
caption?: string | null
}
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = { const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
@@ -60,7 +72,7 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
cold: { icon: Snowflake, label: 'Cold' }, cold: { icon: Snowflake, label: 'Cold' },
} }
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string { function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}` return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
} }
@@ -96,6 +108,7 @@ export default function JourneyPublicPage() {
const locale = useSettingsStore(s => s.settings.language) || 'en' const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null) const mapRef = useRef<JourneyMapHandle>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(null) const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
const handleMarkerClick = useCallback((entryId: string) => { const handleMarkerClick = useCallback((entryId: string) => {
setActiveEntryId(entryId) setActiveEntryId(entryId)
@@ -113,21 +126,19 @@ export default function JourneyPublicPage() {
}, [token]) }, [token])
const entries = (data?.entries || []) as PublicEntry[] const entries = (data?.entries || []) as PublicEntry[]
const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
const perms = data?.permissions || {} const perms = data?.permissions || {}
const journey = data?.journey || {} const journey = data?.journey || {}
const stats = data?.stats || {} const stats = data?.stats || {}
const timelineEntries = useMemo( const timelineEntries = useMemo(() => entries, [entries])
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
[entries],
)
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries]) const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries]) const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo( const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng), () => timelineEntries.filter(e => e.location_lat && e.location_lng),
[timelineEntries], [timelineEntries],
) )
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries]) const allPhotos = gallery
// Map entries with day color/label for colored markers. // Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors // dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
@@ -189,7 +200,7 @@ export default function JourneyPublicPage() {
const availableViews = [ const availableViews = [
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
!desktopTwoColumn && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, !desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[] ].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
// Shared timeline renderer used in both layout modes // Shared timeline renderer used in both layout modes
@@ -402,11 +413,11 @@ export default function JourneyPublicPage() {
// Shared gallery renderer // Shared gallery renderer
const renderGallery = () => ( const renderGallery = () => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map(({ photo }, idx) => ( {allPhotos.map((photo, idx) => (
<div <div
key={photo.id} key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer" className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })} onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
> >
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" /> <img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div> </div>
@@ -499,7 +510,7 @@ export default function JourneyPublicPage() {
// ── Desktop two-column: scrollable timeline feed + sticky map ────────── // ── Desktop two-column: scrollable timeline feed + sticky map ──────────
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}> <div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
{/* Left: feed */} {/* Left: feed */}
<div className="flex-1 min-w-0 px-8 py-6"> <div className="flex-1 xl:max-w-[50%] min-w-0 px-8 py-6">
{renderTabs(availableViews)} {renderTabs(availableViews)}
{view === 'timeline' && perms.share_timeline && renderTimeline()} {view === 'timeline' && perms.share_timeline && renderTimeline()}
{view === 'gallery' && perms.share_gallery && renderGallery()} {view === 'gallery' && perms.share_gallery && renderGallery()}
@@ -563,7 +574,7 @@ export default function JourneyPublicPage() {
mapEntries={sidebarMapItems as any} mapEntries={sidebarMapItems as any}
dark={document.documentElement.classList.contains('dark')} dark={document.documentElement.classList.contains('dark')}
readOnly readOnly
onEntryClick={() => {}} onEntryClick={(entry) => setViewingEntry(entry as any)}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
/> />
@@ -607,6 +618,26 @@ export default function JourneyPublicPage() {
onClose={() => setLightbox(null)} onClose={() => setLightbox(null)}
/> />
)} )}
{/* Mobile entry detail view (public share) */}
{viewingEntry && (
<MobileEntryView
entry={viewingEntry as any}
readOnly
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
onClose={() => setViewingEntry(null)}
onEdit={() => {}}
onDelete={() => {}}
onPhotoClick={(photos, idx) => setLightbox({
photos: photos.map(p => ({
id: String(p.id),
src: photoUrl(p as any, token!, 'original'),
caption: (p as any).caption ?? null,
})),
index: idx,
})}
/>
)}
</div> </div>
) )
} }