mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user