mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge pull request #739 from mauriceboe/fix/journey-bugs-roel
fix: journey bugs #722-#736 (roel-de-vries batch)
This commit is contained in:
@@ -30,13 +30,14 @@ function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'):
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entry: JourneyEntry
|
entry: JourneyEntry
|
||||||
|
readOnly?: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||||
const photos = entry.photos || []
|
const photos = entry.photos || []
|
||||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||||
@@ -57,6 +58,7 @@ export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPh
|
|||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
{!readOnly && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => { onClose(); onEdit(); }}
|
onClick={() => { onClose(); onEdit(); }}
|
||||||
@@ -72,6 +74,7 @@ export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPh
|
|||||||
<Trash2 size={15} />
|
<Trash2 size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export default function MobileMapTimeline({
|
|||||||
const carouselRef = useRef<HTMLDivElement>(null)
|
const carouselRef = useRef<HTMLDivElement>(null)
|
||||||
const [activeIndex, setActiveIndex] = useState(0)
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||||
|
const activeIndexRef = useRef(activeIndex)
|
||||||
|
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
|
||||||
|
|
||||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||||
const syncMapToCarousel = useCallback((index: number) => {
|
const syncMapToCarousel = useCallback((index: number) => {
|
||||||
@@ -53,41 +55,78 @@ export default function MobileMapTimeline({
|
|||||||
}
|
}
|
||||||
}, [entries, mapEntries])
|
}, [entries, mapEntries])
|
||||||
|
|
||||||
// IntersectionObserver for instant snap detection
|
// Pick the card that's currently closest to the carousel horizontal center.
|
||||||
|
// More stable than IntersectionObserver thresholds when the active card can
|
||||||
|
// drift toward the viewport edge with proximity snapping.
|
||||||
|
const pickNearestCard = useCallback(() => {
|
||||||
|
const el = carouselRef.current
|
||||||
|
if (!el) return
|
||||||
|
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2
|
||||||
|
let bestIdx = 0
|
||||||
|
let bestDist = Infinity
|
||||||
|
cardRefs.current.forEach((node, idx) => {
|
||||||
|
const r = node.getBoundingClientRect()
|
||||||
|
const cardCenter = r.left + r.width / 2
|
||||||
|
const d = Math.abs(cardCenter - containerCenter)
|
||||||
|
if (d < bestDist) { bestDist = d; bestIdx = idx }
|
||||||
|
})
|
||||||
|
setActiveIndex(prev => {
|
||||||
|
if (prev !== bestIdx) syncMapToCarousel(bestIdx)
|
||||||
|
return bestIdx
|
||||||
|
})
|
||||||
|
}, [syncMapToCarousel])
|
||||||
|
|
||||||
|
// Track scroll; debounce to re-center the active card when the user stops.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = carouselRef.current
|
const el = carouselRef.current
|
||||||
if (!el || entries.length === 0) return
|
if (!el || entries.length === 0) return
|
||||||
|
let rafId: number | null = null
|
||||||
|
let settleTimer: number | null = null
|
||||||
|
const onScroll = () => {
|
||||||
|
if (rafId != null) return
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
pickNearestCard()
|
||||||
|
rafId = null
|
||||||
|
})
|
||||||
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
|
settleTimer = window.setTimeout(() => {
|
||||||
|
// Ensure the active card sits at the center once the user settles.
|
||||||
|
const card = cardRefs.current.get(activeIndexRef.current)
|
||||||
|
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||||
|
}, 180)
|
||||||
|
}
|
||||||
|
el.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', onScroll)
|
||||||
|
if (rafId != null) cancelAnimationFrame(rafId)
|
||||||
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
|
}
|
||||||
|
}, [entries.length, pickNearestCard])
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
// Scroll a given card into the horizontal center of the carousel
|
||||||
(observed) => {
|
const scrollCardIntoCenter = useCallback((idx: number) => {
|
||||||
for (const o of observed) {
|
const card = cardRefs.current.get(idx)
|
||||||
if (o.isIntersecting) {
|
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||||
const idx = Number(o.target.getAttribute('data-idx'))
|
}, [])
|
||||||
if (!isNaN(idx)) {
|
|
||||||
setActiveIndex(idx)
|
|
||||||
syncMapToCarousel(idx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ root: el, threshold: 0.6 },
|
|
||||||
)
|
|
||||||
|
|
||||||
cardRefs.current.forEach(node => observer.observe(node))
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [entries.length, syncMapToCarousel])
|
|
||||||
|
|
||||||
// Scroll carousel to entry when map marker is clicked
|
// Scroll carousel to entry when map marker is clicked
|
||||||
const handleMarkerClick = useCallback((id: string) => {
|
const handleMarkerClick = useCallback((id: string) => {
|
||||||
const idx = entries.findIndex((e: any) => String(e.id) === id)
|
const idx = entries.findIndex((e: any) => String(e.id) === id)
|
||||||
if (idx === -1) return
|
if (idx === -1) return
|
||||||
setActiveIndex(idx)
|
setActiveIndex(idx)
|
||||||
|
scrollCardIntoCenter(idx)
|
||||||
|
}, [entries, scrollCardIntoCenter])
|
||||||
|
|
||||||
const el = carouselRef.current
|
// Tap on a card: if it's already active, open the edit view; otherwise
|
||||||
if (!el) return
|
// activate + center it first (don't jump straight into the editor).
|
||||||
const cardWidth = 272
|
const handleCardTap = useCallback((entry: any, idx: number) => {
|
||||||
el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' })
|
if (idx === activeIndex) {
|
||||||
}, [entries])
|
onEntryClick(entry)
|
||||||
|
} else {
|
||||||
|
setActiveIndex(idx)
|
||||||
|
scrollCardIntoCenter(idx)
|
||||||
|
}
|
||||||
|
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
|
||||||
|
|
||||||
// Initial map focus — delay to let Leaflet initialize and fitBounds
|
// Initial map focus — delay to let Leaflet initialize and fitBounds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,12 +154,12 @@ export default function MobileMapTimeline({
|
|||||||
fullScreen
|
fullScreen
|
||||||
/>
|
/>
|
||||||
{!readOnly && onAddEntry && (
|
{!readOnly && onAddEntry && (
|
||||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={onAddEntry}
|
onClick={onAddEntry}
|
||||||
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||||
>
|
>
|
||||||
<Plus size={18} />
|
<Plus size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -146,14 +185,14 @@ export default function MobileMapTimeline({
|
|||||||
|
|
||||||
{/* Bottom carousel */}
|
{/* Bottom carousel */}
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-20 left-0 right-0 z-40"
|
className="fixed left-0 right-0 z-40"
|
||||||
style={{ touchAction: 'pan-x' }}
|
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={carouselRef}
|
ref={carouselRef}
|
||||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
||||||
style={{
|
style={{
|
||||||
scrollSnapType: 'x mandatory',
|
scrollSnapType: 'x proximity',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
scrollbarWidth: 'none',
|
scrollbarWidth: 'none',
|
||||||
msOverflowStyle: 'none',
|
msOverflowStyle: 'none',
|
||||||
@@ -170,7 +209,7 @@ export default function MobileMapTimeline({
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
index={i}
|
index={i}
|
||||||
isActive={i === activeIndex}
|
isActive={i === activeIndex}
|
||||||
onClick={() => onEntryClick(entry)}
|
onClick={() => handleCardTap(entry, i)}
|
||||||
publicPhotoUrl={publicPhotoUrl}
|
publicPhotoUrl={publicPhotoUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,14 +217,17 @@ export default function MobileMapTimeline({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAB: add entry — top right */}
|
{/* FAB: add entry — bottom right, above the timeline carousel */}
|
||||||
{!readOnly && onAddEntry && (
|
{!readOnly && onAddEntry && (
|
||||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
<div
|
||||||
|
className="fixed right-4 z-30"
|
||||||
|
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onAddEntry}
|
onClick={onAddEntry}
|
||||||
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||||
>
|
>
|
||||||
<Plus size={18} />
|
<Plus size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1577,6 +1577,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'كلمة المرور',
|
'memories.providerPassword': 'كلمة المرور',
|
||||||
'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
|
'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
|
||||||
'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
|
'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
|
||||||
|
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
|
||||||
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
||||||
'memories.testConnection': 'اختبار الاتصال',
|
'memories.testConnection': 'اختبار الاتصال',
|
||||||
'memories.testFirst': 'اختبر الاتصال أولاً',
|
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||||
@@ -1655,6 +1656,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.invite.inviting': 'جارٍ الدعوة...',
|
'journey.invite.inviting': 'جارٍ الدعوة...',
|
||||||
|
|
||||||
// Journey Entry Editor
|
// Journey Entry Editor
|
||||||
|
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
||||||
'journey.editor.uploadPhotos': 'رفع صور',
|
'journey.editor.uploadPhotos': 'رفع صور',
|
||||||
'journey.editor.uploading': '...جارٍ الرفع',
|
'journey.editor.uploading': '...جارٍ الرفع',
|
||||||
'journey.editor.fromGallery': 'من المعرض',
|
'journey.editor.fromGallery': 'من المعرض',
|
||||||
|
|||||||
@@ -1616,6 +1616,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'Senha',
|
'memories.providerPassword': 'Senha',
|
||||||
'memories.providerOTP': 'Código MFA (se habilitado)',
|
'memories.providerOTP': 'Código MFA (se habilitado)',
|
||||||
'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
|
'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
|
||||||
|
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
|
||||||
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Testar conexão',
|
'memories.testConnection': 'Testar conexão',
|
||||||
'memories.testFirst': 'Teste a conexão primeiro',
|
'memories.testFirst': 'Teste a conexão primeiro',
|
||||||
@@ -2025,6 +2026,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Poderia ser melhor',
|
'journey.verdict.couldBeBetter': 'Poderia ser melhor',
|
||||||
'journey.synced.places': 'lugares',
|
'journey.synced.places': 'lugares',
|
||||||
'journey.synced.synced': 'sincronizado',
|
'journey.synced.synced': 'sincronizado',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
|
||||||
'journey.editor.uploadPhotos': 'Enviar fotos',
|
'journey.editor.uploadPhotos': 'Enviar fotos',
|
||||||
'journey.editor.uploading': 'Enviando...',
|
'journey.editor.uploading': 'Enviando...',
|
||||||
'journey.editor.fromGallery': 'Da galeria',
|
'journey.editor.fromGallery': 'Da galeria',
|
||||||
|
|||||||
@@ -1575,6 +1575,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'Heslo',
|
'memories.providerPassword': 'Heslo',
|
||||||
'memories.providerOTP': 'MFA kód (pokud je povoleno)',
|
'memories.providerOTP': 'MFA kód (pokud je povoleno)',
|
||||||
'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu',
|
'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu',
|
||||||
|
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
|
||||||
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Otestovat připojení',
|
'memories.testConnection': 'Otestovat připojení',
|
||||||
'memories.testFirst': 'Nejprve otestujte připojení',
|
'memories.testFirst': 'Nejprve otestujte připojení',
|
||||||
@@ -2030,6 +2031,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
|
'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
|
||||||
'journey.synced.places': 'místa',
|
'journey.synced.places': 'místa',
|
||||||
'journey.synced.synced': 'synchronizováno',
|
'journey.synced.synced': 'synchronizováno',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
|
||||||
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
||||||
'journey.editor.uploading': 'Nahrávání...',
|
'journey.editor.uploading': 'Nahrávání...',
|
||||||
'journey.editor.fromGallery': 'Z galerie',
|
'journey.editor.fromGallery': 'Z galerie',
|
||||||
|
|||||||
@@ -1579,6 +1579,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'Passwort',
|
'memories.providerPassword': 'Passwort',
|
||||||
'memories.providerOTP': 'MFA-Code (falls aktiviert)',
|
'memories.providerOTP': 'MFA-Code (falls aktiviert)',
|
||||||
'memories.skipSSLVerification': 'SSL-Zertifikatsprüfung überspringen',
|
'memories.skipSSLVerification': 'SSL-Zertifikatsprüfung überspringen',
|
||||||
|
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
|
||||||
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Verbindung testen',
|
'memories.testConnection': 'Verbindung testen',
|
||||||
'memories.testFirst': 'Verbindung zuerst testen',
|
'memories.testFirst': 'Verbindung zuerst testen',
|
||||||
@@ -2033,6 +2034,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
|
'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
|
||||||
'journey.synced.places': 'Orte',
|
'journey.synced.places': 'Orte',
|
||||||
'journey.synced.synced': 'synchronisiert',
|
'journey.synced.synced': 'synchronisiert',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
|
||||||
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
||||||
'journey.editor.uploading': 'Hochladen...',
|
'journey.editor.uploading': 'Hochladen...',
|
||||||
'journey.editor.fromGallery': 'Aus Galerie',
|
'journey.editor.fromGallery': 'Aus Galerie',
|
||||||
@@ -2083,6 +2085,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.contributors.role': 'Rolle',
|
'journey.contributors.role': 'Rolle',
|
||||||
'journey.contributors.added': 'Mitwirkender hinzugefügt',
|
'journey.contributors.added': 'Mitwirkender hinzugefügt',
|
||||||
'journey.contributors.addFailed': 'Hinzufügen fehlgeschlagen',
|
'journey.contributors.addFailed': 'Hinzufügen fehlgeschlagen',
|
||||||
|
'journey.contributors.remove': 'Mitwirkenden entfernen',
|
||||||
|
'journey.contributors.removeConfirm': '{username} aus dieser Journey entfernen?',
|
||||||
|
'journey.contributors.removed': 'Mitwirkender entfernt',
|
||||||
|
'journey.contributors.removeFailed': 'Entfernen fehlgeschlagen',
|
||||||
'journey.share.publicShare': 'Öffentlicher Link',
|
'journey.share.publicShare': 'Öffentlicher Link',
|
||||||
'journey.share.createLink': 'Link erstellen',
|
'journey.share.createLink': 'Link erstellen',
|
||||||
'journey.share.linkCreated': 'Link erstellt',
|
'journey.share.linkCreated': 'Link erstellt',
|
||||||
|
|||||||
@@ -1638,6 +1638,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'Password',
|
'memories.providerPassword': 'Password',
|
||||||
'memories.providerOTP': 'MFA code (if enabled)',
|
'memories.providerOTP': 'MFA code (if enabled)',
|
||||||
'memories.skipSSLVerification': 'Skip SSL certificate verification',
|
'memories.skipSSLVerification': 'Skip SSL certificate verification',
|
||||||
|
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
|
||||||
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test connection',
|
'memories.testConnection': 'Test connection',
|
||||||
'memories.testFirst': 'Test connection first',
|
'memories.testFirst': 'Test connection first',
|
||||||
@@ -2045,6 +2046,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.synced': 'synced',
|
'journey.synced.synced': 'synced',
|
||||||
|
|
||||||
// Journey Entry Editor
|
// Journey Entry Editor
|
||||||
|
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
|
||||||
'journey.editor.uploadPhotos': 'Upload photos',
|
'journey.editor.uploadPhotos': 'Upload photos',
|
||||||
'journey.editor.uploading': 'Uploading...',
|
'journey.editor.uploading': 'Uploading...',
|
||||||
'journey.editor.fromGallery': 'From Gallery',
|
'journey.editor.fromGallery': 'From Gallery',
|
||||||
@@ -2103,6 +2105,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.contributors.role': 'Role',
|
'journey.contributors.role': 'Role',
|
||||||
'journey.contributors.added': 'Contributor added',
|
'journey.contributors.added': 'Contributor added',
|
||||||
'journey.contributors.addFailed': 'Failed to add contributor',
|
'journey.contributors.addFailed': 'Failed to add contributor',
|
||||||
|
'journey.contributors.remove': 'Remove contributor',
|
||||||
|
'journey.contributors.removeConfirm': 'Remove {username} from this journey?',
|
||||||
|
'journey.contributors.removed': 'Contributor removed',
|
||||||
|
'journey.contributors.removeFailed': 'Failed to remove contributor',
|
||||||
|
|
||||||
// Journey — Share
|
// Journey — Share
|
||||||
'journey.share.publicShare': 'Public Share',
|
'journey.share.publicShare': 'Public Share',
|
||||||
|
|||||||
@@ -1516,6 +1516,7 @@ const es: Record<string, string> = {
|
|||||||
'memories.providerPassword': 'Contraseña',
|
'memories.providerPassword': 'Contraseña',
|
||||||
'memories.providerOTP': 'Código MFA (si está habilitado)',
|
'memories.providerOTP': 'Código MFA (si está habilitado)',
|
||||||
'memories.skipSSLVerification': 'Omitir verificación del certificado SSL',
|
'memories.skipSSLVerification': 'Omitir verificación del certificado SSL',
|
||||||
|
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
|
||||||
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Probar conexión',
|
'memories.testConnection': 'Probar conexión',
|
||||||
'memories.testFirst': 'Probar conexión primero',
|
'memories.testFirst': 'Probar conexión primero',
|
||||||
@@ -2032,6 +2033,7 @@ const es: Record<string, string> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Podría mejorar',
|
'journey.verdict.couldBeBetter': 'Podría mejorar',
|
||||||
'journey.synced.places': 'lugares',
|
'journey.synced.places': 'lugares',
|
||||||
'journey.synced.synced': 'sincronizado',
|
'journey.synced.synced': 'sincronizado',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
|
||||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||||
'journey.editor.uploading': 'Subiendo...',
|
'journey.editor.uploading': 'Subiendo...',
|
||||||
'journey.editor.fromGallery': 'Desde galería',
|
'journey.editor.fromGallery': 'Desde galería',
|
||||||
|
|||||||
@@ -1573,6 +1573,7 @@ const fr: Record<string, string> = {
|
|||||||
'memories.providerPassword': 'Mot de passe',
|
'memories.providerPassword': 'Mot de passe',
|
||||||
'memories.providerOTP': 'Code MFA (si activé)',
|
'memories.providerOTP': 'Code MFA (si activé)',
|
||||||
'memories.skipSSLVerification': 'Ignorer la vérification du certificat SSL',
|
'memories.skipSSLVerification': 'Ignorer la vérification du certificat SSL',
|
||||||
|
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
|
||||||
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Tester la connexion',
|
'memories.testConnection': 'Tester la connexion',
|
||||||
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
||||||
@@ -2026,6 +2027,7 @@ const fr: Record<string, string> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Pourrait être mieux',
|
'journey.verdict.couldBeBetter': 'Pourrait être mieux',
|
||||||
'journey.synced.places': 'lieux',
|
'journey.synced.places': 'lieux',
|
||||||
'journey.synced.synced': 'synchronisé',
|
'journey.synced.synced': 'synchronisé',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?',
|
||||||
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
||||||
'journey.editor.uploading': 'Envoi...',
|
'journey.editor.uploading': 'Envoi...',
|
||||||
'journey.editor.fromGallery': 'Depuis la galerie',
|
'journey.editor.fromGallery': 'Depuis la galerie',
|
||||||
|
|||||||
@@ -1644,6 +1644,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'Jelszó',
|
'memories.providerPassword': 'Jelszó',
|
||||||
'memories.providerOTP': 'MFA kód (ha engedélyezve van)',
|
'memories.providerOTP': 'MFA kód (ha engedélyezve van)',
|
||||||
'memories.skipSSLVerification': 'SSL tanúsítvány ellenőrzésének kihagyása',
|
'memories.skipSSLVerification': 'SSL tanúsítvány ellenőrzésének kihagyása',
|
||||||
|
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
|
||||||
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Kapcsolat tesztelése',
|
'memories.testConnection': 'Kapcsolat tesztelése',
|
||||||
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
||||||
@@ -2027,6 +2028,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Lehetne jobb',
|
'journey.verdict.couldBeBetter': 'Lehetne jobb',
|
||||||
'journey.synced.places': 'helyszín',
|
'journey.synced.places': 'helyszín',
|
||||||
'journey.synced.synced': 'szinkronizálva',
|
'journey.synced.synced': 'szinkronizálva',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?',
|
||||||
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
||||||
'journey.editor.uploading': 'Feltöltés...',
|
'journey.editor.uploading': 'Feltöltés...',
|
||||||
'journey.editor.fromGallery': 'Galériából',
|
'journey.editor.fromGallery': 'Galériából',
|
||||||
|
|||||||
@@ -1636,6 +1636,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'Kata sandi',
|
'memories.providerPassword': 'Kata sandi',
|
||||||
'memories.providerOTP': 'Kode MFA (jika diaktifkan)',
|
'memories.providerOTP': 'Kode MFA (jika diaktifkan)',
|
||||||
'memories.skipSSLVerification': 'Lewati verifikasi sertifikat SSL',
|
'memories.skipSSLVerification': 'Lewati verifikasi sertifikat SSL',
|
||||||
|
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
|
||||||
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Uji koneksi',
|
'memories.testConnection': 'Uji koneksi',
|
||||||
'memories.testFirst': 'Uji koneksi terlebih dahulu',
|
'memories.testFirst': 'Uji koneksi terlebih dahulu',
|
||||||
@@ -2042,6 +2043,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.synced': 'tersinkron',
|
'journey.synced.synced': 'tersinkron',
|
||||||
|
|
||||||
// Journey Entry Editor
|
// Journey Entry Editor
|
||||||
|
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
|
||||||
'journey.editor.uploadPhotos': 'Unggah foto',
|
'journey.editor.uploadPhotos': 'Unggah foto',
|
||||||
'journey.editor.uploading': 'Mengunggah...',
|
'journey.editor.uploading': 'Mengunggah...',
|
||||||
'journey.editor.fromGallery': 'Dari Galeri',
|
'journey.editor.fromGallery': 'Dari Galeri',
|
||||||
|
|||||||
@@ -1574,6 +1574,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'Password',
|
'memories.providerPassword': 'Password',
|
||||||
'memories.providerOTP': 'Codice MFA (se abilitato)',
|
'memories.providerOTP': 'Codice MFA (se abilitato)',
|
||||||
'memories.skipSSLVerification': 'Ignora la verifica del certificato SSL',
|
'memories.skipSSLVerification': 'Ignora la verifica del certificato SSL',
|
||||||
|
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
|
||||||
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test connessione',
|
'memories.testConnection': 'Test connessione',
|
||||||
'memories.testFirst': 'Testa prima la connessione',
|
'memories.testFirst': 'Testa prima la connessione',
|
||||||
@@ -2027,6 +2028,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Potrebbe essere meglio',
|
'journey.verdict.couldBeBetter': 'Potrebbe essere meglio',
|
||||||
'journey.synced.places': 'luoghi',
|
'journey.synced.places': 'luoghi',
|
||||||
'journey.synced.synced': 'sincronizzato',
|
'journey.synced.synced': 'sincronizzato',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
|
||||||
'journey.editor.uploadPhotos': 'Carica foto',
|
'journey.editor.uploadPhotos': 'Carica foto',
|
||||||
'journey.editor.uploading': 'Caricamento...',
|
'journey.editor.uploading': 'Caricamento...',
|
||||||
'journey.editor.fromGallery': 'Dalla galleria',
|
'journey.editor.fromGallery': 'Dalla galleria',
|
||||||
|
|||||||
@@ -1573,6 +1573,7 @@ const nl: Record<string, string> = {
|
|||||||
'memories.providerPassword': 'Wachtwoord',
|
'memories.providerPassword': 'Wachtwoord',
|
||||||
'memories.providerOTP': 'MFA-code (indien ingeschakeld)',
|
'memories.providerOTP': 'MFA-code (indien ingeschakeld)',
|
||||||
'memories.skipSSLVerification': 'SSL-certificaatverificatie overslaan',
|
'memories.skipSSLVerification': 'SSL-certificaatverificatie overslaan',
|
||||||
|
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
|
||||||
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Verbinding testen',
|
'memories.testConnection': 'Verbinding testen',
|
||||||
'memories.testFirst': 'Test eerst de verbinding',
|
'memories.testFirst': 'Test eerst de verbinding',
|
||||||
@@ -2026,6 +2027,7 @@ const nl: Record<string, string> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Kan beter',
|
'journey.verdict.couldBeBetter': 'Kan beter',
|
||||||
'journey.synced.places': 'plaatsen',
|
'journey.synced.places': 'plaatsen',
|
||||||
'journey.synced.synced': 'gesynchroniseerd',
|
'journey.synced.synced': 'gesynchroniseerd',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
|
||||||
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
||||||
'journey.editor.uploading': 'Uploaden...',
|
'journey.editor.uploading': 'Uploaden...',
|
||||||
'journey.editor.fromGallery': 'Uit galerij',
|
'journey.editor.fromGallery': 'Uit galerij',
|
||||||
|
|||||||
@@ -1525,6 +1525,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'Hasło',
|
'memories.providerPassword': 'Hasło',
|
||||||
'memories.providerOTP': 'Kod MFA (jeśli włączony)',
|
'memories.providerOTP': 'Kod MFA (jeśli włączony)',
|
||||||
'memories.skipSSLVerification': 'Pomiń weryfikację certyfikatu SSL',
|
'memories.skipSSLVerification': 'Pomiń weryfikację certyfikatu SSL',
|
||||||
|
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
|
||||||
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test',
|
'memories.testConnection': 'Test',
|
||||||
'memories.connected': 'Połączono',
|
'memories.connected': 'Połączono',
|
||||||
@@ -2019,6 +2020,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Mogłoby być lepiej',
|
'journey.verdict.couldBeBetter': 'Mogłoby być lepiej',
|
||||||
'journey.synced.places': 'miejsca',
|
'journey.synced.places': 'miejsca',
|
||||||
'journey.synced.synced': 'zsynchronizowane',
|
'journey.synced.synced': 'zsynchronizowane',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?',
|
||||||
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
||||||
'journey.editor.uploading': 'Przesyłanie...',
|
'journey.editor.uploading': 'Przesyłanie...',
|
||||||
'journey.editor.fromGallery': 'Z galerii',
|
'journey.editor.fromGallery': 'Z galerii',
|
||||||
|
|||||||
@@ -1573,6 +1573,7 @@ const ru: Record<string, string> = {
|
|||||||
'memories.providerPassword': 'Пароль',
|
'memories.providerPassword': 'Пароль',
|
||||||
'memories.providerOTP': 'Код MFA (если включён)',
|
'memories.providerOTP': 'Код MFA (если включён)',
|
||||||
'memories.skipSSLVerification': 'Пропустить проверку SSL-сертификата',
|
'memories.skipSSLVerification': 'Пропустить проверку SSL-сертификата',
|
||||||
|
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
|
||||||
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Проверить подключение',
|
'memories.testConnection': 'Проверить подключение',
|
||||||
'memories.testFirst': 'Сначала проверьте подключение',
|
'memories.testFirst': 'Сначала проверьте подключение',
|
||||||
@@ -2026,6 +2027,7 @@ const ru: Record<string, string> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Могло быть лучше',
|
'journey.verdict.couldBeBetter': 'Могло быть лучше',
|
||||||
'journey.synced.places': 'мест',
|
'journey.synced.places': 'мест',
|
||||||
'journey.synced.synced': 'синхронизировано',
|
'journey.synced.synced': 'синхронизировано',
|
||||||
|
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
|
||||||
'journey.editor.uploadPhotos': 'Загрузить фото',
|
'journey.editor.uploadPhotos': 'Загрузить фото',
|
||||||
'journey.editor.uploading': 'Загрузка...',
|
'journey.editor.uploading': 'Загрузка...',
|
||||||
'journey.editor.fromGallery': 'Из галереи',
|
'journey.editor.fromGallery': 'Из галереи',
|
||||||
|
|||||||
@@ -1573,6 +1573,7 @@ const zh: Record<string, string> = {
|
|||||||
'memories.providerPassword': '密码',
|
'memories.providerPassword': '密码',
|
||||||
'memories.providerOTP': 'MFA 验证码(如已启用)',
|
'memories.providerOTP': 'MFA 验证码(如已启用)',
|
||||||
'memories.skipSSLVerification': '跳过 SSL 证书验证',
|
'memories.skipSSLVerification': '跳过 SSL 证书验证',
|
||||||
|
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
|
||||||
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
|
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
|
||||||
'memories.testConnection': '测试连接',
|
'memories.testConnection': '测试连接',
|
||||||
'memories.testFirst': '请先测试连接',
|
'memories.testFirst': '请先测试连接',
|
||||||
@@ -2026,6 +2027,7 @@ const zh: Record<string, string> = {
|
|||||||
'journey.verdict.couldBeBetter': '有待改进',
|
'journey.verdict.couldBeBetter': '有待改进',
|
||||||
'journey.synced.places': '个地点',
|
'journey.synced.places': '个地点',
|
||||||
'journey.synced.synced': '已同步',
|
'journey.synced.synced': '已同步',
|
||||||
|
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
|
||||||
'journey.editor.uploadPhotos': '上传照片',
|
'journey.editor.uploadPhotos': '上传照片',
|
||||||
'journey.editor.uploading': '上传中...',
|
'journey.editor.uploading': '上传中...',
|
||||||
'journey.editor.fromGallery': '从相册',
|
'journey.editor.fromGallery': '从相册',
|
||||||
|
|||||||
@@ -1633,6 +1633,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'memories.providerPassword': '密碼',
|
'memories.providerPassword': '密碼',
|
||||||
'memories.providerOTP': 'MFA 驗證碼(如已啟用)',
|
'memories.providerOTP': 'MFA 驗證碼(如已啟用)',
|
||||||
'memories.skipSSLVerification': '跳過 SSL 憑證驗證',
|
'memories.skipSSLVerification': '跳過 SSL 憑證驗證',
|
||||||
|
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
|
||||||
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
|
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
|
||||||
'memories.testConnection': '測試連線',
|
'memories.testConnection': '測試連線',
|
||||||
'memories.testFirst': '請先測試連線',
|
'memories.testFirst': '請先測試連線',
|
||||||
@@ -1986,6 +1987,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'journey.verdict.couldBeBetter': '有待改進',
|
'journey.verdict.couldBeBetter': '有待改進',
|
||||||
'journey.synced.places': '個地點',
|
'journey.synced.places': '個地點',
|
||||||
'journey.synced.synced': '已同步',
|
'journey.synced.synced': '已同步',
|
||||||
|
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
|
||||||
'journey.editor.uploadPhotos': '上傳照片',
|
'journey.editor.uploadPhotos': '上傳照片',
|
||||||
'journey.editor.uploading': '上傳中...',
|
'journey.editor.uploading': '上傳中...',
|
||||||
'journey.editor.fromGallery': '從相簿',
|
'journey.editor.fromGallery': '從相簿',
|
||||||
|
|||||||
@@ -3579,8 +3579,8 @@ describe('JourneyDetailPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── FE-PAGE-JOURNEYDETAIL-148 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-148 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-148: EntryEditor file upload for existing entry calls API directly', () => {
|
describe('FE-PAGE-JOURNEYDETAIL-148: EntryEditor queues file uploads until save (#727)', () => {
|
||||||
it('uploading a file on an existing entry calls the upload API immediately', async () => {
|
it('uploading a file on an existing entry stays pending until Save is clicked', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
let uploadCalled = false;
|
let uploadCalled = false;
|
||||||
|
|
||||||
@@ -3618,7 +3618,11 @@ describe('JourneyDetailPage', () => {
|
|||||||
const testFile = new File(['data'], 'upload.jpg', { type: 'image/jpeg' });
|
const testFile = new File(['data'], 'upload.jpg', { type: 'image/jpeg' });
|
||||||
await user.upload(fileInput, testFile);
|
await user.upload(fileInput, testFile);
|
||||||
|
|
||||||
// For existing entries, upload happens immediately
|
// Picked file is queued locally — upload should NOT fire until Save.
|
||||||
|
expect(uploadCalled).toBe(false);
|
||||||
|
|
||||||
|
// Saving triggers the queued upload.
|
||||||
|
await user.click(screen.getByText('Save'));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(uploadCalled).toBe(true);
|
expect(uploadCalled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
|
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
|
||||||
Laugh, Smile, Meh, Annoyed, Frown,
|
Laugh, Smile, Meh, Annoyed, Frown,
|
||||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
|
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
|
||||||
|
Archive, ArchiveRestore,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||||
@@ -89,6 +90,12 @@ export default function JourneyDetailPage() {
|
|||||||
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
||||||
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
// Role-based permissions (server-provided via my_role). Fall back to
|
||||||
|
// "owner" when the field isn't present yet (legacy responses) so behavior
|
||||||
|
// matches the pre-permissions era.
|
||||||
|
const myRole = (current as any)?.my_role ?? 'owner'
|
||||||
|
const canEditEntries = myRole === 'owner' || myRole === 'editor'
|
||||||
|
const canEditJourney = myRole === 'owner'
|
||||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||||
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
|
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
|
||||||
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
|
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
|
||||||
@@ -234,11 +241,12 @@ export default function JourneyDetailPage() {
|
|||||||
entries={timelineEntries}
|
entries={timelineEntries}
|
||||||
mapEntries={sidebarMapItems}
|
mapEntries={sidebarMapItems}
|
||||||
dark={document.documentElement.classList.contains('dark')}
|
dark={document.documentElement.classList.contains('dark')}
|
||||||
|
readOnly={!canEditEntries}
|
||||||
onEntryClick={(entry) => setViewingEntry(entry)}
|
onEntryClick={(entry) => setViewingEntry(entry)}
|
||||||
onAddEntry={() => {
|
onAddEntry={canEditEntries ? () => {
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
|
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
|
||||||
}}
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -246,6 +254,7 @@ export default function JourneyDetailPage() {
|
|||||||
{viewingEntry && (
|
{viewingEntry && (
|
||||||
<MobileEntryView
|
<MobileEntryView
|
||||||
entry={viewingEntry}
|
entry={viewingEntry}
|
||||||
|
readOnly={!canEditEntries}
|
||||||
onClose={() => setViewingEntry(null)}
|
onClose={() => setViewingEntry(null)}
|
||||||
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
|
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
|
||||||
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
|
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
|
||||||
@@ -253,9 +262,21 @@ export default function JourneyDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Floating tab toggle on mobile combined view */}
|
{/* Floating top bar on mobile combined view: back | tabs+title | settings */}
|
||||||
{showMobileCombined && (
|
{showMobileCombined && (
|
||||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] left-4 z-30">
|
<div
|
||||||
|
className="fixed left-0 right-0 z-30 flex items-start justify-between gap-2 px-4"
|
||||||
|
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/journey')}
|
||||||
|
aria-label={t('journey.detail.backToJourney')}
|
||||||
|
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col items-center gap-1">
|
||||||
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('timeline')}
|
onClick={() => setView('timeline')}
|
||||||
@@ -272,6 +293,24 @@ export default function JourneyDetailPage() {
|
|||||||
{t('journey.share.gallery')}
|
{t('journey.share.gallery')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{current?.title && (
|
||||||
|
<div className="max-w-full truncate text-center text-[11px] font-medium text-zinc-700 dark:text-zinc-200 px-2.5 py-0.5 rounded-full bg-white/80 dark:bg-zinc-800/80 backdrop-blur-md border border-zinc-200/60 dark:border-zinc-700/60 shadow-sm">
|
||||||
|
{current.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEditJourney ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
aria-label={t('journey.settings.title')}
|
||||||
|
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 flex-shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -345,7 +384,9 @@ export default function JourneyDetailPage() {
|
|||||||
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{canEditJourney && (
|
||||||
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
|
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,7 +446,7 @@ export default function JourneyDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{(!isMobile ? view === 'timeline' : view !== 'gallery') && (
|
{canEditEntries && (!isMobile ? view === 'timeline' : view !== 'gallery') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
@@ -455,12 +496,13 @@ export default function JourneyDetailPage() {
|
|||||||
{entries.map(entry => (
|
{entries.map(entry => (
|
||||||
<div key={entry.id} data-entry-id={String(entry.id)}>
|
<div key={entry.id} data-entry-id={String(entry.id)}>
|
||||||
{entry.type === 'skeleton' ? (
|
{entry.type === 'skeleton' ? (
|
||||||
<SkeletonCard entry={entry} onClick={() => setEditingEntry(entry)} />
|
<SkeletonCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
|
||||||
) : entry.type === 'checkin' ? (
|
) : entry.type === 'checkin' ? (
|
||||||
<CheckinCard entry={entry} onClick={() => setEditingEntry(entry)} />
|
<CheckinCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
|
||||||
) : (
|
) : (
|
||||||
<EntryCard
|
<EntryCard
|
||||||
entry={entry}
|
entry={entry}
|
||||||
|
readOnly={!canEditEntries}
|
||||||
onEdit={() => setEditingEntry(entry)}
|
onEdit={() => setEditingEntry(entry)}
|
||||||
onDelete={() => setDeleteTarget(entry)}
|
onDelete={() => setDeleteTarget(entry)}
|
||||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||||
@@ -911,12 +953,14 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
setGalleryUploading(true)
|
setGalleryUploading(true)
|
||||||
try {
|
try {
|
||||||
// find existing "Gallery" entry or create one
|
// find existing "Gallery" entry or create one. The stored title is the
|
||||||
|
// literal 'Gallery' (server-side checks look for this exact string) —
|
||||||
|
// do not send a translated label here.
|
||||||
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
|
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
|
||||||
let entryId = galleryEntry?.id
|
let entryId = galleryEntry?.id
|
||||||
if (!entryId) {
|
if (!entryId) {
|
||||||
const entry = await journeyApi.createEntry(journeyId, {
|
const entry = await journeyApi.createEntry(journeyId, {
|
||||||
title: t('journey.share.gallery'),
|
title: 'Gallery',
|
||||||
entry_date: new Date().toISOString().split('T')[0],
|
entry_date: new Date().toISOString().split('T')[0],
|
||||||
type: 'entry',
|
type: 'entry',
|
||||||
})
|
})
|
||||||
@@ -1057,7 +1101,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
try {
|
try {
|
||||||
const entry = await journeyApi.createEntry(journeyId, {
|
const entry = await journeyApi.createEntry(journeyId, {
|
||||||
title: t('journey.share.gallery'),
|
title: 'Gallery',
|
||||||
entry_date: new Date().toISOString().split('T')[0],
|
entry_date: new Date().toISOString().split('T')[0],
|
||||||
type: 'entry',
|
type: 'entry',
|
||||||
})
|
})
|
||||||
@@ -1233,8 +1277,9 @@ function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
|
|||||||
|
|
||||||
// ── Entry Card ────────────────────────────────────────────────────────────
|
// ── Entry Card ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||||
entry: JourneyEntry
|
entry: JourneyEntry
|
||||||
|
readOnly?: boolean
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||||
@@ -1277,6 +1322,7 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu top-right */}
|
{/* Menu top-right */}
|
||||||
|
{!readOnly && (
|
||||||
<div className="absolute top-2.5 right-3 z-[2]">
|
<div className="absolute top-2.5 right-3 z-[2]">
|
||||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
|
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
|
||||||
<MoreHorizontal size={14} />
|
<MoreHorizontal size={14} />
|
||||||
@@ -1291,6 +1337,7 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Title on photo */}
|
{/* Title on photo */}
|
||||||
{entry.title && (
|
{entry.title && (
|
||||||
@@ -1314,6 +1361,7 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||||
<MoreHorizontal size={14} />
|
<MoreHorizontal size={14} />
|
||||||
@@ -1328,6 +1376,7 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1366,12 +1415,12 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) {
|
function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-[border-color,border-style] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer"
|
className={`bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-[border-color,border-style] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 flex-shrink-0">
|
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 flex-shrink-0">
|
||||||
<MapPin size={14} />
|
<MapPin size={14} />
|
||||||
@@ -1391,11 +1440,11 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) {
|
function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer"
|
className={`bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0">
|
<div className="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0">
|
||||||
<MapPin size={13} />
|
<MapPin size={13} />
|
||||||
@@ -2082,6 +2131,31 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const storyRef = useRef<HTMLTextAreaElement>(null)
|
const storyRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
// Track which fields differ from the entry we started editing so we can
|
||||||
|
// warn before discarding on close/cancel.
|
||||||
|
const originalPros = (entry.pros_cons?.pros ?? []).join('\n')
|
||||||
|
const originalCons = (entry.pros_cons?.cons ?? []).join('\n')
|
||||||
|
const isDirty = (
|
||||||
|
title !== (entry.title || '') ||
|
||||||
|
story !== (entry.story || '') ||
|
||||||
|
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
|
||||||
|
entryTime !== (entry.entry_time || '') ||
|
||||||
|
locationName !== (entry.location_name || '') ||
|
||||||
|
(locationLat ?? null) !== (entry.location_lat ?? null) ||
|
||||||
|
(locationLng ?? null) !== (entry.location_lng ?? null) ||
|
||||||
|
mood !== (entry.mood || '') ||
|
||||||
|
weather !== (entry.weather || '') ||
|
||||||
|
pros.filter(p => p.trim()).join('\n') !== originalPros ||
|
||||||
|
cons.filter(c => c.trim()).join('\n') !== originalCons ||
|
||||||
|
pendingFiles.length > 0 ||
|
||||||
|
pendingLinkIds.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -2096,7 +2170,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
mood: mood || null,
|
mood: mood || null,
|
||||||
weather: weather || null,
|
weather: weather || null,
|
||||||
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
|
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
|
||||||
type: (entry.type === 'skeleton' && story.trim()) ? 'entry' : undefined,
|
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)) ? 'entry' : undefined),
|
||||||
})
|
})
|
||||||
// upload queued files after entry is created
|
// upload queued files after entry is created
|
||||||
if (pendingFiles.length > 0 && entryId) {
|
if (pendingFiles.length > 0 && entryId) {
|
||||||
@@ -2119,20 +2193,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
if (entry.id === 0) {
|
// Queue files locally until Save so cancel/close actually discards. This
|
||||||
// queue files for upload after save
|
// keeps photo behavior consistent with text fields — no silent persistence.
|
||||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
||||||
} else {
|
|
||||||
setUploading(true)
|
|
||||||
try {
|
|
||||||
const formData = new FormData()
|
|
||||||
for (const f of files) formData.append('photos', f)
|
|
||||||
const newPhotos = await onUploadPhotos(entry.id, formData)
|
|
||||||
if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos])
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2142,7 +2205,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
|
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
|
||||||
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2474,7 +2537,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
|
||||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
<button onClick={handleClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||||
<button onClick={handleSave} disabled={saving} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50">
|
<button onClick={handleSave} disabled={saving} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50">
|
||||||
{saving ? t('common.saving') : t('common.save')}
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -2893,7 +2956,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }} onClick={e => e.stopPropagation()}>
|
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
||||||
@@ -2986,6 +3049,25 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-[12px] font-medium text-zinc-900 dark:text-white">{c.username}</div>
|
<div className="flex-1 text-[12px] font-medium text-zinc-900 dark:text-white">{c.username}</div>
|
||||||
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{c.role}</span>
|
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{c.role}</span>
|
||||||
|
{c.role !== 'owner' && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return
|
||||||
|
try {
|
||||||
|
await journeyApi.removeContributor(journey.id, c.user_id)
|
||||||
|
toast.success(t('journey.contributors.removed'))
|
||||||
|
onSaved()
|
||||||
|
} catch {
|
||||||
|
toast.error(t('journey.contributors.removeFailed'))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={t('journey.contributors.remove')}
|
||||||
|
title={t('journey.contributors.remove')}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
@@ -3005,24 +3087,28 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex flex-wrap items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
<div className="flex items-center gap-1.5 px-4 md:px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2"
|
aria-label={t('journey.settings.delete')}
|
||||||
|
title={t('journey.settings.delete')}
|
||||||
|
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
|
||||||
>
|
>
|
||||||
<Trash2 size={13} />
|
<Trash2 size={14} />
|
||||||
{t('journey.settings.delete')}
|
<span className="hidden md:inline">{t('journey.settings.delete')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleArchiveToggle}
|
onClick={handleArchiveToggle}
|
||||||
disabled={archiving}
|
disabled={archiving}
|
||||||
className="flex items-center gap-1.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg px-2.5 py-2 mr-auto disabled:opacity-40"
|
aria-label={journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
|
||||||
title={t('journey.settings.endDescription')}
|
title={t('journey.settings.endDescription')}
|
||||||
|
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg mr-auto disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
|
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
||||||
|
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
<button onClick={onClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
||||||
{saving ? t('common.saving') : t('common.save')}
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1738,6 +1738,35 @@ function runMigrations(db: Database.Database): void {
|
|||||||
AND substr(reservations.reservation_end_time, 1, 10) != substr(reservations.reservation_time, 1, 10)
|
AND substr(reservations.reservation_end_time, 1, 10) != substr(reservations.reservation_time, 1, 10)
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
|
// Migration 111: opt-in Immich auto-upload — users column only (#730)
|
||||||
|
// Default is off — uploading to Immich must be an explicit choice, not a
|
||||||
|
// side effect of having a writable API key.
|
||||||
|
() => {
|
||||||
|
try { db.exec('ALTER TABLE users ADD COLUMN immich_auto_upload INTEGER NOT NULL DEFAULT 0'); }
|
||||||
|
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||||
|
},
|
||||||
|
// Migration 112: expose immich auto-upload toggle in the Settings UI (#730)
|
||||||
|
// Runs after Immich provider seeding so the FK to photo_providers holds.
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('photo_providers', 'photo_provider_fields')").all() as Array<{ name: string }>;
|
||||||
|
const hasProviders = hasTable.some(t => t.name === 'photo_providers');
|
||||||
|
const hasFields = hasTable.some(t => t.name === 'photo_provider_fields');
|
||||||
|
if (hasProviders && hasFields) {
|
||||||
|
const immichRow = db.prepare("SELECT 1 FROM photo_providers WHERE id = 'immich' LIMIT 1").get();
|
||||||
|
if (immichRow) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO photo_provider_fields
|
||||||
|
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
|
||||||
|
VALUES
|
||||||
|
('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, 0, 0, 'auto_upload', 'auto_upload', 5)
|
||||||
|
`).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!err.message?.includes('no such table') && !err.message?.includes('FOREIGN KEY')) throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import crypto from 'node:crypto';
|
|||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import * as svc from '../services/journeyService';
|
import * as svc from '../services/journeyService';
|
||||||
|
import { db } from '../db/database';
|
||||||
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
|
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
|
||||||
import { uploadToImmich } from '../services/memories/immichService';
|
import { uploadToImmich } from '../services/memories/immichService';
|
||||||
|
|
||||||
@@ -95,7 +96,11 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
|
|||||||
req.body?.caption
|
req.body?.caption
|
||||||
);
|
);
|
||||||
if (photo) {
|
if (photo) {
|
||||||
// sync to Immich if connected — update the same photo record
|
// Mirror to Immich only when the user has explicitly opted in via the
|
||||||
|
// Immich integration settings. Avoids the "surprise upload" in #730
|
||||||
|
// where a write-capable API key implicitly enabled mirroring.
|
||||||
|
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(authReq.user.id) as { immich_auto_upload?: number } | undefined;
|
||||||
|
if (prefs?.immich_auto_upload) {
|
||||||
try {
|
try {
|
||||||
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
|
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
|
||||||
if (immichId) {
|
if (immichId) {
|
||||||
@@ -105,6 +110,7 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
|
|||||||
photo.owner_id = authReq.user.id;
|
photo.owner_id = authReq.user.id;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
}
|
||||||
results.push(photo);
|
results.push(photo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,11 +307,15 @@ router.post('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
|||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { share_timeline, share_gallery, share_map } = req.body || {};
|
const { share_timeline, share_gallery, share_map } = req.body || {};
|
||||||
const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map });
|
const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map });
|
||||||
|
if (!result) return res.status(403).json({ error: 'Not allowed' });
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||||
deleteJourneyShareLink(Number(req.params.id));
|
const authReq = req as AuthRequest;
|
||||||
|
if (!deleteJourneyShareLink(Number(req.params.id), authReq.user.id)) {
|
||||||
|
return res.status(403).json({ error: 'Not allowed' });
|
||||||
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getClientIp } from '../../services/auditLog';
|
|||||||
import {
|
import {
|
||||||
getConnectionSettings,
|
getConnectionSettings,
|
||||||
saveImmichSettings,
|
saveImmichSettings,
|
||||||
|
setImmichAutoUpload,
|
||||||
testConnection,
|
testConnection,
|
||||||
getConnectionStatus,
|
getConnectionStatus,
|
||||||
browseTimeline,
|
browseTimeline,
|
||||||
@@ -31,9 +32,12 @@ router.get('/settings', authenticate, (req: Request, res: Response) => {
|
|||||||
|
|
||||||
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { immich_url, immich_api_key } = req.body;
|
const { immich_url, immich_api_key, auto_upload } = req.body;
|
||||||
const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req));
|
const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req));
|
||||||
if (!result.success) return res.status(400).json({ error: result.error });
|
if (!result.success) return res.status(400).json({ error: result.error });
|
||||||
|
if (typeof auto_upload === 'boolean') {
|
||||||
|
setImmichAutoUpload(authReq.user.id, auto_upload);
|
||||||
|
}
|
||||||
if (result.warning) return res.json({ success: true, warning: result.warning });
|
if (result.warning) return res.json({ success: true, warning: result.warning });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -167,6 +167,19 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
|||||||
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||||
).get(journeyId, userId) as { hide_skeletons: number } | undefined;
|
).get(journeyId, userId) as { hide_skeletons: number } | undefined;
|
||||||
|
|
||||||
|
// Determine the viewer's role on this journey so the UI can gate edit/settings
|
||||||
|
// actions. 'owner' = creator, 'editor' | 'viewer' = from journey_contributors.
|
||||||
|
const journeyRow = journey as unknown as { user_id?: number };
|
||||||
|
let myRole: 'owner' | 'editor' | 'viewer' | null = null;
|
||||||
|
if (journeyRow.user_id === userId) {
|
||||||
|
myRole = 'owner';
|
||||||
|
} else {
|
||||||
|
const contribRow = db.prepare(
|
||||||
|
'SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||||
|
).get(journeyId, userId) as { role: 'editor' | 'viewer' } | undefined;
|
||||||
|
myRole = contribRow?.role ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...journey,
|
...journey,
|
||||||
entries: enrichedEntries,
|
entries: enrichedEntries,
|
||||||
@@ -174,6 +187,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
|||||||
contributors,
|
contributors,
|
||||||
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
||||||
hide_skeletons: !!(userPrefs?.hide_skeletons),
|
hide_skeletons: !!(userPrefs?.hide_skeletons),
|
||||||
|
my_role: myRole,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +198,9 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
|
|||||||
cover_image: string;
|
cover_image: string;
|
||||||
status: string;
|
status: string;
|
||||||
}>): Journey | null {
|
}>): Journey | null {
|
||||||
if (!canEdit(journeyId, userId)) return null;
|
// Journey-level settings (title, cover, status) are owner-only — editors
|
||||||
|
// may only edit entries and photos, not reshape the journey itself.
|
||||||
|
if (!isOwner(journeyId, userId)) return null;
|
||||||
|
|
||||||
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
|
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
|
||||||
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
|
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
|
||||||
@@ -615,6 +631,14 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
|
|||||||
|
|
||||||
// ── Photos ───────────────────────────────────────────────────────────────
|
// ── Photos ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Promote a skeleton suggestion to a concrete entry. Called whenever the user
|
||||||
|
// adds content (photo upload, provider photo, gallery link) — a suggestion
|
||||||
|
// with photos is no longer just a suggestion.
|
||||||
|
function promoteSkeletonIfNeeded(entry: JourneyEntry): void {
|
||||||
|
if (entry.type !== 'skeleton') return;
|
||||||
|
db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
|
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
|
||||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
@@ -629,6 +653,8 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
|
|||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||||
|
|
||||||
|
promoteSkeletonIfNeeded(entry);
|
||||||
|
|
||||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,6 +677,8 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri
|
|||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||||
|
|
||||||
|
promoteSkeletonIfNeeded(entry);
|
||||||
|
|
||||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,21 +692,41 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe
|
|||||||
|
|
||||||
if (source.entry_id === entryId) return source;
|
if (source.entry_id === entryId) return source;
|
||||||
|
|
||||||
const oldEntryId = source.entry_id;
|
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(source.entry_id) as JourneyEntry | undefined;
|
||||||
|
const sourceIsGallery = oldEntry?.title === 'Gallery';
|
||||||
|
|
||||||
// move photo to the target entry
|
// skip if target already has this photo (by trek_photo_id)
|
||||||
|
const dupe = db.prepare('SELECT id FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, source.photo_id) as { id: number } | undefined;
|
||||||
|
if (dupe) return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(dupe.id) as JourneyPhoto;
|
||||||
|
|
||||||
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||||
|
let resultId: number;
|
||||||
|
|
||||||
|
if (sourceIsGallery) {
|
||||||
|
// Copy so the photo stays in the gallery even after being used in an entry.
|
||||||
|
const res = db.prepare(`
|
||||||
|
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(entryId, source.photo_id, source.caption || null, (maxOrder?.m ?? -1) + 1, ts());
|
||||||
|
resultId = Number(res.lastInsertRowid);
|
||||||
|
} else {
|
||||||
|
// Non-gallery source: keep existing move behavior.
|
||||||
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
|
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
|
||||||
|
resultId = photoId;
|
||||||
|
}
|
||||||
|
|
||||||
// clean up: if old entry was a "Gallery" entry and is now empty, delete it
|
promoteSkeletonIfNeeded(entry);
|
||||||
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(oldEntryId) as JourneyEntry | undefined;
|
|
||||||
if (oldEntry && oldEntry.title === 'Gallery') {
|
// If we moved out of a Gallery entry (shouldn't happen with the guard above,
|
||||||
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(oldEntryId) as { c: number };
|
// but kept for any legacy data), clean up the Gallery wrapper if emptied.
|
||||||
|
if (!sourceIsGallery && oldEntry && oldEntry.title === 'Gallery') {
|
||||||
|
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(source.entry_id) as { c: number };
|
||||||
if (remaining.c === 0) {
|
if (remaining.c === 0) {
|
||||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(oldEntryId);
|
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(source.entry_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
|
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(resultId) as JourneyPhoto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
|
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { isOwner } from './journeyService';
|
||||||
|
|
||||||
interface JourneySharePermissions {
|
interface JourneySharePermissions {
|
||||||
share_timeline?: boolean;
|
share_timeline?: boolean;
|
||||||
@@ -19,7 +20,11 @@ export function createOrUpdateJourneyShareLink(
|
|||||||
journeyId: number,
|
journeyId: number,
|
||||||
createdBy: number,
|
createdBy: number,
|
||||||
permissions: JourneySharePermissions
|
permissions: JourneySharePermissions
|
||||||
): { token: string; created: boolean } {
|
): { token: string; created: boolean } | null {
|
||||||
|
// Public sharing is an owner-only action — editors/viewers must not be
|
||||||
|
// able to publish the journey or change which screens are shared.
|
||||||
|
if (!isOwner(journeyId, createdBy)) return null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
share_timeline = true,
|
share_timeline = true,
|
||||||
share_gallery = true,
|
share_gallery = true,
|
||||||
@@ -51,8 +56,10 @@ export function getJourneyShareLink(journeyId: number): JourneyShareTokenInfo |
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteJourneyShareLink(journeyId: number): void {
|
export function deleteJourneyShareLink(journeyId: number, userId: number): boolean {
|
||||||
|
if (!isOwner(journeyId, userId)) return false;
|
||||||
db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
|
db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
|
export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
|
||||||
|
|||||||
@@ -25,12 +25,18 @@ export function isValidAssetId(id: string): boolean {
|
|||||||
|
|
||||||
export function getConnectionSettings(userId: number) {
|
export function getConnectionSettings(userId: number) {
|
||||||
const creds = getImmichCredentials(userId);
|
const creds = getImmichCredentials(userId);
|
||||||
|
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
|
||||||
return {
|
return {
|
||||||
immich_url: creds?.immich_url || '',
|
immich_url: creds?.immich_url || '',
|
||||||
connected: !!(creds?.immich_url && creds?.immich_api_key),
|
connected: !!(creds?.immich_url && creds?.immich_api_key),
|
||||||
|
auto_upload: !!(prefs?.immich_auto_upload),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setImmichAutoUpload(userId: number, enabled: boolean): void {
|
||||||
|
db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveImmichSettings(
|
export async function saveImmichSettings(
|
||||||
userId: number,
|
userId: number,
|
||||||
immichUrl: string | undefined,
|
immichUrl: string | undefined,
|
||||||
|
|||||||
@@ -318,7 +318,9 @@ describe('updateJourney', () => {
|
|||||||
expect(updated!.subtitle).toBe('New Sub');
|
expect(updated!.subtitle).toBe('New Sub');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('JOURNEY-SVC-019: editor contributor can update', () => {
|
it('JOURNEY-SVC-019: editor contributor cannot update journey settings (#732)', () => {
|
||||||
|
// Post-#732: journey-level settings (title/cover/status) are owner-only.
|
||||||
|
// Editors keep access to entries and photos, but not the journey shell.
|
||||||
const { user: owner } = createUser(testDb);
|
const { user: owner } = createUser(testDb);
|
||||||
const { user: editor } = createUser(testDb);
|
const { user: editor } = createUser(testDb);
|
||||||
const journey = createJourney(testDb, owner.id, { title: 'Original' });
|
const journey = createJourney(testDb, owner.id, { title: 'Original' });
|
||||||
@@ -326,8 +328,7 @@ describe('updateJourney', () => {
|
|||||||
|
|
||||||
const updated = updateJourney(journey.id, editor.id, { title: 'Edited' });
|
const updated = updateJourney(journey.id, editor.id, { title: 'Edited' });
|
||||||
|
|
||||||
expect(updated).not.toBeNull();
|
expect(updated).toBeNull();
|
||||||
expect(updated!.title).toBe('Edited');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('JOURNEY-SVC-020: viewer cannot update', () => {
|
it('JOURNEY-SVC-020: viewer cannot update', () => {
|
||||||
|
|||||||
@@ -176,13 +176,14 @@ describe('getJourneyShareLink', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteJourneyShareLink', () => {
|
describe('deleteJourneyShareLink', () => {
|
||||||
it('JOURNEY-SHARE-007: removes an existing share link', () => {
|
it('JOURNEY-SHARE-007: owner can remove an existing share link', () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const journey = createJourney(testDb, user.id);
|
const journey = createJourney(testDb, user.id);
|
||||||
createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||||
|
|
||||||
deleteJourneyShareLink(journey.id);
|
const ok = deleteJourneyShareLink(journey.id, user.id);
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
expect(getJourneyShareLink(journey.id)).toBeNull();
|
expect(getJourneyShareLink(journey.id)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,7 +191,7 @@ describe('deleteJourneyShareLink', () => {
|
|||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const journey = createJourney(testDb, user.id);
|
const journey = createJourney(testDb, user.id);
|
||||||
|
|
||||||
expect(() => deleteJourneyShareLink(journey.id)).not.toThrow();
|
expect(() => deleteJourneyShareLink(journey.id, user.id)).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user