[+] Unsplash

This commit is contained in:
Azalea
2026-06-21 07:43:39 +00:00
committed by Maurice
parent c7e8a5614d
commit 8c941b52f9
11 changed files with 288 additions and 52 deletions
+2 -1
View File
@@ -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' });
});
});
});
+110 -5
View File
@@ -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>
+7 -1
View File
@@ -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
View File
@@ -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,
},
}
+21
View File
@@ -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}`);
+5
View File
@@ -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);
}
+2 -30
View File
@@ -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);
}
+69
View File
@@ -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' } },
+7
View File
@@ -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,',