diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index 50be5edf..766f8b7f 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -25,20 +25,22 @@ const WEATHER_CONFIG: Record = { 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}` } interface Props { entry: JourneyEntry readOnly?: boolean + publicPhotoUrl?: (photoId: number) => string onClose: () => void onEdit: () => void onDelete: () => 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 mood = entry.mood ? MOOD_CONFIG[entry.mood] : 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 && (
onPhotoClick(photos, 0)} @@ -102,7 +104,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe {photos.map((p, i) => ( onPhotoClick(photos, i)} diff --git a/client/src/pages/JourneyPublicPage.test.tsx b/client/src/pages/JourneyPublicPage.test.tsx index 1702a7d0..460e82c4 100644 --- a/client/src/pages/JourneyPublicPage.test.tsx +++ b/client/src/pages/JourneyPublicPage.test.tsx @@ -56,6 +56,21 @@ vi.mock('../components/Journey/PhotoLightbox', () => ({ ), })); +vi.mock('../components/Journey/MobileMapTimeline', () => ({ + default: ({ onEntryClick }: any) => ( +
+ +
+ ), +})); + +const mockIsMobile = { value: false }; +vi.mock('../hooks/useIsMobile', () => ({ + useIsMobile: () => mockIsMobile.value, +})); + import JourneyPublicPage from './JourneyPublicPage'; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -106,6 +121,9 @@ const mockJourneyData = { share_gallery: 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: { entries: 2, photos: 1, @@ -136,6 +154,7 @@ function setup404() { beforeEach(() => { resetAllStores(); vi.clearAllMocks(); + mockIsMobile.value = false; }); // ── 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 }, }; @@ -391,6 +415,40 @@ describe('JourneyPublicPage', () => { 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(); + 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(); + 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 it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => { const user = userEvent.setup(); diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index b7a9772a..3962d7d9 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -14,6 +14,7 @@ import type { JourneyMapHandle } from '../components/Journey/JourneyMap' import JournalBody from '../components/Journey/JournalBody' import PhotoLightbox from '../components/Journey/PhotoLightbox' import MobileMapTimeline from '../components/Journey/MobileMapTimeline' +import MobileEntryView from '../components/Journey/MobileEntryView' import { useIsMobile } from '../hooks/useIsMobile' import { formatLocationName } from '../utils/formatters' import { DAY_COLORS } from '../components/Journey/dayColors' @@ -44,6 +45,17 @@ interface PublicPhoto { 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 = { 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' }, @@ -60,7 +72,7 @@ const WEATHER_CONFIG: Record = { 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}` } @@ -96,6 +108,7 @@ export default function JourneyPublicPage() { const locale = useSettingsStore(s => s.settings.language) || 'en' const mapRef = useRef(null) const [activeEntryId, setActiveEntryId] = useState(null) + const [viewingEntry, setViewingEntry] = useState(null) const handleMarkerClick = useCallback((entryId: string) => { setActiveEntryId(entryId) @@ -113,21 +126,19 @@ export default function JourneyPublicPage() { }, [token]) const entries = (data?.entries || []) as PublicEntry[] + const gallery = (data?.gallery || []) as PublicGalleryPhoto[] const perms = data?.permissions || {} const journey = data?.journey || {} const stats = data?.stats || {} - const timelineEntries = useMemo( - () => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'), - [entries], - ) + const timelineEntries = useMemo(() => entries, [entries]) const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries]) const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries]) const mapEntries = useMemo( () => timelineEntries.filter(e => e.location_lat && e.location_lng), [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. // dayIdx is derived from sortedDates (ALL timeline dates) so marker colors @@ -189,7 +200,7 @@ export default function JourneyPublicPage() { const availableViews = [ 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') }, - !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 }[] // Shared timeline renderer used in both layout modes @@ -402,11 +413,11 @@ export default function JourneyPublicPage() { // Shared gallery renderer const renderGallery = () => (
- {allPhotos.map(({ photo }, idx) => ( + {allPhotos.map((photo, idx) => (
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 })} >
@@ -499,7 +510,7 @@ export default function JourneyPublicPage() { // ── Desktop two-column: scrollable timeline feed + sticky map ──────────
{/* Left: feed */} -
+
{renderTabs(availableViews)} {view === 'timeline' && perms.share_timeline && renderTimeline()} {view === 'gallery' && perms.share_gallery && renderGallery()} @@ -563,7 +574,7 @@ export default function JourneyPublicPage() { mapEntries={sidebarMapItems as any} dark={document.documentElement.classList.contains('dark')} readOnly - onEntryClick={() => {}} + onEntryClick={(entry) => setViewingEntry(entry as any)} publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" /> @@ -607,6 +618,26 @@ export default function JourneyPublicPage() { onClose={() => setLightbox(null)} /> )} + + {/* Mobile entry detail view (public share) */} + {viewingEntry && ( + `/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, + })} + /> + )}
) }