mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(studio): add photos from Immich and Synology like the journey gallery (#2271)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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': 'ستُضاف الصور الجديدة إلى المعرض',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Οι νέες εικόνες θα μπουν στη συλλογή',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '新しい画像はギャラリーに追加されます',
|
||||
|
||||
@@ -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': '새 이미지는 갤러리에 추가됩니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Новые изображения попадут в галерею',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': "รูปภาพใหม่จะเข้าสู่แกลเลอรี่",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Нові зображення потраплять у галерею',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '新圖片會加入圖庫',
|
||||
|
||||
@@ -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': '新图片会加入图库',
|
||||
|
||||
Reference in New Issue
Block a user