feat(studio): add photos from Immich and Synology like the journey gallery (#2271)

This commit is contained in:
Maurice
2026-10-01 14:19:51 +02:00
parent 81d7022b96
commit f9baf3395e
35 changed files with 265 additions and 28 deletions
@@ -4,12 +4,13 @@ import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n'
import { journeyApi, addonsApi, memoriesApi } from '../../api/client'
import { journeyApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker'
import { useConnectedPhotoProviders } from './useConnectedPhotoProviders'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import EmptyState from '../shared/EmptyState'
@@ -22,7 +23,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onRefresh: () => void
/** What the picker's Add does: the host's useProviderPhotoAdds, shared with the phone screen. */
onAddProviderPhotos: (journeyId: number, provider: string, groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>
onAddProviderPhotos: (journeyId: number, provider: string, groups: ProviderPhotoGroup[], entryId: number | null) => Promise<unknown>
onRegisterUpload?: (fn: () => void) => void
onRegisterProviders?: (providers: { id: string; name: string }[], browse: (provider: string) => void) => void
}) {
@@ -33,26 +34,13 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
const galleryUploading = galleryProgress !== null
const toast = useToast()
// check which providers are enabled AND connected for the current user, then
// hand them up: the page header renders the provider buttons next to Upload
// The providers enabled AND connected for the current user, handed up: the page
// header renders the provider buttons next to Upload.
const connectedProviders = useConnectedPhotoProviders()
useEffect(() => {
(async () => {
try {
const addonsData = await addonsApi.enabled()
const enabledProviders = (addonsData.addons || []).filter(
(a: any) => a.type === 'photo_provider' && a.enabled
)
const connected: { id: string; name: string }[] = []
for (const p of enabledProviders) {
try {
const status = await memoriesApi.status(p.id)
if (status.connected) connected.push({ id: p.id, name: p.name })
} catch { }
}
onRegisterProviders?.(connected, browseProvider)
} catch { }
})()
}, [])
onRegisterProviders?.(connectedProviders, browseProvider)
// browseProvider only sets state; registering on the list alone is the old behaviour.
}, [connectedProviders])
const allPhotos = gallery
// A long trip's gallery is hundreds of tiles, and rendering them all at once is
@@ -174,7 +162,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
)}
{/* Delete button */}
<button type="button"
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }}
onClick={(e) => { e.stopPropagation(); void handleDeletePhoto(photo.id) }}
className="absolute top-1.5 right-1.5 w-6 h-6 rounded-full bg-black/60 backdrop-blur text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<X size={12} />
@@ -0,0 +1,29 @@
// FE-JRN-PROVIDERS-001
import { describe, expect, it } from 'vitest'
import { http, HttpResponse } from 'msw'
import { renderHook, waitFor } from '@testing-library/react'
import { server } from '../../../tests/helpers/msw/server'
import { useConnectedPhotoProviders } from './useConnectedPhotoProviders'
describe('useConnectedPhotoProviders (#2271)', () => {
it('FE-JRN-PROVIDERS-001: keeps enabled providers that report connected, drops the rest', async () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({ addons: [
{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true },
{ id: 'synology', name: 'Synology Photos', type: 'photo_provider', enabled: true },
{ id: 'broken', name: 'Broken', type: 'photo_provider', enabled: true },
{ id: 'vacay', name: 'Vacay', type: 'trip', enabled: true },
] })),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/synology/status', () => HttpResponse.json({ connected: false })),
http.get('/api/integrations/memories/broken/status', () => HttpResponse.json({}, { status: 500 })),
)
const { result } = renderHook(() => useConnectedPhotoProviders())
await waitFor(() => expect(result.current).toEqual([{ id: 'immich', name: 'Immich' }]))
})
it('FE-JRN-PROVIDERS-002: asks nothing while switched off', () => {
const { result } = renderHook(() => useConnectedPhotoProviders(false))
expect(result.current).toEqual([])
})
})
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react'
import { addonsApi, memoriesApi } from '../../api/client'
export interface ConnectedPhotoProvider { id: string; name: string }
/**
* The photo providers (Immich, Synology Photos) that are switched on for the
* instance AND connected for this user, for every place that offers to pick
* from them: the journey gallery and Studio (#2271). A provider whose status
* cannot be read is left out rather than offered and then failing.
*/
export function useConnectedPhotoProviders(enabled = true): ConnectedPhotoProvider[] {
const [providers, setProviders] = useState<ConnectedPhotoProvider[]>([])
useEffect(() => {
if (!enabled) return
let cancelled = false
void (async () => {
try {
const addonsData = await addonsApi.enabled()
const enabledProviders = (addonsData.addons || []).filter(
(a: { type?: string; enabled?: boolean }) => a.type === 'photo_provider' && a.enabled,
) as { id: string; name: string }[]
const connected: ConnectedPhotoProvider[] = []
for (const p of enabledProviders) {
try {
const status = await memoriesApi.status(p.id)
if (status.connected) connected.push({ id: p.id, name: p.name })
} catch { /* not reachable for this user: not offered */ }
}
if (!cancelled) setProviders(connected)
} catch { /* no addon list: nothing to offer */ }
})()
return () => { cancelled = true }
}, [enabled])
return providers
}
@@ -9,6 +9,8 @@ import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
import { foliosOf } from './bookSheets'
import { StudioSidebar } from './StudioSidebar'
import { ProviderPicker } from '../Journey/JourneyDetailPageProviderPicker'
import { useAuthStore } from '../../store/authStore'
import { StudioCanvas } from './StudioCanvas'
import { StudioInspector } from './StudioInspector'
import { StudioWordmark } from './StudioWordmark'
@@ -82,6 +84,19 @@ export default function StudioShell() {
aria-label={s.t('journey.studio.title')}
>
<StudioBar s={s} bookView={bookView} setBookView={setBookView} onExport={() => setExporting(true)} />
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
{s.providerPick && s.journey && (
<ProviderPicker
provider={s.providerPick.provider}
userId={useAuthStore.getState().user?.id || 0}
entries={s.journey.entries.filter(e => e.type !== 'skeleton' || e.title)}
trips={s.journey.trips}
existingAssetIds={new Set((s.journey.gallery ?? []).filter(p => p.asset_id).map(p => p.asset_id!))}
initialEntryId={s.providerPick.entryId}
onClose={s.closeProviderPick}
onAdd={s.addFromProvider}
/>
)}
<div className="st-body">
<StudioSidebar
@@ -96,6 +111,8 @@ export default function StudioShell() {
canEdit={s.canEdit}
onUpload={s.uploadPhotos}
onToggleStop={s.setStopExcluded}
providers={s.photoProviders}
onBrowseProvider={s.browseProvider}
/>
<Workbench s={s} bookView={bookView} />
<StudioInspector
+36 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Camera, Check, ChevronDown, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate,
Camera, Check, ChevronDown, ImagePlus, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate,
Plus, Search, Shapes, Trash2, Upload, X,
} from 'lucide-react'
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
@@ -86,7 +86,7 @@ const THUMB_CHROME = (2 + 3) * 2
export function StudioSidebar({
page, pxPerMm, bookView, source, stats, path, t, locale,
canEdit, onUpload, onToggleStop,
canEdit, onUpload, onToggleStop, providers = [], onBrowseProvider,
}: {
page: BookPageSetup
pxPerMm: number
@@ -104,6 +104,10 @@ export function StudioSidebar({
onUpload: StudioUploader
/** Switch a stop on or off in the figures. Resolves false when it did not land. */
onToggleStop: (entryId: number, excluded: boolean) => Promise<boolean>
/** Photo providers connected for this user, each a way in next to the upload (#2271). */
providers?: { id: string; name: string }[]
/** Opens the provider picker; answers the photo ids that landed, none when closed. */
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>
}) {
const [section, setSection] = useState<Section>('pages')
@@ -135,7 +139,8 @@ export function StudioSidebar({
<aside className="st-panel st-side">
{section === 'pages' && <PagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
{section === 'content' && (
<ContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload} />
<ContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload}
providers={providers} onBrowseProvider={onBrowseProvider} />
)}
{section === 'elements' && <StudioElementsPanel page={page} t={t} />}
{section === 'travel' && (
@@ -337,7 +342,7 @@ function PagesPanel({
* sees it the moment the panel does.
*/
function ContentPanel({
source, page, t, locale, canEdit, onUpload,
source, page, t, locale, canEdit, onUpload, providers = [], onBrowseProvider,
}: {
source: JourneySource
page: BookPageSetup
@@ -345,6 +350,8 @@ function ContentPanel({
locale: string
canEdit: boolean
onUpload: StudioUploader
providers?: { id: string; name: string }[]
onBrowseProvider?: (provider: string, entryId: number | null) => Promise<number[]>
}) {
const [tab, setTab] = useState<'photos' | 'text'>('photos')
const [query, setQuery] = useState('')
@@ -394,6 +401,19 @@ function ContentPanel({
}
}
/**
* From a photo provider (#2271): into the same place an upload goes, and the
* new pictures shown the same way, under their own filter.
*/
const pickFromProvider = async (provider: string) => {
if (!onBrowseProvider) return
const photoIds = await onBrowseProvider(provider, filter.kind === 'entry' ? filter.id : null)
if (photoIds.length) {
setFilter({ kind: 'recent', photoIds })
setQuery('')
}
}
/*
* The whole scrolling area takes a drop, not just the dashed cell: a drag
* from the desktop arrives with the pointer wherever it arrives, and a target
@@ -587,6 +607,18 @@ function ContentPanel({
</span>
</button>
)}
{canEdit && onBrowseProvider && providers.map(p => (
<button type="button"
key={p.id}
className="st-photo-cell is-upload"
onClick={() => { void pickFromProvider(p.id) }}
disabled={!!upload}
title={t('journey.studio.fromProviderHint', { name: p.name })}
>
<ImagePlus size={18} strokeWidth={1.6} />
<span>{t('journey.studio.fromProvider', { name: p.name })}</span>
</button>
))}
{photos.map(p => (
<button type="button"
key={p.photoId}
@@ -31,17 +31,20 @@ export function useProviderPhotoAdds(reload: () => void) {
/**
* The picker's Add: every group onto the entry picked in it, or onto the
* gallery, then one count, one reload and at most one error.
* gallery, then one count, one reload and at most one error. Answers the
* trek photo ids that landed, for a host that wants to show them (Studio's
* "just added" filter, #2271); the others ignore it.
*/
const addPickedPhotos = async (
journeyId: number,
provider: string,
groups: ProviderPhotoGroup[],
entryId: number | null,
): Promise<void> => {
): Promise<number[]> => {
const { addProviderPhotos, addProviderPhotosToGallery } = useJourneyStore.getState()
let added = 0
let anyFailed = false
const photoIds: number[] = []
// One group after the other: the server numbers photos in the order they
// arrive, and each group is already sorted oldest first.
for (const group of groups) {
@@ -50,6 +53,9 @@ export function useProviderPhotoAdds(reload: () => void) {
? await addProviderPhotos(entryId, provider, group)
: await addProviderPhotosToGallery(journeyId, provider, group)
added += result.added
for (const p of (result as { photos?: { photo_id?: unknown }[] }).photos ?? []) {
if (typeof p.photo_id === 'number') photoIds.push(p.photo_id)
}
} catch (err) {
added += addedBeforeFailure(err)
anyFailed = true
@@ -57,6 +63,7 @@ export function useProviderPhotoAdds(reload: () => void) {
}
showAdded(added)
if (anyFailed) toast.error(t('common.error'))
return photoIds
}
/**
@@ -11,6 +11,9 @@ import { uploadStudioPhotos, type StudioUploader } from '../../components/Studio
import { useBookStore } from '../../components/Studio/useBookStore'
import { useBookPresence } from '../../components/Studio/useBookPresence'
import { useTranslation } from '../../i18n'
import { useConnectedPhotoProviders } from '../../components/Journey/useConnectedPhotoProviders'
import type { ProviderPhotoGroup } from '../../components/Journey/JourneyDetailPageProviderPicker'
import { useProviderPhotoAdds } from '../journeyDetail/useProviderPhotoAdds'
import { useIsMobile } from '../../hooks/useIsMobile'
import { PAGE_PRESETS, clampPageSize, type PagePresetId } from '../../components/Studio/pagePresets'
import {
@@ -691,6 +694,32 @@ export function useJourneyStudio() {
}
}, [journeyId, toast, t])
/*
* Pictures from a photo provider (#2271), the same picker the journey gallery
* opens. The content browser asks and waits: the promise answers the trek photo
* ids that landed, or none when the picker was closed, so the browser can show
* the new ones the way it shows an upload.
*/
const photoProviders = useConnectedPhotoProviders(canEdit)
const providerAdds = useProviderPhotoAdds(() => { void loadJourney(journeyId) })
const [providerPick, setProviderPick] = useState<{ provider: string; entryId: number | null } | null>(null)
const pickAnswer = useRef<((photoIds: number[]) => void) | null>(null)
const answerPick = (photoIds: number[]) => {
pickAnswer.current?.(photoIds)
pickAnswer.current = null
setProviderPick(null)
}
const browseProvider = useCallback((provider: string, entryId: number | null) => new Promise<number[]>(resolve => {
pickAnswer.current?.([])
pickAnswer.current = resolve
setProviderPick({ provider, entryId })
}), [])
const closeProviderPick = () => answerPick([])
const addFromProvider = async (groups: ProviderPhotoGroup[], entryId: number | null) => {
if (!providerPick) return
answerPick(await providerAdds.addPickedPhotos(journeyId, providerPick.provider, groups, entryId))
}
/** How far a drop onto the page has got, for the veil over the sheet. */
const [canvasUpload, setCanvasUpload] = useState<{ done: number; total: number } | null>(null)
@@ -772,6 +801,11 @@ export function useJourneyStudio() {
uploadPhotos,
dropFiles,
canvasUpload,
photoProviders,
browseProvider,
providerPick,
closeProviderPick,
addFromProvider,
doc,
page,
@@ -261,3 +261,41 @@ describe('the upload cell', () => {
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], null, expect.any(Function)))
})
})
describe('photos from a provider (#2271)', () => {
it('offers one cell per connected provider, asks with the filtered entry, and shows what landed', async () => {
const onBrowseProvider = vi.fn(async () => [14])
render(
<StudioSidebar
page={page} pxPerMm={96 / 25.4} bookView={false} source={source} stats={null} path={[]}
t={(k: string) => k} locale="en-US" canEdit onUpload={nothing} onToggleStop={async () => true}
providers={[{ id: 'immich', name: 'Immich' }]} onBrowseProvider={onBrowseProvider}
/>,
)
fireEvent.click(screen.getByLabelText('journey.studio.content'))
fireEvent.click(screen.getByTitle('journey.studio.fromProviderHint'))
expect(onBrowseProvider).toHaveBeenCalledWith('immich', null)
await waitFor(() => expect(shown()).toEqual([14]))
})
it('a closed picker changes nothing, and a viewer gets no provider cell', async () => {
const onBrowseProvider = vi.fn(async () => [] as number[])
const { unmount } = render(
<StudioSidebar
page={page} pxPerMm={96 / 25.4} bookView={false} source={source} stats={null} path={[]}
t={(k: string) => k} locale="en-US" canEdit onUpload={nothing} onToggleStop={async () => true}
providers={[{ id: 'immich', name: 'Immich' }]} onBrowseProvider={onBrowseProvider}
/>,
)
fireEvent.click(screen.getByLabelText('journey.studio.content'))
const before = shown()
fireEvent.click(screen.getByTitle('journey.studio.fromProviderHint'))
await waitFor(() => expect(onBrowseProvider).toHaveBeenCalled())
expect(shown()).toEqual(before)
unmount()
openContent(source, { canEdit: false })
expect(screen.queryByTitle('journey.studio.fromProviderHint')).toBeNull()
})
})
+2
View File
@@ -627,6 +627,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'لا توجد صور في هذا الإدخال بعد.',
'journey.studio.noLoosePhotos': 'كل صورة هنا تنتمي إلى إدخال.',
'journey.studio.uploadPhotos': 'رفع الصور',
'journey.studio.fromProvider': 'من {name}',
'journey.studio.fromProviderHint': 'اختر صورًا من {name} وأضفها حيث يذهب الرفع',
'journey.studio.uploadHint': 'أفلت الصور هنا أو انقر لاختيارها',
'journey.studio.uploadToEntry': 'ستُضاف الصور الجديدة إلى هذا الإدخال',
'journey.studio.uploadToGallery': 'ستُضاف الصور الجديدة إلى المعرض',
+4
View File
@@ -745,6 +745,10 @@ const journey: TranslationStrings = {
'journey.studio.noLoosePhotos': 'Buradakı bütün fotolar bir qeydə aiddir.',
'journey.studio.uploadPhotos': 'Fotolar yüklə',
'journey.studio.fromProvider': '{name}-dan',
'journey.studio.fromProviderHint': '{name}-dan foto seçin və yükləmənin düşəcəyi yerə əlavə edin',
'journey.studio.uploadHint':
'Şəkilləri buraya sürükləyib buraxın və ya seçmək üçün klikləyin',
'journey.studio.uploadToEntry':
+2
View File
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Esta entrada ainda não tem fotos.',
'journey.studio.noLoosePhotos': 'Todas as fotos aqui pertencem a uma entrada.',
'journey.studio.uploadPhotos': 'Enviar fotos',
'journey.studio.fromProvider': 'Do {name}',
'journey.studio.fromProviderHint': 'Escolha fotos do {name} e adicione onde um envio iria',
'journey.studio.uploadHint': 'Solte imagens aqui ou clique para escolher',
'journey.studio.uploadToEntry': 'As novas imagens vão para esta entrada',
'journey.studio.uploadToGallery': 'As novas imagens vão para a galeria',
+2
View File
@@ -630,6 +630,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Aquesta entrada encara no té fotos.',
'journey.studio.noLoosePhotos': "Totes les fotos d'aquí pertanyen a una entrada.",
'journey.studio.uploadPhotos': 'Puja fotos',
'journey.studio.fromProvider': 'Des de {name}',
'journey.studio.fromProviderHint': 'Tria fotos de {name} i afegeix-les on aniria una pujada',
'journey.studio.uploadHint': 'Deixa anar imatges aquí o fes clic per triar-ne',
'journey.studio.uploadToEntry': 'Les imatges noves aniran a aquesta entrada',
'journey.studio.uploadToGallery': 'Les imatges noves aniran a la galeria',
+2
View File
@@ -628,6 +628,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Tento záznam zatím nemá žádné fotografie.',
'journey.studio.noLoosePhotos': 'Každá fotografie zde patří k nějakému záznamu.',
'journey.studio.uploadPhotos': 'Nahrát fotografie',
'journey.studio.fromProvider': 'Z {name}',
'journey.studio.fromProviderHint': 'Vyberte fotky z {name} a přidejte je tam, kam by šlo nahrání',
'journey.studio.uploadHint': 'Přetáhněte sem obrázky nebo klikněte a vyberte je',
'journey.studio.uploadToEntry': 'Nové obrázky půjdou do tohoto záznamu',
'journey.studio.uploadToGallery': 'Nové obrázky půjdou do galerie',
+2
View File
@@ -630,6 +630,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Dieser Eintrag hat noch keine Fotos.',
'journey.studio.noLoosePhotos': 'Jedes Foto hier gehört zu einem Eintrag.',
'journey.studio.uploadPhotos': 'Fotos hochladen',
'journey.studio.fromProvider': 'Aus {name}',
'journey.studio.fromProviderHint': 'Fotos aus {name} auswählen und dort hinzufügen, wo auch ein Upload landen würde',
'journey.studio.uploadHint': 'Bilder hier ablegen oder klicken, um welche auszuwählen',
'journey.studio.uploadToEntry': 'Neue Bilder landen in diesem Eintrag',
'journey.studio.uploadToGallery': 'Neue Bilder landen in der Galerie',
+2
View File
@@ -628,6 +628,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'This entry has no photos yet.',
'journey.studio.noLoosePhotos': 'Every photo here belongs to an entry.',
'journey.studio.uploadPhotos': 'Upload photos',
'journey.studio.fromProvider': 'From {name}',
'journey.studio.fromProviderHint': 'Pick photos from {name} and add them where an upload would go',
'journey.studio.uploadHint': 'Drop pictures here, or click to choose some',
'journey.studio.uploadToEntry': 'New pictures go into this entry',
'journey.studio.uploadToGallery': 'New pictures go into the gallery',
+2
View File
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Esta entrada aún no tiene fotos.',
'journey.studio.noLoosePhotos': 'Todas las fotos de aquí pertenecen a una entrada.',
'journey.studio.uploadPhotos': 'Subir fotos',
'journey.studio.fromProvider': 'Desde {name}',
'journey.studio.fromProviderHint': 'Elige fotos de {name} y añádelas donde iría una subida',
'journey.studio.uploadHint': 'Suelta imágenes aquí o haz clic para elegirlas',
'journey.studio.uploadToEntry': 'Las imágenes nuevas irán a esta entrada',
'journey.studio.uploadToGallery': 'Las imágenes nuevas irán a la galería',
+2
View File
@@ -636,6 +636,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Sellel sissekandel pole veel fotosid.',
'journey.studio.noLoosePhotos': 'Kõik siinsed fotod kuuluvad mõne sissekande juurde.',
'journey.studio.uploadPhotos': 'Laadi fotod üles',
'journey.studio.fromProvider': 'Teenusest {name}',
'journey.studio.fromProviderHint': 'Vali fotod teenusest {name} ja lisa need sinna, kuhu läheks üleslaadimine',
'journey.studio.uploadHint': 'Lohista pildid siia või klõpsa valimiseks',
'journey.studio.uploadToEntry': 'Uued pildid lisatakse sellele sissekandele',
'journey.studio.uploadToGallery': 'Uued pildid lisatakse galeriisse',
+2
View File
@@ -631,6 +631,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': "Cette entrée n'a pas encore de photos.",
'journey.studio.noLoosePhotos': 'Chaque photo ici appartient à une entrée.',
'journey.studio.uploadPhotos': 'Téléverser des photos',
'journey.studio.fromProvider': 'Depuis {name}',
'journey.studio.fromProviderHint': 'Choisir des photos dans {name} et les ajouter là où irait un envoi',
'journey.studio.uploadHint': 'Déposez des images ici ou cliquez pour en choisir',
'journey.studio.uploadToEntry': 'Les nouvelles images iront dans cette entrée',
'journey.studio.uploadToGallery': 'Les nouvelles images iront dans la galerie',
+2
View File
@@ -630,6 +630,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Αυτή η καταχώρηση δεν έχει ακόμη φωτογραφίες.',
'journey.studio.noLoosePhotos': 'Κάθε φωτογραφία εδώ ανήκει σε μια καταχώρηση.',
'journey.studio.uploadPhotos': 'Μεταφόρτωση φωτογραφιών',
'journey.studio.fromProvider': 'Από {name}',
'journey.studio.fromProviderHint': 'Επιλέξτε φωτογραφίες από {name} και προσθέστε τες εκεί όπου θα πήγαινε μια μεταφόρτωση',
'journey.studio.uploadHint': 'Αφήστε εικόνες εδώ ή κάντε κλικ για να επιλέξετε',
'journey.studio.uploadToEntry': 'Οι νέες εικόνες θα μπουν σε αυτή την καταχώρηση',
'journey.studio.uploadToGallery': 'Οι νέες εικόνες θα μπουν στη συλλογή',
+2
View File
@@ -628,6 +628,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Ehhez a bejegyzéshez még nincs fotó.',
'journey.studio.noLoosePhotos': 'Itt minden fotó egy bejegyzéshez tartozik.',
'journey.studio.uploadPhotos': 'Fotók feltöltése',
'journey.studio.fromProvider': 'Innen: {name}',
'journey.studio.fromProviderHint': 'Fotók választása innen: {name}, oda, ahová a feltöltés kerülne',
'journey.studio.uploadHint': 'Húzd ide a képeket, vagy kattints a kiválasztáshoz',
'journey.studio.uploadToEntry': 'Az új képek ebbe a bejegyzésbe kerülnek',
'journey.studio.uploadToGallery': 'Az új képek a galériába kerülnek',
+2
View File
@@ -627,6 +627,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Entri ini belum punya foto.',
'journey.studio.noLoosePhotos': 'Semua foto di sini milik sebuah entri.',
'journey.studio.uploadPhotos': 'Unggah foto',
'journey.studio.fromProvider': 'Dari {name}',
'journey.studio.fromProviderHint': 'Pilih foto dari {name} dan tambahkan ke tempat unggahan masuk',
'journey.studio.uploadHint': 'Letakkan gambar di sini atau klik untuk memilih',
'journey.studio.uploadToEntry': 'Gambar baru akan masuk ke entri ini',
'journey.studio.uploadToGallery': 'Gambar baru akan masuk ke galeri',
+2
View File
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Questa voce non ha ancora foto.',
'journey.studio.noLoosePhotos': 'Ogni foto qui appartiene a una voce.',
'journey.studio.uploadPhotos': 'Carica foto',
'journey.studio.fromProvider': 'Da {name}',
'journey.studio.fromProviderHint': 'Scegli foto da {name} e aggiungile dove andrebbe un caricamento',
'journey.studio.uploadHint': 'Trascina qui le immagini o fai clic per sceglierle',
'journey.studio.uploadToEntry': 'Le nuove immagini andranno in questa voce',
'journey.studio.uploadToGallery': 'Le nuove immagini andranno nella galleria',
+2
View File
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'このエントリーにはまだ写真がありません。',
'journey.studio.noLoosePhotos': 'ここにある写真はすべてエントリーに属しています。',
'journey.studio.uploadPhotos': '写真をアップロード',
'journey.studio.fromProvider': '{name}から',
'journey.studio.fromProviderHint': '{name}から写真を選び、アップロードと同じ場所に追加します',
'journey.studio.uploadHint': 'ここに画像をドロップするか、クリックして選択してください',
'journey.studio.uploadToEntry': '新しい画像はこのエントリーに追加されます',
'journey.studio.uploadToGallery': '新しい画像はギャラリーに追加されます',
+2
View File
@@ -628,6 +628,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': '이 항목에는 아직 사진이 없습니다.',
'journey.studio.noLoosePhotos': '여기 있는 사진은 모두 항목에 속해 있습니다.',
'journey.studio.uploadPhotos': '사진 업로드',
'journey.studio.fromProvider': '{name}에서',
'journey.studio.fromProviderHint': '{name}에서 사진을 골라 업로드와 같은 곳에 추가합니다',
'journey.studio.uploadHint': '여기에 이미지를 놓거나 클릭해서 선택하세요',
'journey.studio.uploadToEntry': '새 이미지는 이 항목에 추가됩니다',
'journey.studio.uploadToGallery': '새 이미지는 갤러리에 추가됩니다',
+2
View File
@@ -630,6 +630,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': "Deze vermelding heeft nog geen foto's.",
'journey.studio.noLoosePhotos': 'Elke foto hier hoort bij een vermelding.',
'journey.studio.uploadPhotos': "Foto's uploaden",
'journey.studio.fromProvider': 'Uit {name}',
'journey.studio.fromProviderHint': "Kies foto's uit {name} en voeg ze toe waar een upload terechtkomt",
'journey.studio.uploadHint': 'Sleep afbeeldingen hierheen of klik om ze te kiezen',
'journey.studio.uploadToEntry': 'Nieuwe afbeeldingen komen in deze vermelding',
'journey.studio.uploadToGallery': 'Nieuwe afbeeldingen komen in de galerij',
+2
View File
@@ -628,6 +628,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Ten wpis nie ma jeszcze zdjęć.',
'journey.studio.noLoosePhotos': 'Każde zdjęcie tutaj należy do jakiegoś wpisu.',
'journey.studio.uploadPhotos': 'Prześlij zdjęcia',
'journey.studio.fromProvider': 'Z {name}',
'journey.studio.fromProviderHint': 'Wybierz zdjęcia z {name} i dodaj je tam, gdzie trafiłby upload',
'journey.studio.uploadHint': 'Upuść obrazy tutaj lub kliknij, aby je wybrać',
'journey.studio.uploadToEntry': 'Nowe obrazy trafią do tego wpisu',
'journey.studio.uploadToGallery': 'Nowe obrazy trafią do galerii',
+2
View File
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'В этой записи пока нет фото.',
'journey.studio.noLoosePhotos': 'Каждое фото здесь относится к какой-то записи.',
'journey.studio.uploadPhotos': 'Загрузить фото',
'journey.studio.fromProvider': 'Из {name}',
'journey.studio.fromProviderHint': 'Выбрать фото из {name} и добавить туда, куда попала бы загрузка',
'journey.studio.uploadHint': 'Перетащите изображения сюда или нажмите, чтобы выбрать',
'journey.studio.uploadToEntry': 'Новые изображения попадут в эту запись',
'journey.studio.uploadToGallery': 'Новые изображения попадут в галерею',
+2
View File
@@ -635,6 +635,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Tento záznam zatiaľ nemá žiadne fotografie.',
'journey.studio.noLoosePhotos': 'Každá fotografia tu patrí k niektorému záznamu.',
'journey.studio.uploadPhotos': 'Nahrať fotografie',
'journey.studio.fromProvider': 'Z {name}',
'journey.studio.fromProviderHint': 'Vyberte fotky z {name} a pridajte ich tam, kam by išlo nahranie',
'journey.studio.uploadHint': 'Presuňte sem obrázky alebo kliknite a vyberte ich',
'journey.studio.uploadToEntry': 'Nové obrázky pôjdu do tohto záznamu',
'journey.studio.uploadToGallery': 'Nové obrázky pôjdu do galérie',
+2
View File
@@ -628,6 +628,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Det här inlägget har inga foton än.',
'journey.studio.noLoosePhotos': 'Alla foton här hör till ett inlägg.',
'journey.studio.uploadPhotos': 'Ladda upp foton',
'journey.studio.fromProvider': 'Från {name}',
'journey.studio.fromProviderHint': 'Välj foton från {name} och lägg till dem där en uppladdning hamnar',
'journey.studio.uploadHint': 'Släpp bilder här eller klicka för att välja',
'journey.studio.uploadToEntry': 'Nya bilder hamnar i det här inlägget',
'journey.studio.uploadToGallery': 'Nya bilder hamnar i galleriet',
+2
View File
@@ -628,6 +628,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': "รายการนี้ยังไม่มีรูปถ่าย",
'journey.studio.noLoosePhotos': "ทุกภาพถ่ายที่นี่เป็นของรายการ",
'journey.studio.uploadPhotos': "อัปโหลดรูปภาพ",
'journey.studio.fromProvider': 'จาก {name}',
'journey.studio.fromProviderHint': 'เลือกรูปจาก {name} แล้วเพิ่มไว้ที่เดียวกับการอัปโหลด',
'journey.studio.uploadHint': "วางรูปภาพที่นี่ หรือคลิกเพื่อเลือกบางส่วน",
'journey.studio.uploadToEntry': "รูปภาพใหม่จะเข้าสู่รายการนี้",
'journey.studio.uploadToGallery': "รูปภาพใหม่จะเข้าสู่แกลเลอรี่",
+2
View File
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Bu kaydın henüz fotoğrafı yok.',
'journey.studio.noLoosePhotos': 'Buradaki her fotoğraf bir kayda ait.',
'journey.studio.uploadPhotos': 'Fotoğraf yükle',
'journey.studio.fromProvider': '{name} üzerinden',
'journey.studio.fromProviderHint': '{name} üzerinden fotoğraf seç ve yüklemenin gideceği yere ekle',
'journey.studio.uploadHint': 'Resimleri buraya bırakın veya seçmek için tıklayın',
'journey.studio.uploadToEntry': 'Yeni resimler bu kayda eklenir',
'journey.studio.uploadToGallery': 'Yeni resimler galeriye eklenir',
+2
View File
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'У цьому записі ще немає фото.',
'journey.studio.noLoosePhotos': 'Кожне фото тут належить до якогось запису.',
'journey.studio.uploadPhotos': 'Завантажити фото',
'journey.studio.fromProvider': 'З {name}',
'journey.studio.fromProviderHint': 'Вибрати фото з {name} і додати туди, куди потрапило б завантаження',
'journey.studio.uploadHint': 'Перетягніть зображення сюди або натисніть, щоб вибрати',
'journey.studio.uploadToEntry': 'Нові зображення потраплять у цей запис',
'journey.studio.uploadToGallery': 'Нові зображення потраплять у галерею',
+2
View File
@@ -628,6 +628,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': 'Mục này chưa có ảnh.',
'journey.studio.noLoosePhotos': 'Mọi ảnh ở đây đều thuộc về một mục.',
'journey.studio.uploadPhotos': 'Tải ảnh lên',
'journey.studio.fromProvider': 'Từ {name}',
'journey.studio.fromProviderHint': 'Chọn ảnh từ {name} và thêm vào nơi ảnh tải lên sẽ vào',
'journey.studio.uploadHint': 'Thả ảnh vào đây hoặc nhấp để chọn',
'journey.studio.uploadToEntry': 'Ảnh mới sẽ được đưa vào mục này',
'journey.studio.uploadToGallery': 'Ảnh mới sẽ được đưa vào thư viện',
+2
View File
@@ -626,6 +626,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': '這個條目還沒有照片。',
'journey.studio.noLoosePhotos': '這裡的每張照片都屬於某個條目。',
'journey.studio.uploadPhotos': '上傳照片',
'journey.studio.fromProvider': '來自 {name}',
'journey.studio.fromProviderHint': '從 {name} 選擇照片,加到上傳會放入的位置',
'journey.studio.uploadHint': '將圖片拖放到這裡,或點擊選擇',
'journey.studio.uploadToEntry': '新圖片會加入這個條目',
'journey.studio.uploadToGallery': '新圖片會加入圖庫',
+2
View File
@@ -626,6 +626,8 @@ const journey: TranslationStrings = {
'journey.studio.noEntryPhotos': '这个条目还没有照片。',
'journey.studio.noLoosePhotos': '这里的每张照片都属于某个条目。',
'journey.studio.uploadPhotos': '上传照片',
'journey.studio.fromProvider': '来自 {name}',
'journey.studio.fromProviderHint': '从 {name} 选择照片,添加到上传会放入的位置',
'journey.studio.uploadHint': '将图片拖放到这里,或点击选择',
'journey.studio.uploadToEntry': '新图片会加入这个条目',
'journey.studio.uploadToGallery': '新图片会加入图库',