Merge pull request #739 from mauriceboe/fix/journey-bugs-roel

fix: journey bugs #722-#736 (roel-de-vries batch)
This commit is contained in:
Maurice
2026-04-18 19:16:44 +02:00
committed by GitHub
27 changed files with 448 additions and 169 deletions
@@ -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>
)} )}
+2
View File
@@ -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': 'من المعرض',
+2
View File
@@ -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',
+2
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': 'Из галереи',
+2
View File
@@ -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': '从相册',
+2
View File
@@ -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': '從相簿',
+7 -3
View File
@@ -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);
}); });
+126 -40
View File
@@ -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>
+29
View File
@@ -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) {
+12 -2
View File
@@ -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 });
}); });
+5 -1
View File
@@ -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 });
}); });
+57 -9
View File
@@ -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) {
+9 -2
View File
@@ -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();
}); });
}); });