mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 10:41:49 +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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
@@ -808,4 +809,4 @@ export const inAppNotificationsApi = {
|
||||
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 userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -310,4 +310,41 @@ describe('TripFormModal', () => {
|
||||
await screen.findByText('Number of days is required');
|
||||
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 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 CustomSelect from '../shared/CustomSelect'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -9,7 +9,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||
import type { Trip } from '../../types'
|
||||
import { getApiErrorMessage, type Trip } from '../../types'
|
||||
import type { TripCreateRequest } from '@trek/shared'
|
||||
|
||||
interface TripFormModalProps {
|
||||
@@ -22,6 +22,15 @@ interface TripFormModalProps {
|
||||
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) {
|
||||
const isEditing = !!trip
|
||||
const fileRef = useRef(null)
|
||||
@@ -45,9 +54,14 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
const [customReminder, setCustomReminder] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [coverPreview, setCoverPreview] = useState(null)
|
||||
const [pendingCoverFile, setPendingCoverFile] = useState(null)
|
||||
const [coverPreview, setCoverPreview] = useState<string | null>(null)
|
||||
const [pendingCoverFile, setPendingCoverFile] = useState<File | null>(null)
|
||||
const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState<string | null>(null)
|
||||
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 [selectedMembers, setSelectedMembers] = useState<number[]>([])
|
||||
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))
|
||||
setCoverPreview(trip.cover_image || null)
|
||||
setCoverSearchQuery('')
|
||||
} else {
|
||||
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 })
|
||||
setCustomReminder(false)
|
||||
setCoverPreview(null)
|
||||
setCoverSearchQuery('')
|
||||
}
|
||||
setPendingCoverFile(null)
|
||||
setPendingUnsplashUrl(null)
|
||||
setCoverSearchResults([])
|
||||
setCoverSearchError('')
|
||||
setSelectedMembers([])
|
||||
setError('')
|
||||
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
|
||||
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()
|
||||
} catch (err: unknown) {
|
||||
@@ -152,6 +178,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (!file) return
|
||||
// HEIC/HEIF from iOS can't be rendered or stored as-is — convert to JPEG first
|
||||
const normalized = await normalizeImageFile(file)
|
||||
setPendingUnsplashUrl(null)
|
||||
if (isEditing && trip?.id) {
|
||||
// Existing trip: upload immediately
|
||||
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 () => {
|
||||
if (pendingCoverFile) {
|
||||
if (pendingCoverFile || pendingUnsplashUrl) {
|
||||
setPendingCoverFile(null)
|
||||
setPendingUnsplashUrl(null)
|
||||
setCoverPreview(null)
|
||||
return
|
||||
}
|
||||
@@ -288,6 +357,42 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||
</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>
|
||||
|
||||
@@ -697,7 +697,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</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)} />
|
||||
<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} />
|
||||
<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} />}
|
||||
|
||||
+11
-9
@@ -2,6 +2,8 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
const backendTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3001'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
@@ -126,42 +128,42 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001',
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'http://localhost:3001',
|
||||
target: backendTarget,
|
||||
ws: true,
|
||||
},
|
||||
'/mcp': {
|
||||
target: 'http://localhost:3001',
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
// 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/consent is served by Vite as a SPA route (no proxy entry needed)
|
||||
'/oauth/authorize': {
|
||||
target: 'http://localhost:3001',
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/token': {
|
||||
target: 'http://localhost:3001',
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/register': {
|
||||
target: 'http://localhost:3001',
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/revoke': {
|
||||
target: 'http://localhost:3001',
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/.well-known': {
|
||||
target: 'http://localhost:3001',
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -71,6 +71,21 @@ export class TripsController {
|
||||
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()
|
||||
@HttpCode(201)
|
||||
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)) {
|
||||
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 {
|
||||
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) {
|
||||
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}`);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { listItems as listTodoItems } from '../../services/todoService';
|
||||
import { listBudgetItems } from '../../services/budgetService';
|
||||
import { listReservations } from '../../services/reservationService';
|
||||
import { listFiles } from '../../services/fileService';
|
||||
import { searchUnsplashPhotos } from '../../services/unsplashService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing trip service + the per-domain list
|
||||
@@ -49,6 +50,10 @@ export class TripsService {
|
||||
return tripSvc.getTripRaw(tripId);
|
||||
}
|
||||
|
||||
searchCoverImages(userId: number, query: string) {
|
||||
return searchUnsplashPhotos(userId, query, 9);
|
||||
}
|
||||
|
||||
getOwner(tripId: string) {
|
||||
return tripSvc.getTripOwner(tripId);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './kmlImport';
|
||||
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
import { searchUnsplashPhotos } from './unsplashService';
|
||||
|
||||
// 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
|
||||
@@ -42,11 +43,6 @@ interface PlaceWithCategory extends Place {
|
||||
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 {
|
||||
places: any[];
|
||||
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;
|
||||
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;
|
||||
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 };
|
||||
return searchUnsplashPhotos(userId, place.name + (place.address ? ' ' + place.address : ''), 5);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
* 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';
|
||||
|
||||
@@ -541,20 +541,31 @@ describe('searchPlaceImage', () => {
|
||||
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 trip = createTrip(testDb, user.id);
|
||||
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;
|
||||
expect(result.error).toMatch(/No Unsplash API key/);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.photos).toHaveLength(1);
|
||||
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 () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
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 = [
|
||||
{ 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.coverSaved': 'Cover image saved',
|
||||
'dashboard.coverUploadError': 'Failed to upload',
|
||||
'dashboard.coverSaveError': 'Failed to save cover image',
|
||||
'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.endDateError': 'End date must be after start date',
|
||||
'dashboard.greeting.morning': 'Good morning,',
|
||||
|
||||
Reference in New Issue
Block a user