mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
[+] Unsplash
This commit is contained in:
@@ -333,6 +333,7 @@ export const tripsApi = {
|
|||||||
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||||
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
||||||
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||||
|
searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data),
|
||||||
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
||||||
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
||||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||||
@@ -808,4 +809,4 @@ export const inAppNotificationsApi = {
|
|||||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-028
|
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-031
|
||||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
@@ -310,4 +310,41 @@ describe('TripFormModal', () => {
|
|||||||
await screen.findByText('Number of days is required');
|
await screen.findByText('Number of days is required');
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-TRIPFORM-031: selects an Unsplash cover and saves it after trip creation', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) });
|
||||||
|
let updateBody: unknown;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/cover-images/search', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
photos: [{
|
||||||
|
id: 'unsplash-1',
|
||||||
|
url: 'https://images.example.com/regular.jpg',
|
||||||
|
thumb: 'https://images.example.com/thumb.jpg',
|
||||||
|
description: 'Mountain lake',
|
||||||
|
photographer: 'Alice',
|
||||||
|
link: 'https://unsplash.com/photos/unsplash-1',
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.put('/api/trips/99', async ({ request }) => {
|
||||||
|
updateBody = await request.json();
|
||||||
|
return HttpResponse.json({ trip: buildTrip({ id: 99, cover_image: 'https://images.example.com/regular.jpg' }) });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
||||||
|
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Alpine Trip');
|
||||||
|
await user.type(screen.getByPlaceholderText('Search destination photos'), 'alps');
|
||||||
|
await user.click(screen.getByRole('button', { name: /Search Unsplash/i }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: /Use Unsplash photo by Alice/i }));
|
||||||
|
|
||||||
|
const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!;
|
||||||
|
await user.click(submitBtn.closest('button')!);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updateBody).toMatchObject({ cover_image: 'https://images.example.com/regular.jpg' });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react'
|
import { Calendar, Camera, Search, X, UserPlus, Bell } from 'lucide-react'
|
||||||
import { tripsApi, authApi } from '../../api/client'
|
import { tripsApi, authApi } from '../../api/client'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
@@ -9,7 +9,7 @@ import { useToast } from '../shared/Toast'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
import { normalizeImageFile } from '../../utils/convertHeic'
|
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||||
import type { Trip } from '../../types'
|
import { getApiErrorMessage, type Trip } from '../../types'
|
||||||
import type { TripCreateRequest } from '@trek/shared'
|
import type { TripCreateRequest } from '@trek/shared'
|
||||||
|
|
||||||
interface TripFormModalProps {
|
interface TripFormModalProps {
|
||||||
@@ -22,6 +22,15 @@ interface TripFormModalProps {
|
|||||||
onCoverUpdate?: (tripId: number, coverUrl: string | null) => void
|
onCoverUpdate?: (tripId: number, coverUrl: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CoverSearchPhoto {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
description?: string | null
|
||||||
|
photographer?: string | null
|
||||||
|
link?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) {
|
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) {
|
||||||
const isEditing = !!trip
|
const isEditing = !!trip
|
||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
@@ -45,9 +54,14 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
const [customReminder, setCustomReminder] = useState(false)
|
const [customReminder, setCustomReminder] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [coverPreview, setCoverPreview] = useState(null)
|
const [coverPreview, setCoverPreview] = useState<string | null>(null)
|
||||||
const [pendingCoverFile, setPendingCoverFile] = useState(null)
|
const [pendingCoverFile, setPendingCoverFile] = useState<File | null>(null)
|
||||||
|
const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState<string | null>(null)
|
||||||
const [uploadingCover, setUploadingCover] = useState(false)
|
const [uploadingCover, setUploadingCover] = useState(false)
|
||||||
|
const [coverSearchQuery, setCoverSearchQuery] = useState('')
|
||||||
|
const [coverSearchResults, setCoverSearchResults] = useState<CoverSearchPhoto[]>([])
|
||||||
|
const [coverSearchError, setCoverSearchError] = useState('')
|
||||||
|
const [searchingCover, setSearchingCover] = useState(false)
|
||||||
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
|
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
|
||||||
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
|
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
|
||||||
const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([])
|
const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([])
|
||||||
@@ -66,12 +80,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
})
|
})
|
||||||
setCustomReminder(![0, 1, 3, 9].includes(rd))
|
setCustomReminder(![0, 1, 3, 9].includes(rd))
|
||||||
setCoverPreview(trip.cover_image || null)
|
setCoverPreview(trip.cover_image || null)
|
||||||
|
setCoverSearchQuery('')
|
||||||
} else {
|
} else {
|
||||||
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 })
|
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 })
|
||||||
setCustomReminder(false)
|
setCustomReminder(false)
|
||||||
setCoverPreview(null)
|
setCoverPreview(null)
|
||||||
|
setCoverSearchQuery('')
|
||||||
}
|
}
|
||||||
setPendingCoverFile(null)
|
setPendingCoverFile(null)
|
||||||
|
setPendingUnsplashUrl(null)
|
||||||
|
setCoverSearchResults([])
|
||||||
|
setCoverSearchError('')
|
||||||
setSelectedMembers([])
|
setSelectedMembers([])
|
||||||
setError('')
|
setError('')
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -139,6 +158,13 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
// Cover upload failed but trip was created — surface it without blocking the create
|
// Cover upload failed but trip was created — surface it without blocking the create
|
||||||
toast.error(t('dashboard.coverUploadError'))
|
toast.error(t('dashboard.coverUploadError'))
|
||||||
}
|
}
|
||||||
|
} else if (pendingUnsplashUrl && createdTrip?.id) {
|
||||||
|
try {
|
||||||
|
await tripsApi.update(createdTrip.id, { cover_image: pendingUnsplashUrl })
|
||||||
|
onCoverUpdate?.(createdTrip.id, pendingUnsplashUrl)
|
||||||
|
} catch {
|
||||||
|
toast.error(t('dashboard.coverSaveError'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -152,6 +178,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
if (!file) return
|
if (!file) return
|
||||||
// HEIC/HEIF from iOS can't be rendered or stored as-is — convert to JPEG first
|
// HEIC/HEIF from iOS can't be rendered or stored as-is — convert to JPEG first
|
||||||
const normalized = await normalizeImageFile(file)
|
const normalized = await normalizeImageFile(file)
|
||||||
|
setPendingUnsplashUrl(null)
|
||||||
if (isEditing && trip?.id) {
|
if (isEditing && trip?.id) {
|
||||||
// Existing trip: upload immediately
|
// Existing trip: upload immediately
|
||||||
uploadCoverNow(normalized)
|
uploadCoverNow(normalized)
|
||||||
@@ -183,9 +210,51 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCoverSearch = async () => {
|
||||||
|
const query = coverSearchQuery.trim() || formData.title.trim()
|
||||||
|
if (!query) {
|
||||||
|
setCoverSearchError(t('dashboard.unsplashQueryRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSearchingCover(true)
|
||||||
|
setCoverSearchError('')
|
||||||
|
try {
|
||||||
|
const data = await tripsApi.searchCoverImages(query)
|
||||||
|
const photos = data.photos || []
|
||||||
|
setCoverSearchResults(photos)
|
||||||
|
if (photos.length === 0) setCoverSearchError(t('dashboard.unsplashNoResults'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setCoverSearchError(getApiErrorMessage(err, t('dashboard.coverSearchError')))
|
||||||
|
} finally {
|
||||||
|
setSearchingCover(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnsplashSelect = async (photo: CoverSearchPhoto) => {
|
||||||
|
if (!photo.url) return
|
||||||
|
setPendingCoverFile(null)
|
||||||
|
if (isEditing && trip?.id) {
|
||||||
|
setUploadingCover(true)
|
||||||
|
try {
|
||||||
|
await tripsApi.update(trip.id, { cover_image: photo.url })
|
||||||
|
setCoverPreview(photo.url)
|
||||||
|
onCoverUpdate?.(trip.id, photo.url)
|
||||||
|
toast.success(t('dashboard.coverSaved'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('dashboard.coverSaveError')))
|
||||||
|
} finally {
|
||||||
|
setUploadingCover(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPendingUnsplashUrl(photo.url)
|
||||||
|
setCoverPreview(photo.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleRemoveCover = async () => {
|
const handleRemoveCover = async () => {
|
||||||
if (pendingCoverFile) {
|
if (pendingCoverFile || pendingUnsplashUrl) {
|
||||||
setPendingCoverFile(null)
|
setPendingCoverFile(null)
|
||||||
|
setPendingUnsplashUrl(null)
|
||||||
setCoverPreview(null)
|
setCoverPreview(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -288,6 +357,42 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={coverSearchQuery}
|
||||||
|
onChange={e => setCoverSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleCoverSearch() } }}
|
||||||
|
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleCoverSearch} disabled={searchingCover || (!coverSearchQuery.trim() && !formData.title.trim())}
|
||||||
|
className="px-3 py-2 text-sm border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 whitespace-nowrap">
|
||||||
|
{searchingCover ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <Search size={14} />}
|
||||||
|
{t('dashboard.searchUnsplash')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{coverSearchError && <p className="text-xs text-red-500 mt-1.5">{coverSearchError}</p>}
|
||||||
|
{coverSearchResults.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||||
|
{coverSearchResults.map(photo => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={photo.id}
|
||||||
|
onClick={() => handleUnsplashSelect(photo)}
|
||||||
|
aria-label={t('dashboard.useUnsplashPhoto', { photographer: photo.photographer || 'Unsplash' })}
|
||||||
|
className={`relative h-20 overflow-hidden rounded-lg border transition-colors ${coverPreview === photo.url ? 'border-slate-900 ring-2 ring-slate-900/20' : 'border-slate-200 hover:border-slate-400'}`}
|
||||||
|
>
|
||||||
|
<img src={photo.thumb} alt={photo.description || ''} loading="lazy" className="w-full h-full object-cover" />
|
||||||
|
{photo.photographer && (
|
||||||
|
<span className="absolute inset-x-0 bottom-0 truncate bg-black/55 px-1.5 py-1 text-[10px] text-white">
|
||||||
|
{photo.photographer}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -697,7 +697,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
<TripFormModal
|
||||||
|
isOpen={showTripForm}
|
||||||
|
onClose={() => setShowTripForm(false)}
|
||||||
|
onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }}
|
||||||
|
trip={trip}
|
||||||
|
onCoverUpdate={(_, coverUrl) => useTripStore.setState(state => ({ trip: state.trip ? { ...state.trip, cover_image: coverUrl } : state.trip }))}
|
||||||
|
/>
|
||||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||||
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
|
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
|
||||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
|
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
|
||||||
|
|||||||
+11
-9
@@ -2,6 +2,8 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
const backendTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3001'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
@@ -126,42 +128,42 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: backendTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/uploads': {
|
'/uploads': {
|
||||||
target: 'http://localhost:3001',
|
target: backendTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: 'http://localhost:3001',
|
target: backendTarget,
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
'/mcp': {
|
'/mcp': {
|
||||||
target: 'http://localhost:3001',
|
target: backendTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
// OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke)
|
// OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke)
|
||||||
// /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent
|
// /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent
|
||||||
// /oauth/consent is served by Vite as a SPA route (no proxy entry needed)
|
// /oauth/consent is served by Vite as a SPA route (no proxy entry needed)
|
||||||
'/oauth/authorize': {
|
'/oauth/authorize': {
|
||||||
target: 'http://localhost:3001',
|
target: backendTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/oauth/token': {
|
'/oauth/token': {
|
||||||
target: 'http://localhost:3001',
|
target: backendTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/oauth/register': {
|
'/oauth/register': {
|
||||||
target: 'http://localhost:3001',
|
target: backendTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/oauth/revoke': {
|
'/oauth/revoke': {
|
||||||
target: 'http://localhost:3001',
|
target: backendTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/.well-known': {
|
'/.well-known': {
|
||||||
target: 'http://localhost:3001',
|
target: backendTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,21 @@ export class TripsController {
|
|||||||
return { trips: this.trips.list(user.id, archived === '1' ? 1 : 0) };
|
return { trips: this.trips.list(user.id, archived === '1' ? 1 : 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('cover-images/search')
|
||||||
|
async coverImages(@CurrentUser() user: User, @Query('query') query?: string) {
|
||||||
|
try {
|
||||||
|
const result = await this.trips.searchCoverImages(user.id, query || '');
|
||||||
|
if ('error' in result) {
|
||||||
|
throw new HttpException({ error: result.error }, result.status);
|
||||||
|
}
|
||||||
|
return { photos: result.photos };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof HttpException) throw err;
|
||||||
|
console.error('Unsplash cover image error:', err);
|
||||||
|
throw new HttpException({ error: 'Error searching for cover images' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(201)
|
@HttpCode(201)
|
||||||
create(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Req() req: Request) {
|
create(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Req() req: Request) {
|
||||||
@@ -122,8 +137,14 @@ export class TripsController {
|
|||||||
if (editFields.some((f) => body[f] !== undefined) && !this.trips.can('trip_edit', user.role, ownerId, user.id, isMember)) {
|
if (editFields.some((f) => body[f] !== undefined) && !this.trips.can('trip_edit', user.role, ownerId, user.id, isMember)) {
|
||||||
throw new HttpException({ error: 'No permission to edit this trip' }, 403);
|
throw new HttpException({ error: 'No permission to edit this trip' }, 403);
|
||||||
}
|
}
|
||||||
|
const oldCover = body.cover_image !== undefined
|
||||||
|
? (this.trips.getRaw(id) as { cover_image: string | null } | undefined)?.cover_image
|
||||||
|
: undefined;
|
||||||
try {
|
try {
|
||||||
const result = this.trips.update(id, user.id, body, user.role);
|
const result = this.trips.update(id, user.id, body, user.role);
|
||||||
|
if (body.cover_image !== undefined && body.cover_image !== oldCover) {
|
||||||
|
this.trips.deleteOldCover(oldCover);
|
||||||
|
}
|
||||||
if (Object.keys(result.changes).length > 0) {
|
if (Object.keys(result.changes).length > 0) {
|
||||||
writeAudit({ userId: user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } });
|
writeAudit({ userId: user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } });
|
||||||
if (result.isAdminEdit && result.ownerEmail) logInfo(`Admin ${user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`);
|
if (result.isAdminEdit && result.ownerEmail) logInfo(`Admin ${user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { listItems as listTodoItems } from '../../services/todoService';
|
|||||||
import { listBudgetItems } from '../../services/budgetService';
|
import { listBudgetItems } from '../../services/budgetService';
|
||||||
import { listReservations } from '../../services/reservationService';
|
import { listReservations } from '../../services/reservationService';
|
||||||
import { listFiles } from '../../services/fileService';
|
import { listFiles } from '../../services/fileService';
|
||||||
|
import { searchUnsplashPhotos } from '../../services/unsplashService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin Nest wrapper around the existing trip service + the per-domain list
|
* Thin Nest wrapper around the existing trip service + the per-domain list
|
||||||
@@ -49,6 +50,10 @@ export class TripsService {
|
|||||||
return tripSvc.getTripRaw(tripId);
|
return tripSvc.getTripRaw(tripId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchCoverImages(userId: number, query: string) {
|
||||||
|
return searchUnsplashPhotos(userId, query, 9);
|
||||||
|
}
|
||||||
|
|
||||||
getOwner(tripId: string) {
|
getOwner(tripId: string) {
|
||||||
return tripSvc.getTripOwner(tripId);
|
return tripSvc.getTripOwner(tripId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from './kmlImport';
|
} from './kmlImport';
|
||||||
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
|
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
|
||||||
import * as placePhotoCache from './placePhotoCache';
|
import * as placePhotoCache from './placePhotoCache';
|
||||||
|
import { searchUnsplashPhotos } from './unsplashService';
|
||||||
|
|
||||||
// Reclaim a deleted place's cached marker photo if nothing else references it.
|
// Reclaim a deleted place's cached marker photo if nothing else references it.
|
||||||
// The cache key is the Google place_id, or — for coordinate-only places — the
|
// The cache key is the Google place_id, or — for coordinate-only places — the
|
||||||
@@ -42,11 +43,6 @@ interface PlaceWithCategory extends Place {
|
|||||||
category_icon: string | null;
|
category_icon: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UnsplashSearchResponse {
|
|
||||||
results?: { id: string; urls?: { regular?: string; thumb?: string }; description?: string; alt_description?: string; user?: { name?: string }; links?: { html?: string } }[];
|
|
||||||
errors?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlaceImportResult {
|
export interface PlaceImportResult {
|
||||||
places: any[];
|
places: any[];
|
||||||
count: number;
|
count: number;
|
||||||
@@ -947,29 +943,5 @@ export async function searchPlaceImage(tripId: string, placeId: string, userId:
|
|||||||
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined;
|
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined;
|
||||||
if (!place) return { error: 'Place not found', status: 404 };
|
if (!place) return { error: 'Place not found', status: 404 };
|
||||||
|
|
||||||
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(userId) as { unsplash_api_key: string | null } | undefined;
|
return searchUnsplashPhotos(userId, place.name + (place.address ? ' ' + place.address : ''), 5);
|
||||||
if (!user || !user.unsplash_api_key) {
|
|
||||||
return { error: 'No Unsplash API key configured', status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = encodeURIComponent(place.name + (place.address ? ' ' + place.address : ''));
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.unsplash.com/search/photos?query=${query}&per_page=5&client_id=${user.unsplash_api_key}`,
|
|
||||||
);
|
|
||||||
const data = await response.json() as UnsplashSearchResponse;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return { error: data.errors?.[0] || 'Unsplash API error', status: response.status };
|
|
||||||
}
|
|
||||||
|
|
||||||
const photos = (data.results || []).map((p: NonNullable<UnsplashSearchResponse['results']>[number]) => ({
|
|
||||||
id: p.id,
|
|
||||||
url: p.urls?.regular,
|
|
||||||
thumb: p.urls?.thumb,
|
|
||||||
description: p.description || p.alt_description,
|
|
||||||
photographer: p.user?.name,
|
|
||||||
link: p.links?.html,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { photos };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
interface UnsplashSearchResponse {
|
||||||
|
results?: {
|
||||||
|
id: string;
|
||||||
|
urls?: { regular?: string; small?: string; thumb?: string };
|
||||||
|
description?: string | null;
|
||||||
|
alt_description?: string | null;
|
||||||
|
user?: { name?: string };
|
||||||
|
links?: { html?: string };
|
||||||
|
}[];
|
||||||
|
errors?: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnsplashPhoto {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
thumb: string;
|
||||||
|
description: string | null;
|
||||||
|
photographer: string | null;
|
||||||
|
link: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchUnsplashPhotos(_userId: number, query: string, perPage = 9) {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { error: 'Search query is required', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: '1',
|
||||||
|
query: trimmed,
|
||||||
|
per_page: String(perPage),
|
||||||
|
});
|
||||||
|
const response = await fetch(`https://unsplash.com/napi/search/photos?${params.toString()}`, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0',
|
||||||
|
Accept: '*/*',
|
||||||
|
'Accept-Language': 'en-US',
|
||||||
|
Referer: `https://unsplash.com/s/photos/${encodeURIComponent(trimmed)}`,
|
||||||
|
'client-geo-region': 'global',
|
||||||
|
'Sec-Fetch-Dest': 'empty',
|
||||||
|
'Sec-Fetch-Site': 'same-origin',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let data: UnsplashSearchResponse;
|
||||||
|
try {
|
||||||
|
data = await response.json() as UnsplashSearchResponse;
|
||||||
|
} catch {
|
||||||
|
return { error: 'Unsplash search unavailable', status: response.ok ? 502 : response.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { error: data.errors?.[0] || data.error || 'Unsplash search unavailable', status: response.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
const photos: UnsplashPhoto[] = (data.results || [])
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
url: p.urls?.regular || '',
|
||||||
|
thumb: p.urls?.small || p.urls?.thumb || p.urls?.regular || '',
|
||||||
|
description: p.description || p.alt_description || null,
|
||||||
|
photographer: p.user?.name || null,
|
||||||
|
link: p.links?.html || null,
|
||||||
|
}))
|
||||||
|
.filter((p) => p.url && p.thumb)
|
||||||
|
.slice(0, perPage);
|
||||||
|
|
||||||
|
return { photos };
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for placeService — PLACE-SVC-001 through PLACE-SVC-025.
|
* Unit tests for placeService — PLACE-SVC-001 through PLACE-SVC-025.
|
||||||
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
|
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
|
||||||
* Skips importGpx / importGoogleList / searchPlaceImage (require external I/O).
|
* External fetches are mocked where needed.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
|
||||||
@@ -541,20 +541,31 @@ describe('searchPlaceImage', () => {
|
|||||||
expect(result.status).toBe(404);
|
expect(result.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PLACE-SVC-031 — returns 400 when user has no Unsplash API key', async () => {
|
it('PLACE-SVC-031 — searches Unsplash without a stored API key', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
|
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
results: [
|
||||||
|
{ id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
status: 200,
|
||||||
|
}));
|
||||||
|
|
||||||
const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any;
|
const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any;
|
||||||
expect(result.error).toMatch(/No Unsplash API key/);
|
expect(result.photos).toHaveLength(1);
|
||||||
expect(result.status).toBe(400);
|
const [url] = (fetch as any).mock.calls[0];
|
||||||
|
expect(url).toContain('https://unsplash.com/napi/search/photos?');
|
||||||
|
expect(url).not.toContain('client_id=');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PLACE-SVC-032 — returns photos when Unsplash API responds successfully', async () => {
|
it('PLACE-SVC-032 — returns photos when Unsplash API responds successfully', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
|
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
|
||||||
testDb.prepare('UPDATE users SET unsplash_api_key = ? WHERE id = ?').run('test-unsplash-key', user.id);
|
|
||||||
|
|
||||||
const mockPhotos = [
|
const mockPhotos = [
|
||||||
{ id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } },
|
{ id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } },
|
||||||
|
|||||||
@@ -88,7 +88,14 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.addMember': 'Add member',
|
'dashboard.addMember': 'Add member',
|
||||||
'dashboard.coverSaved': 'Cover image saved',
|
'dashboard.coverSaved': 'Cover image saved',
|
||||||
'dashboard.coverUploadError': 'Failed to upload',
|
'dashboard.coverUploadError': 'Failed to upload',
|
||||||
|
'dashboard.coverSaveError': 'Failed to save cover image',
|
||||||
'dashboard.coverRemoveError': 'Failed to remove',
|
'dashboard.coverRemoveError': 'Failed to remove',
|
||||||
|
'dashboard.searchUnsplash': 'Search Unsplash',
|
||||||
|
'dashboard.unsplashSearchPlaceholder': 'Search destination photos',
|
||||||
|
'dashboard.unsplashQueryRequired': 'Enter a search term',
|
||||||
|
'dashboard.unsplashNoResults': 'No images found',
|
||||||
|
'dashboard.coverSearchError': 'Failed to search Unsplash',
|
||||||
|
'dashboard.useUnsplashPhoto': 'Use Unsplash photo by {photographer}',
|
||||||
'dashboard.titleRequired': 'Title is required',
|
'dashboard.titleRequired': 'Title is required',
|
||||||
'dashboard.endDateError': 'End date must be after start date',
|
'dashboard.endDateError': 'End date must be after start date',
|
||||||
'dashboard.greeting.morning': 'Good morning,',
|
'dashboard.greeting.morning': 'Good morning,',
|
||||||
|
|||||||
Reference in New Issue
Block a user