Compare commits

...

2 Commits

Author SHA1 Message Date
Maurice 74abaa9d7c test(unsplash): check both refusals without awaiting in a loop 2026-10-28 16:30:54 +01:00
Maurice 091505d17c fix(unsplash): tell a server Unsplash turns away that the free key helps
Without an Access Key the image search goes through Unsplash's web endpoint,
which refuses datacenter addresses with a 401 or 403 or answers with a
challenge page. The search then said only "Unsplash search unavailable",
although an admin can already enter a free key in the settings. Such a
refusal now answers 502 with that hint and the code unsplash_key_needed, and
the cover search shows it in the user's language.

Closes #1449
2026-10-28 16:30:54 +01:00
31 changed files with 123 additions and 4 deletions
@@ -135,6 +135,16 @@ describe('useTripCoverPicker', () => {
expect(result.current.searchError).toBe('offline');
});
it('FE-COMP-TRIPCOVER-010b: a server Unsplash turned away says the admin can add the key (#1449)', async () => {
vi.spyOn(tripsApi, 'searchCoverImages').mockRejectedValueOnce({
response: { status: 502, data: { error: 'Unsplash refused this server.', code: 'unsplash_key_needed' } },
});
const { result } = picker();
act(() => result.current.setSearchQuery('Lisbon'));
await act(() => result.current.search());
expect(result.current.searchError).toBe('dashboard.unsplashKeyNeeded');
});
it('FE-COMP-TRIPCOVER-011: only the latest search applies its results', async () => {
let resolveFirst!: (v: { photos: (typeof photo)[] }) => void;
vi.spyOn(tripsApi, 'searchCoverImages')
@@ -4,6 +4,7 @@ import { tripsApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { isEffectivelyOffline } from '../../sync/networkMode';
import { getApiErrorMessage } from '../../types';
import { getApiErrorCode } from '../../utils/apiError';
import { normalizeImageFile } from '../../utils/convertHeic';
import { refusedOffline } from '../../utils/needsConnection';
import { useToast } from '../shared/Toast';
@@ -149,7 +150,12 @@ export function useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewO
if (photos.length === 0) setSearchError(t('dashboard.unsplashNoResults'));
} catch (err: unknown) {
if (seq !== coverSearchSeq.current) return;
setSearchError(getApiErrorMessage(err, t('dashboard.coverSearchError')));
// #1449: Unsplash turns a datacenter address away until an admin enters the free key.
setSearchError(
getApiErrorCode(err) === 'unsplash_key_needed'
? t('dashboard.unsplashKeyNeeded')
: getApiErrorMessage(err, t('dashboard.coverSearchError'))
);
} finally {
if (seq === coverSearchSeq.current) setSearching(false);
}
@@ -41,6 +41,16 @@ const MAX_COVER_BYTES = 15 * 1024 * 1024;
// Unsplash is a third party on the request path; safeFetch adds no deadline of
// its own, so both the search and the cover download carry one.
const UNSPLASH_TIMEOUT_MS = 10_000;
/**
* What a search is told when the keyless web endpoint turns this server away, as it
* does a datacenter (VPS) address: the free Access Key is the way out, and the admin
* who can enter it is the one to tell (#1449). The code lets the client say it in
* the user's language.
*/
const UNSPLASH_KEY_NEEDED =
'Unsplash refused this server. An administrator can add a free Unsplash Access Key in Admin > Settings.';
const UNSPLASH_KEY_NEEDED_CODE = 'unsplash_key_needed';
const COVER_EXT_BY_TYPE: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
@@ -120,10 +130,18 @@ export class UnsplashService {
'Sec-Fetch-Site': 'same-origin',
},
});
// Turned away without a key: a 401 or 403, or an ok answer that is a challenge page, not JSON.
const refused = () =>
new DomainError(502, UNSPLASH_KEY_NEEDED, {
code: UNSPLASH_KEY_NEEDED_CODE,
details: { code: UNSPLASH_KEY_NEEDED_CODE },
});
if (!accessKey && (response.status === 401 || response.status === 403)) throw refused();
let data: UnsplashSearchResponse;
try {
data = (await response.json()) as UnsplashSearchResponse;
} catch {
if (!accessKey && response.ok) throw refused();
throw new DomainError(response.ok ? 502 : response.status, 'Unsplash search unavailable');
}
@@ -1,6 +1,7 @@
import type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../../../src/db/repositories/Users.repository';
import { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
import { DomainError } from '../../../../src/nest/common/domain-error';
import { UnsplashService } from '../../../../src/nest/unsplash/unsplash.service';
import { asLegacyResult } from '../../../helpers/domain-error';
import { makeStorageFixture } from '../../../helpers/storage-fixture';
@@ -40,6 +41,9 @@ const getUnsplashKey = svc.getUnsplashKey.bind(svc);
const saveUnsplashCover = svc.saveUnsplashCover.bind(svc);
const isUnsplashCoverUrl = svc.isUnsplashCoverUrl.bind(svc);
const KEY_NEEDED =
'Unsplash refused this server. An administrator can add a free Unsplash Access Key in Admin > Settings.';
const ORIGINAL_UNSPLASH_ENV = process.env.UNSPLASH_ACCESS_KEY;
afterEach(() => {
@@ -99,13 +103,39 @@ describe('unsplashService.searchUnsplashPhotos', () => {
it('UNSPLASH-016: a 200 carrying unparseable JSON becomes a 502, not a crash', async () => {
// The web endpoint answers 200 with an HTML challenge page when it decides the
// caller is a datacenter (#1449), so a parse failure on an ok response is an
// upstream problem, not the caller's.
// upstream problem, not the caller's: without a key, the key is the way out.
const bad = fakeRes({ ok: true, type: 'text/html' });
(bad as { json: () => Promise<unknown> }).json = async () => {
throw new SyntaxError('Unexpected token < in JSON');
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(bad));
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Unsplash search unavailable', status: 502 });
expect(await searchUnsplashPhotos('paris')).toMatchObject({ error: KEY_NEEDED, status: 502 });
// With a key the official API answered, so no key is named.
expect(await searchUnsplashPhotos('paris', 9, 'key')).toEqual({
error: 'Unsplash search unavailable',
status: 502,
});
});
it('UNSPLASH-016b: a keyless search turned away with 401 or 403 says the Access Key is the way out (#1449)', async () => {
const refusedWith = (status: number) => {
const refused = fakeRes({ ok: false, status, type: 'text/plain' });
(refused as { json: () => Promise<unknown> }).json = async () => {
throw new SyntaxError('Unexpected token A in JSON');
};
return refused;
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(refusedWith(401)).mockResolvedValueOnce(refusedWith(403)));
const errors = [
await svc.searchUnsplashPhotos('paris').catch((e: unknown) => e),
await svc.searchUnsplashPhotos('paris').catch((e: unknown) => e),
];
for (const err of errors) {
expect(err).toBeInstanceOf(DomainError);
expect((err as DomainError).getStatus()).toBe(502);
expect(err).toMatchObject({ publicMessage: KEY_NEEDED, code: 'unsplash_key_needed' });
expect((err as DomainError).toBody()).toEqual({ error: KEY_NEEDED, code: 'unsplash_key_needed' });
}
});
it('UNSPLASH-017: a failing response with unparseable JSON keeps its own status', async () => {
@@ -134,7 +164,8 @@ describe('unsplashService.searchUnsplashPhotos', () => {
fakeRes({ ok: false, status: 401, type: 'application/json', json: { error: 'Bad credentials' } }),
),
);
expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Bad credentials', status: 401 });
// A key the official API refuses is named by Unsplash itself.
expect(await searchUnsplashPhotos('paris', 9, 'stale-key')).toEqual({ error: 'Bad credentials', status: 401 });
});
it('UNSPLASH-004: returns normalised photos on success and drops entries missing a url/thumb', async () => {
+2
View File
@@ -124,6 +124,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'أدخل عبارة بحث',
'dashboard.unsplashNoResults': 'لم يتم العثور على صور',
'dashboard.coverSearchError': 'فشل البحث في Unsplash',
'dashboard.unsplashKeyNeeded':
'رفض Unsplash هذا الخادم. يمكن للمسؤول إضافة مفتاح Unsplash API مجاني في إعدادات المسؤول لتعمل عملية البحث عن الصور.',
'dashboard.useUnsplashPhoto': 'استخدم صورة من Unsplash بواسطة {photographer}',
'dashboard.titleRequired': 'العنوان مطلوب',
'dashboard.endDateError': 'يجب أن يكون تاريخ النهاية بعد البداية',
+2
View File
@@ -111,6 +111,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Axtarış sözü daxil edin',
'dashboard.unsplashNoResults': 'Şəkil tapılmadı',
'dashboard.coverSearchError': 'Unsplash-da axtarış aparmaq mümkün olmadı',
'dashboard.unsplashKeyNeeded':
'Unsplash bu serveri rədd etdi. Şəkil axtarışının işləməsi üçün administrator admin ayarlarında pulsuz Unsplash API açarı əlavə edə bilər.',
'dashboard.useUnsplashPhoto': '{photographer} tərəfindən çəkilmiş Unsplash şəklindən istifadə et',
'dashboard.titleRequired': 'Başlıq tələb olunur',
'dashboard.endDateError': 'Bitmə tarixi başlama tarixindən sonra olmalıdır',
+2
View File
@@ -96,6 +96,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Digite um termo de busca',
'dashboard.unsplashNoResults': 'Nenhuma imagem encontrada',
'dashboard.coverSearchError': 'Falha ao buscar no Unsplash',
'dashboard.unsplashKeyNeeded':
'O Unsplash recusou este servidor. Um administrador pode adicionar uma chave de API gratuita do Unsplash nas configurações de administração para a busca de imagens funcionar.',
'dashboard.useUnsplashPhoto': 'Usar foto do Unsplash por {photographer}',
'dashboard.titleRequired': 'O título é obrigatório',
'dashboard.endDateError': 'A data final deve ser depois da inicial',
+2
View File
@@ -186,6 +186,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Introdueix un terme de cerca',
'dashboard.unsplashNoResults': "No s'han trobat imatges",
'dashboard.coverSearchError': 'Error en cercar a Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash ha rebutjat aquest servidor. Un administrador pot afegir una clau d’API gratuïta d’Unsplash a la configuració d’administració perquè funcioni la cerca d’imatges.',
'dashboard.useUnsplashPhoto': "Utilitzar foto d'Unsplash de {photographer}",
'dashboard.dayCountRequired': 'El nombre de dies és obligatori',
'dashboard.dateShiftTitle': "Nova data d'inici",
+2
View File
@@ -103,6 +103,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Zadejte hledaný výraz',
'dashboard.unsplashNoResults': 'Nebyly nalezeny žádné obrázky',
'dashboard.coverSearchError': 'Nepodařilo se vyhledat na Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash tento server odmítl. Správce může v nastavení administrace přidat bezplatný klíč Unsplash API, aby vyhledávání obrázků fungovalo.',
'dashboard.useUnsplashPhoto': 'Použít fotku z Unsplash od {photographer}',
'dashboard.titleRequired': 'Název je povinný',
'dashboard.endDateError': 'Datum konce musí být po datu začátku',
+2
View File
@@ -97,6 +97,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Suchbegriff eingeben',
'dashboard.unsplashNoResults': 'Keine Bilder gefunden',
'dashboard.coverSearchError': 'Unsplash-Suche fehlgeschlagen',
'dashboard.unsplashKeyNeeded':
'Unsplash hat diesen Server abgewiesen. Ein Administrator kann in den Admin-Einstellungen einen kostenlosen Unsplash-API-Schlüssel eintragen, dann funktioniert die Bildsuche.',
'dashboard.useUnsplashPhoto': 'Unsplash-Foto von {photographer} verwenden',
'dashboard.titleRequired': 'Titel ist erforderlich',
'dashboard.endDateError': 'Enddatum muss nach dem Startdatum liegen',
+2
View File
@@ -110,6 +110,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Enter a search term',
'dashboard.unsplashNoResults': 'No images found',
'dashboard.coverSearchError': 'Failed to search Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash turned this server away. An administrator can add a free Unsplash API Key in the admin settings to make image search work.',
'dashboard.useUnsplashPhoto': 'Use Unsplash photo by {photographer}',
'dashboard.titleRequired': 'Title is required',
'dashboard.endDateError': 'End date must be after start date',
+2
View File
@@ -96,6 +96,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Introduce un término de búsqueda',
'dashboard.unsplashNoResults': 'No se encontraron imágenes',
'dashboard.coverSearchError': 'No se pudo buscar en Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash ha rechazado este servidor. Un administrador puede añadir una clave de API gratuita de Unsplash en los ajustes de administración para que funcione la búsqueda de imágenes.',
'dashboard.useUnsplashPhoto': 'Usar foto de Unsplash de {photographer}',
'dashboard.titleRequired': 'El título es obligatorio',
'dashboard.endDateError': 'La fecha de fin debe ser posterior a la de inicio',
+2
View File
@@ -110,6 +110,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Sisesta otsingusõna',
'dashboard.unsplashNoResults': 'Pilte ei leitud',
'dashboard.coverSearchError': 'Unsplashi otsing ebaõnnestus',
'dashboard.unsplashKeyNeeded':
'Unsplash lükkas selle serveri tagasi. Administraator saab halduse seadetes lisada tasuta Unsplash API võtme, et pildiotsing töötaks.',
'dashboard.useUnsplashPhoto': 'Kasuta Unsplashi fotot autorilt {photographer}',
'dashboard.titleRequired': 'Pealkiri on kohustuslik',
'dashboard.endDateError': 'Lõppkuupäev peab olema alguskuupäevast hilisem',
+2
View File
@@ -98,6 +98,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Saisissez un terme de recherche',
'dashboard.unsplashNoResults': 'Aucune image trouvée',
'dashboard.coverSearchError': 'Échec de la recherche Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash a refusé ce serveur. Un administrateur peut ajouter une clé API Unsplash gratuite dans les paramètres d’administration pour que la recherche d’images fonctionne.',
'dashboard.useUnsplashPhoto': 'Utiliser la photo Unsplash de {photographer}',
'dashboard.titleRequired': 'Le titre est obligatoire',
'dashboard.endDateError': 'La date de fin doit être postérieure à la date de début',
+2
View File
@@ -108,6 +108,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Πληκτρολογήστε όρο αναζήτησης',
'dashboard.unsplashNoResults': 'Δεν βρέθηκαν εικόνες',
'dashboard.coverSearchError': 'Αποτυχία αναζήτησης στο Unsplash',
'dashboard.unsplashKeyNeeded':
'Το Unsplash απέρριψε αυτόν τον διακομιστή. Ένας διαχειριστής μπορεί να προσθέσει ένα δωρεάν κλειδί Unsplash API στις ρυθμίσεις διαχείρισης ώστε να λειτουργεί η αναζήτηση εικόνων.',
'dashboard.useUnsplashPhoto': 'Χρήση φωτογραφίας Unsplash από {photographer}',
'dashboard.titleRequired': 'Ο τίτλος είναι υποχρεωτικός',
'dashboard.endDateError': 'Η ημερομηνία λήξης πρέπει να είναι μετά την ημερομηνία έναρξης',
+2
View File
@@ -98,6 +98,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Adj meg egy keresési kifejezést',
'dashboard.unsplashNoResults': 'Nem található kép',
'dashboard.coverSearchError': 'Nem sikerült keresni az Unsplashen',
'dashboard.unsplashKeyNeeded':
'Az Unsplash elutasította ezt a szervert. Egy rendszergazda az adminisztrációs beállításokban megadhat egy ingyenes Unsplash API-kulcsot, és akkor működik a képkeresés.',
'dashboard.useUnsplashPhoto': '{photographer} Unsplash-fotójának használata',
'dashboard.titleRequired': 'A cím megadása kötelező',
'dashboard.endDateError': 'A záró dátumnak a kezdő dátum után kell lennie',
+2
View File
@@ -89,6 +89,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Masukkan istilah pencarian',
'dashboard.unsplashNoResults': 'Tidak ada gambar ditemukan',
'dashboard.coverSearchError': 'Gagal mencari di Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash menolak server ini. Administrator dapat menambahkan kunci API Unsplash gratis di pengaturan admin agar pencarian gambar berfungsi.',
'dashboard.useUnsplashPhoto': 'Gunakan foto Unsplash oleh {photographer}',
'dashboard.titleRequired': 'Judul wajib diisi',
'dashboard.endDateError': 'Tanggal selesai harus setelah tanggal mulai',
+2
View File
@@ -98,6 +98,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Inserisci un termine di ricerca',
'dashboard.unsplashNoResults': 'Nessuna immagine trovata',
'dashboard.coverSearchError': 'Ricerca su Unsplash non riuscita',
'dashboard.unsplashKeyNeeded':
'Unsplash ha rifiutato questo server. Un amministratore può aggiungere una chiave API Unsplash gratuita nelle impostazioni di amministrazione per far funzionare la ricerca di immagini.',
'dashboard.useUnsplashPhoto': 'Usa foto Unsplash di {photographer}',
'dashboard.titleRequired': 'Il titolo è obbligatorio',
'dashboard.endDateError': 'La data di fine deve essere successiva alla data di inizio',
+2
View File
@@ -104,6 +104,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': '検索語を入力してください',
'dashboard.unsplashNoResults': '画像が見つかりません',
'dashboard.coverSearchError': 'Unsplashの検索に失敗しました',
'dashboard.unsplashKeyNeeded':
'Unsplash がこのサーバーを拒否しました。管理者が管理設定で無料の Unsplash API キーを追加すると、画像検索が使えるようになります。',
'dashboard.useUnsplashPhoto': '{photographer}さんのUnsplash写真を使用',
'dashboard.titleRequired': 'タイトルは必須です',
'dashboard.endDateError': '終了日は開始日より後にしてください',
+2
View File
@@ -104,6 +104,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': '검색어를 입력하세요',
'dashboard.unsplashNoResults': '이미지를 찾을 수 없습니다',
'dashboard.coverSearchError': 'Unsplash 검색에 실패했습니다',
'dashboard.unsplashKeyNeeded':
'Unsplash가 이 서버를 거부했습니다. 관리자가 관리자 설정에서 무료 Unsplash API 키를 추가하면 이미지 검색이 작동합니다.',
'dashboard.useUnsplashPhoto': '{photographer}님의 Unsplash 사진 사용',
'dashboard.titleRequired': '제목을 입력하세요',
'dashboard.endDateError': '종료일은 시작일 이후여야 합니다',
+2
View File
@@ -96,6 +96,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Voer een zoekterm in',
'dashboard.unsplashNoResults': 'Geen afbeeldingen gevonden',
'dashboard.coverSearchError': 'Zoeken op Unsplash mislukt',
'dashboard.unsplashKeyNeeded':
'Unsplash heeft deze server geweigerd. Een beheerder kan in de beheerinstellingen een gratis Unsplash-API-sleutel toevoegen, dan werkt het zoeken naar afbeeldingen.',
'dashboard.useUnsplashPhoto': 'Unsplash-foto van {photographer} gebruiken',
'dashboard.titleRequired': 'Titel is verplicht',
'dashboard.endDateError': 'Einddatum moet na de startdatum liggen',
+2
View File
@@ -102,6 +102,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Wpisz szukaną frazę',
'dashboard.unsplashNoResults': 'Nie znaleziono obrazów',
'dashboard.coverSearchError': 'Nie udało się wyszukać w Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash odrzucił ten serwer. Administrator może dodać darmowy klucz Unsplash API w ustawieniach administracyjnych, aby wyszukiwanie obrazów działało.',
'dashboard.useUnsplashPhoto': 'Użyj zdjęcia z Unsplash autorstwa {photographer}',
'dashboard.titleRequired': 'Nazwa podróży jest wymagana',
'dashboard.endDateError': 'Data zakończenia musi być po dacie rozpoczęcia',
+2
View File
@@ -110,6 +110,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Введите поисковый запрос',
'dashboard.unsplashNoResults': 'Изображения не найдены',
'dashboard.coverSearchError': 'Не удалось выполнить поиск в Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash отклонил этот сервер. Администратор может добавить бесплатный ключ Unsplash API в настройках администрирования, чтобы заработал поиск изображений.',
'dashboard.useUnsplashPhoto': 'Использовать фото Unsplash от {photographer}',
'dashboard.titleRequired': 'Название обязательно',
'dashboard.endDateError': 'Дата окончания должна быть позже даты начала',
+2
View File
@@ -117,6 +117,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Zadajte hľadaný výraz',
'dashboard.unsplashNoResults': 'Nenašli sa žiadne obrázky',
'dashboard.coverSearchError': 'Nepodarilo sa vyhľadať na Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash tento server odmietol. Správca môže v nastaveniach administrácie pridať bezplatný kľúč Unsplash API, aby vyhľadávanie obrázkov fungovalo.',
'dashboard.useUnsplashPhoto': 'Použiť fotku z Unsplash od {photographer}',
'dashboard.titleRequired': 'Názov je povinný',
'dashboard.endDateError': 'Dátum konca musí byť po dátume začiatku',
+2
View File
@@ -110,6 +110,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Ange en sökterm',
'dashboard.unsplashNoResults': 'Inga bilder hittades',
'dashboard.coverSearchError': 'Det gick inte att söka på Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash avvisade den här servern. En administratör kan lägga till en gratis Unsplash-API-nyckel i administratörsinställningarna så att bildsökningen fungerar.',
'dashboard.useUnsplashPhoto': 'Använd Unsplash-foto av {photographer}',
'dashboard.titleRequired': 'Titel är obligatoriskt',
'dashboard.endDateError': 'Slutdatumet måste ligga efter startdatumet',
+2
View File
@@ -103,6 +103,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'ป้อนคำค้นหา',
'dashboard.unsplashNoResults': 'ไม่พบภาพ',
'dashboard.coverSearchError': 'ไม่สามารถค้นหา Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash ปฏิเสธเซิร์ฟเวอร์นี้ ผู้ดูแลระบบสามารถเพิ่มคีย์ Unsplash API ฟรีในการตั้งค่าผู้ดูแลระบบเพื่อให้การค้นหารูปภาพใช้งานได้',
'dashboard.useUnsplashPhoto': 'ใช้รูปภาพ Unsplash โดย {photographer}',
'dashboard.titleRequired': 'ต้องระบุชื่อ',
'dashboard.endDateError': 'วันที่สิ้นสุดต้องอยู่หลังวันที่เริ่มต้น',
+2
View File
@@ -110,6 +110,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Bir arama terimi girin',
'dashboard.unsplashNoResults': 'Görsel bulunamadı',
'dashboard.coverSearchError': 'Unsplash araması başarısız oldu',
'dashboard.unsplashKeyNeeded':
'Unsplash bu sunucuyu reddetti. Görsel aramanın çalışması için bir yönetici, yönetici ayarlarına ücretsiz bir Unsplash API anahtarı ekleyebilir.',
'dashboard.useUnsplashPhoto': '{photographer} tarafından çekilen Unsplash fotoğrafını kullan',
'dashboard.titleRequired': 'Başlık gerekli',
'dashboard.endDateError': 'Bitiş tarihi başlangıçtan sonra olmalı',
+2
View File
@@ -126,6 +126,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Введіть пошуковий запит',
'dashboard.unsplashNoResults': 'Зображень не знайдено',
'dashboard.coverSearchError': 'Не вдалося виконати пошук в Unsplash',
'dashboard.unsplashKeyNeeded':
'Unsplash відхилив цей сервер. Адміністратор може додати безкоштовний ключ Unsplash API в налаштуваннях адміністрування, щоб запрацював пошук зображень.',
'dashboard.useUnsplashPhoto': 'Використати фото Unsplash від {photographer}',
'dashboard.titleRequired': "Назва обов'язкова",
'dashboard.endDateError': 'Дата закінчення повинна бути пізніше дати початку',
+2
View File
@@ -203,6 +203,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': 'Nhập từ khóa tìm kiếm',
'dashboard.unsplashNoResults': 'Không tìm thấy ảnh nào',
'dashboard.coverSearchError': 'Tìm kiếm trên Unsplash không thành công',
'dashboard.unsplashKeyNeeded':
'Unsplash đã từ chối máy chủ này. Quản trị viên có thể thêm khóa API Unsplash miễn phí trong cài đặt quản trị để tìm kiếm hình ảnh hoạt động.',
'dashboard.useUnsplashPhoto': 'Dùng ảnh Unsplash của {photographer}',
'dashboard.tripChangeNeedsConnection':
'Đổi ngày, số ngày, tiền tệ hoặc ảnh bìa cần có kết nối. Tên, mô tả và lời nhắc có thể đổi khi ngoại tuyến.',
+2
View File
@@ -89,6 +89,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': '請輸入搜尋詞',
'dashboard.unsplashNoResults': '找不到圖片',
'dashboard.coverSearchError': 'Unsplash 搜尋失敗',
'dashboard.unsplashKeyNeeded':
'Unsplash 拒絕了此伺服器。管理員可以在管理設定中新增免費的 Unsplash API 金鑰,以便使用圖片搜尋。',
'dashboard.useUnsplashPhoto': '使用 {photographer} 的 Unsplash 照片',
'dashboard.titleRequired': '標題為必填項',
'dashboard.endDateError': '結束日期必須晚於開始日期',
+2
View File
@@ -89,6 +89,8 @@ const dashboard: TranslationStrings = {
'dashboard.unsplashQueryRequired': '请输入搜索词',
'dashboard.unsplashNoResults': '未找到图片',
'dashboard.coverSearchError': 'Unsplash 搜索失败',
'dashboard.unsplashKeyNeeded':
'Unsplash 拒绝了此服务器。管理员可以在管理设置中添加免费的 Unsplash API 密钥,以便使用图片搜索。',
'dashboard.useUnsplashPhoto': '使用 {photographer} 的 Unsplash 照片',
'dashboard.titleRequired': '标题为必填项',
'dashboard.endDateError': '结束日期必须晚于开始日期',