Fix skeleton entry deletion and add hide suggestions toggle (#619)

- Revert filled skeleton entries back to skeleton on delete instead of permanently removing them
- Add per-user hide_skeletons preference on journey_contributors (migration 99)
- Add PATCH /journeys/:id/preferences endpoint for toggling skeleton visibility
- Add Eye/EyeOff toggle button with custom tooltip in journey detail header
- Filter skeleton entries from timeline when hidden
- Add i18n keys for all 14 languages
This commit is contained in:
Maurice
2026-04-14 19:58:13 +02:00
parent bb160a4010
commit b3571f391a
21 changed files with 139 additions and 5 deletions
+3
View File
@@ -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),
+2
View File
@@ -1557,6 +1557,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.backToJourney': 'العودة للمجلة',
'journey.detail.day': 'اليوم {number}',
'journey.detail.places': 'أماكن',
'journey.skeletons.show': 'إظهار الاقتراحات',
'journey.skeletons.hide': 'إخفاء الاقتراحات',
// Journey — Invite
'journey.invite.role': 'الدور',
+2
View File
@@ -1895,6 +1895,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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',
+2
View File
@@ -1900,6 +1900,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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',
+2
View File
@@ -1901,6 +1901,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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',
+2
View File
@@ -1906,6 +1906,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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',
+2
View File
@@ -1902,6 +1902,8 @@ const es: Record<string, string> = {
'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',
+2
View File
@@ -1896,6 +1896,8 @@ const fr: Record<string, string> = {
'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',
+2
View File
@@ -1897,6 +1897,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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',
+2
View File
@@ -1897,6 +1897,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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',
+2
View File
@@ -1896,6 +1896,8 @@ const nl: Record<string, string> = {
'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',
+2
View File
@@ -1889,6 +1889,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'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',
+2
View File
@@ -1896,6 +1896,8 @@ const ru: Record<string, string> = {
'journey.stats.entries': 'Записей',
'journey.stats.photos': 'Фото',
'journey.stats.places': 'Мест',
'journey.skeletons.show': 'Показать предложения',
'journey.skeletons.hide': 'Скрыть предложения',
'journey.verdict.lovedIt': 'Понравилось',
'journey.verdict.couldBeBetter': 'Могло быть лучше',
'journey.synced.places': 'мест',
+2
View File
@@ -1896,6 +1896,8 @@ const zh: Record<string, string> = {
'journey.stats.entries': '条目',
'journey.stats.photos': '照片',
'journey.stats.places': '地点',
'journey.skeletons.show': '显示建议',
'journey.skeletons.hide': '隐藏建议',
'journey.verdict.lovedIt': '非常喜欢',
'journey.verdict.couldBeBetter': '有待改进',
'journey.synced.places': '个地点',
+2
View File
@@ -1856,6 +1856,8 @@ const zhTw: Record<string, string> = {
'journey.stats.entries': '條目',
'journey.stats.photos': '照片',
'journey.stats.places': '地點',
'journey.skeletons.show': '顯示建議',
'journey.skeletons.hide': '隱藏建議',
'journey.verdict.lovedIt': '非常喜歡',
'journey.verdict.couldBeBetter': '有待改進',
'journey.synced.places': '個地點',
+22 -3
View File
@@ -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() {
</button>
<div className="flex items-center gap-1.5">
<button onClick={() => { import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current)) }} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download 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"><Share2 size={14} /></button>
<div className="relative group">
<button
onClick={async () => {
const next = !hideSkeletons
setHideSkeletons(next)
await journeyApi.updatePreferences(current.id, { hide_skeletons: next })
}}
className={`w-[34px] h-[34px] rounded-lg backdrop-blur flex items-center justify-center ${hideSkeletons ? 'bg-white/30' : 'bg-white/15 hover:bg-white/25'}`}
>
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
</span>
</div>
<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>
+1
View File
@@ -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 {
+4
View File
@@ -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) {
+9
View File
@@ -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) => {
+32 -2
View File
@@ -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;
}
@@ -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 -------------------------------------------------------------------