diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 791c2a5e..6d981112 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -322,6 +322,9 @@ export const journeyApi = { updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data), removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data), + // Preferences + updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data), + // Share getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data), createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data), diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index ac834d22..a15e801d 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1557,6 +1557,8 @@ const ar: Record = { 'journey.detail.backToJourney': 'العودة للمجلة', 'journey.detail.day': 'اليوم {number}', 'journey.detail.places': 'أماكن', + 'journey.skeletons.show': 'إظهار الاقتراحات', + 'journey.skeletons.hide': 'إخفاء الاقتراحات', // Journey — Invite 'journey.invite.role': 'الدور', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 512095f1..9bb4d00e 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1895,6 +1895,8 @@ const br: Record = { 'journey.stats.entries': 'Entradas', 'journey.stats.photos': 'Fotos', 'journey.stats.places': 'Lugares', + 'journey.skeletons.show': 'Mostrar sugestões', + 'journey.skeletons.hide': 'Ocultar sugestões', 'journey.verdict.lovedIt': 'Adorei', 'journey.verdict.couldBeBetter': 'Poderia ser melhor', 'journey.synced.places': 'lugares', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index f5959522..dc07ce84 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1900,6 +1900,8 @@ const cs: Record = { 'journey.stats.entries': 'Záznamy', 'journey.stats.photos': 'Fotky', 'journey.stats.places': 'Místa', + 'journey.skeletons.show': 'Zobrazit návrhy', + 'journey.skeletons.hide': 'Skrýt návrhy', 'journey.verdict.lovedIt': 'Skvělé', 'journey.verdict.couldBeBetter': 'Mohlo by být lepší', 'journey.synced.places': 'místa', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 3e4f2f11..6aa8fc92 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1901,6 +1901,8 @@ const de: Record = { 'journey.stats.entries': 'Einträge', 'journey.stats.photos': 'Fotos', 'journey.stats.places': 'Orte', + 'journey.skeletons.show': 'Vorschläge anzeigen', + 'journey.skeletons.hide': 'Vorschläge ausblenden', 'journey.verdict.lovedIt': 'Toll', 'journey.verdict.couldBeBetter': 'Verbesserungswürdig', 'journey.synced.places': 'Orte', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 91117374..b331ff76 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1906,6 +1906,8 @@ const en: Record = { 'journey.stats.entries': 'Entries', 'journey.stats.photos': 'Photos', 'journey.stats.places': 'Places', + 'journey.skeletons.show': 'Show suggestions', + 'journey.skeletons.hide': 'Hide suggestions', // Journey Detail — Verdict 'journey.verdict.lovedIt': 'Loved it', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index c37097e9..871f53a6 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1902,6 +1902,8 @@ const es: Record = { 'journey.stats.entries': 'Entradas', 'journey.stats.photos': 'Fotos', 'journey.stats.places': 'Lugares', + 'journey.skeletons.show': 'Mostrar sugerencias', + 'journey.skeletons.hide': 'Ocultar sugerencias', 'journey.verdict.lovedIt': 'Me encantó', 'journey.verdict.couldBeBetter': 'Podría mejorar', 'journey.synced.places': 'lugares', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index c00172c5..f6e4f7a7 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1896,6 +1896,8 @@ const fr: Record = { 'journey.stats.entries': 'Entrées', 'journey.stats.photos': 'Photos', 'journey.stats.places': 'Lieux', + 'journey.skeletons.show': 'Afficher les suggestions', + 'journey.skeletons.hide': 'Masquer les suggestions', 'journey.verdict.lovedIt': 'Adoré', 'journey.verdict.couldBeBetter': 'Pourrait être mieux', 'journey.synced.places': 'lieux', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 18117520..d531e555 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1897,6 +1897,8 @@ const hu: Record = { 'journey.stats.entries': 'Bejegyzések', 'journey.stats.photos': 'Fotók', 'journey.stats.places': 'Helyszínek', + 'journey.skeletons.show': 'Javaslatok megjelenítése', + 'journey.skeletons.hide': 'Javaslatok elrejtése', 'journey.verdict.lovedIt': 'Imádtam', 'journey.verdict.couldBeBetter': 'Lehetne jobb', 'journey.synced.places': 'helyszín', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 7637ecb5..f299587a 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1897,6 +1897,8 @@ const it: Record = { 'journey.stats.entries': 'Voci', 'journey.stats.photos': 'Foto', 'journey.stats.places': 'Luoghi', + 'journey.skeletons.show': 'Mostra suggerimenti', + 'journey.skeletons.hide': 'Nascondi suggerimenti', 'journey.verdict.lovedIt': 'Adorato', 'journey.verdict.couldBeBetter': 'Potrebbe essere meglio', 'journey.synced.places': 'luoghi', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 37d1ea2a..a1b64958 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1896,6 +1896,8 @@ const nl: Record = { 'journey.stats.entries': 'Vermeldingen', 'journey.stats.photos': 'Foto\'s', 'journey.stats.places': 'Plaatsen', + 'journey.skeletons.show': 'Suggesties tonen', + 'journey.skeletons.hide': 'Suggesties verbergen', 'journey.verdict.lovedIt': 'Geweldig', 'journey.verdict.couldBeBetter': 'Kan beter', 'journey.synced.places': 'plaatsen', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index e7bc8507..4c741397 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1889,6 +1889,8 @@ const pl: Record = { 'journey.stats.entries': 'Wpisy', 'journey.stats.photos': 'Zdjęcia', 'journey.stats.places': 'Miejsca', + 'journey.skeletons.show': 'Pokaż sugestie', + 'journey.skeletons.hide': 'Ukryj sugestie', 'journey.verdict.lovedIt': 'Świetne', 'journey.verdict.couldBeBetter': 'Mogłoby być lepiej', 'journey.synced.places': 'miejsca', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 23b02a94..d7e4e9cd 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1896,6 +1896,8 @@ const ru: Record = { 'journey.stats.entries': 'Записей', 'journey.stats.photos': 'Фото', 'journey.stats.places': 'Мест', + 'journey.skeletons.show': 'Показать предложения', + 'journey.skeletons.hide': 'Скрыть предложения', 'journey.verdict.lovedIt': 'Понравилось', 'journey.verdict.couldBeBetter': 'Могло быть лучше', 'journey.synced.places': 'мест', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 53b1e24d..2a8d43d1 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1896,6 +1896,8 @@ const zh: Record = { 'journey.stats.entries': '条目', 'journey.stats.photos': '照片', 'journey.stats.places': '地点', + 'journey.skeletons.show': '显示建议', + 'journey.skeletons.hide': '隐藏建议', 'journey.verdict.lovedIt': '非常喜欢', 'journey.verdict.couldBeBetter': '有待改进', 'journey.synced.places': '个地点', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 334fca61..78231de5 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -1856,6 +1856,8 @@ const zhTw: Record = { 'journey.stats.entries': '條目', 'journey.stats.photos': '照片', 'journey.stats.places': '地點', + 'journey.skeletons.show': '顯示建議', + 'journey.skeletons.hide': '隱藏建議', 'journey.verdict.lovedIt': '非常喜歡', 'journey.verdict.couldBeBetter': '有待改進', 'journey.synced.places': '個地點', diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 989ecfd4..e8307f11 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -18,7 +18,7 @@ import { Clock, Package, Image, ChevronRight, UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil, Laugh, Smile, Meh, Annoyed, Frown, - Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, + Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff, } from 'lucide-react' import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' @@ -92,11 +92,16 @@ export default function JourneyDetailPage() { const [showAddTrip, setShowAddTrip] = useState(false) const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null) const [showSettings, setShowSettings] = useState(false) + const [hideSkeletons, setHideSkeletons] = useState(false) useEffect(() => { if (id) loadJourney(Number(id)).catch(() => {}) }, [id]) + useEffect(() => { + if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons) + }, [current?.hide_skeletons]) + useEffect(() => { if (notFound) { toast.error(t('journey.notFound')) @@ -193,7 +198,7 @@ export default function JourneyDetailPage() { ) } - const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]') + const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton')) const dayGroups = groupByDate(timelineEntries) const sortedDates = [...dayGroups.keys()].sort() @@ -243,7 +248,21 @@ export default function JourneyDetailPage() {
- +
+ + + {hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')} + +
diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index e1e1a16f..e0a8f22b 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -82,6 +82,7 @@ export interface JourneyDetail extends Journey { trips: JourneyTrip[] contributors: JourneyContributor[] stats: { entries: number; photos: number; cities: number } + hide_skeletons?: boolean } interface JourneyState { diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 7059391c..4238cf48 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1574,6 +1574,10 @@ function runMigrations(db: Database.Database): void { db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)'); } }, + // Migration 99: hide_skeletons per-user setting on journey_contributors + () => { + try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts index edb9a674..452df039 100644 --- a/server/src/routes/journey.ts +++ b/server/src/routes/journey.ts @@ -279,6 +279,15 @@ router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Res res.json({ success: true }); }); +// ── User Preferences ───────────────────────────────────────────────────── + +router.patch('/:id/preferences', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const result = svc.updateJourneyPreferences(Number(req.params.id), authReq.user.id, req.body); + if (!result) return res.status(403).json({ error: 'Not allowed' }); + res.json(result); +}); + // ── Share Link ──────────────────────────────────────────────────────────── router.get('/:id/share-link', authenticate, (req: Request, res: Response) => { diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts index 168677ab..593bc20a 100644 --- a/server/src/services/journeyService.ts +++ b/server/src/services/journeyService.ts @@ -161,12 +161,17 @@ export function getJourneyFull(journeyId: number, userId: number) { const photoCount = photos.length; const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))]; + const userPrefs = db.prepare( + 'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?' + ).get(journeyId, userId) as { hide_skeletons: number } | undefined; + return { ...journey, entries: enrichedEntries, trips, contributors, stats: { entries: entryCount, photos: photoCount, cities: cities.length }, + hide_skeletons: !!(userPrefs?.hide_skeletons), }; } @@ -197,6 +202,19 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{ return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; } +export function updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) { + if (!canAccessJourney(journeyId, userId)) return null; + if (data.hide_skeletons !== undefined) { + db.prepare( + 'UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?' + ).run(data.hide_skeletons ? 1 : 0, journeyId, userId); + } + const row = db.prepare( + 'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?' + ).get(journeyId, userId) as { hide_skeletons: number }; + return { hide_skeletons: !!row.hide_skeletons }; +} + export function deleteJourney(journeyId: number, userId: number): boolean { if (!isOwner(journeyId, userId)) return false; db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId); @@ -567,7 +585,20 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool // delete photos along with the entry — no more orphan Gallery entries db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId); - db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); + + if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') { + // Revert filled entry back to skeleton instead of deleting + db.prepare(` + UPDATE journey_entries + SET type = 'skeleton', story = NULL, mood = NULL, weather = NULL, pros_cons = NULL, + visibility = 'private', updated_at = ? + WHERE id = ? + `).run(ts(), entryId); + broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid); + } else { + db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); + broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); + } // clean up any empty Gallery entries in this journey db.prepare(` @@ -575,7 +606,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos) `).run(entry.journey_id); - broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); return true; } diff --git a/server/tests/unit/services/journeyService.test.ts b/server/tests/unit/services/journeyService.test.ts index 50d3ea4b..44c6290d 100644 --- a/server/tests/unit/services/journeyService.test.ts +++ b/server/tests/unit/services/journeyService.test.ts @@ -565,6 +565,46 @@ describe('deleteEntry', () => { expect(deleteEntry(entry.id, viewer.id)).toBe(false); }); + + it('JOURNEY-SVC-037b: deleting a filled skeleton reverts it back to skeleton', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Tokyo Tower' }); + + // Create a filled entry that originated from a trip skeleton + const now = Date.now(); + testDb.prepare(` + INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, story, mood, entry_date, location_name, visibility, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, 'entry', 'Tokyo Tower', 'Amazing view!', 'amazing', '2026-03-01', 'Tokyo', 'private', 0, ?, ?) + `).run(journey.id, trip.id, place.id, user.id, now, now); + const entry = testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, place.id) as any; + + const result = deleteEntry(entry.id, user.id); + expect(result).toBe(true); + + // Entry should still exist but reverted to skeleton + const reverted = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id) as any; + expect(reverted).toBeDefined(); + expect(reverted.type).toBe('skeleton'); + expect(reverted.story).toBeNull(); + expect(reverted.mood).toBeNull(); + expect(reverted.source_trip_id).toBe(trip.id); + expect(reverted.source_place_id).toBe(place.id); + expect(reverted.title).toBe('Tokyo Tower'); + }); + + it('JOURNEY-SVC-037c: deleting an independent entry permanently removes it', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', story: 'Manual entry' }); + + const result = deleteEntry(entry.id, user.id); + expect(result).toBe(true); + + const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id); + expect(row).toBeUndefined(); + }); }); // -- Photos -------------------------------------------------------------------