fix(journey): album photos, select-all, heading/hr fixes, dark mode

- Load actual album photos instead of date-range search fallback
  (new GET /albums/:id/photos for Immich + Synology)
- Add select all / deselect all toggle in photo picker
- Normalize Markdown headings to plain text in journal stories
- Fix setext headings (---) rendering as hr instead of h2
- Add remark-breaks for proper line break rendering
- Fix pros/cons dark mode gradient backgrounds
- i18n: selectAll/deselectAll in 14 languages
This commit is contained in:
Maurice
2026-04-13 21:06:15 +02:00
parent c60332dcf1
commit 7e3cb29c57
19 changed files with 135 additions and 1 deletions
+2
View File
@@ -1546,6 +1546,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'journey.picker.selected': 'محدد',
'journey.picker.addTo': 'إضافة إلى',
'journey.picker.newGallery': 'معرض جديد',
'journey.picker.selectAll': 'تحديد الكل',
'journey.picker.deselectAll': 'إلغاء تحديد الكل',
'journey.picker.noAlbums': 'لم يتم العثور على ألبومات',
'journey.picker.selectDate': 'اختر تاريخ',
'journey.picker.search': 'بحث',
+2
View File
@@ -2020,6 +2020,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.picker.selected': 'selecionados',
'journey.picker.addTo': 'Adicionar a',
'journey.picker.newGallery': 'Nova galeria',
'journey.picker.selectAll': 'Selecionar tudo',
'journey.picker.deselectAll': 'Desmarcar tudo',
'journey.picker.noAlbums': 'Nenhum álbum encontrado',
'journey.picker.selectDate': 'Selecionar data',
'journey.picker.search': 'Pesquisar',
+2
View File
@@ -2022,6 +2022,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.picker.selected': 'vybráno',
'journey.picker.addTo': 'Přidat do',
'journey.picker.newGallery': 'Nová galerie',
'journey.picker.selectAll': 'Vybrat vše',
'journey.picker.deselectAll': 'Zrušit výběr',
'journey.picker.noAlbums': 'Žádná alba nenalezena',
'journey.picker.selectDate': 'Vyberte datum',
'journey.picker.search': 'Hledat',
+2
View File
@@ -2010,6 +2010,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.picker.selected': 'ausgewählt',
'journey.picker.addTo': 'Hinzufügen zu',
'journey.picker.newGallery': 'Neue Galerie',
'journey.picker.selectAll': 'Alle auswählen',
'journey.picker.deselectAll': 'Alle abwählen',
'journey.picker.noAlbums': 'Keine Alben gefunden',
'journey.picker.selectDate': 'Datum wählen',
'journey.picker.search': 'Suchen',
+2
View File
@@ -2037,6 +2037,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.picker.selected': 'selected',
'journey.picker.addTo': 'Add to',
'journey.picker.newGallery': 'New Gallery',
'journey.picker.selectAll': 'Select all',
'journey.picker.deselectAll': 'Deselect all',
'journey.picker.noAlbums': 'No albums found',
'journey.picker.selectDate': 'Select date',
'journey.picker.search': 'Search',
+2
View File
@@ -2024,6 +2024,8 @@ const es: Record<string, string> = {
'journey.picker.selected': 'seleccionados',
'journey.picker.addTo': 'Añadir a',
'journey.picker.newGallery': 'Nueva galería',
'journey.picker.selectAll': 'Seleccionar todo',
'journey.picker.deselectAll': 'Deseleccionar todo',
'journey.picker.noAlbums': 'No se encontraron álbumes',
'journey.picker.selectDate': 'Seleccionar fecha',
'journey.picker.search': 'Buscar',
+2
View File
@@ -2018,6 +2018,8 @@ const fr: Record<string, string> = {
'journey.picker.selected': 'sélectionnés',
'journey.picker.addTo': 'Ajouter à',
'journey.picker.newGallery': 'Nouvelle galerie',
'journey.picker.selectAll': 'Tout sélectionner',
'journey.picker.deselectAll': 'Tout désélectionner',
'journey.picker.noAlbums': 'Aucun album trouvé',
'journey.picker.selectDate': 'Sélectionner une date',
'journey.picker.search': 'Rechercher',
+2
View File
@@ -2019,6 +2019,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.picker.selected': 'kiválasztva',
'journey.picker.addTo': 'Hozzáadás',
'journey.picker.newGallery': 'Új galéria',
'journey.picker.selectAll': 'Összes kijelölése',
'journey.picker.deselectAll': 'Összes kijelölés törlése',
'journey.picker.noAlbums': 'Nem található album',
'journey.picker.selectDate': 'Dátum választása',
'journey.picker.search': 'Keresés',
+2
View File
@@ -2019,6 +2019,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.picker.selected': 'selezionati',
'journey.picker.addTo': 'Aggiungi a',
'journey.picker.newGallery': 'Nuova galleria',
'journey.picker.selectAll': 'Seleziona tutto',
'journey.picker.deselectAll': 'Deseleziona tutto',
'journey.picker.noAlbums': 'Nessun album trovato',
'journey.picker.selectDate': 'Seleziona data',
'journey.picker.search': 'Cerca',
+2
View File
@@ -2018,6 +2018,8 @@ const nl: Record<string, string> = {
'journey.picker.selected': 'geselecteerd',
'journey.picker.addTo': 'Toevoegen aan',
'journey.picker.newGallery': 'Nieuwe galerij',
'journey.picker.selectAll': 'Alles selecteren',
'journey.picker.deselectAll': 'Alles deselecteren',
'journey.picker.noAlbums': 'Geen albums gevonden',
'journey.picker.selectDate': 'Selecteer datum',
'journey.picker.search': 'Zoeken',
+2
View File
@@ -2014,6 +2014,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.picker.selected': 'wybranych',
'journey.picker.addTo': 'Dodaj do',
'journey.picker.newGallery': 'Nowa galeria',
'journey.picker.selectAll': 'Zaznacz wszystko',
'journey.picker.deselectAll': 'Odznacz wszystko',
'journey.picker.noAlbums': 'Nie znaleziono albumów',
'journey.picker.selectDate': 'Wybierz datę',
'journey.picker.search': 'Szukaj',
+2
View File
@@ -2018,6 +2018,8 @@ const ru: Record<string, string> = {
'journey.picker.selected': 'выбрано',
'journey.picker.addTo': 'Добавить в',
'journey.picker.newGallery': 'Новая галерея',
'journey.picker.selectAll': 'Выбрать все',
'journey.picker.deselectAll': 'Снять выбор',
'journey.picker.noAlbums': 'Альбомы не найдены',
'journey.picker.selectDate': 'Выберите дату',
'journey.picker.search': 'Поиск',
+2
View File
@@ -2018,6 +2018,8 @@ const zh: Record<string, string> = {
'journey.picker.selected': '已选择',
'journey.picker.addTo': '添加到',
'journey.picker.newGallery': '新相册',
'journey.picker.selectAll': '全选',
'journey.picker.deselectAll': '取消全选',
'journey.picker.noAlbums': '未找到相册',
'journey.picker.selectDate': '选择日期',
'journey.picker.search': '搜索',
+2
View File
@@ -1979,6 +1979,8 @@ const zhTw: Record<string, string> = {
'journey.picker.selected': '已選擇',
'journey.picker.addTo': '新增至',
'journey.picker.newGallery': '新相簿',
'journey.picker.selectAll': '全選',
'journey.picker.deselectAll': '取消全選',
'journey.picker.noAlbums': '未找到相簿',
'journey.picker.selectDate': '選擇日期',
'journey.picker.search': '搜尋',
+37 -1
View File
@@ -1398,6 +1398,15 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
setLoading(false)
}
const loadAlbumPhotos = async (albumId: string) => {
setLoading(true)
try {
const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include' })
if (res.ok) setPhotos((await res.json()).assets || [])
} catch {}
setLoading(false)
}
const loadAlbums = async () => {
try {
const res = await fetch(`/api/integrations/memories/${provider}/albums`, { credentials: 'include' })
@@ -1511,7 +1520,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{albums.map((a: any) => (
<button
key={a.id}
onClick={() => { setSelectedAlbum(a.id); searchPhotos(a.startDate || '2000-01-01', a.endDate || '2099-01-01') }}
onClick={() => { setSelectedAlbum(a.id); loadAlbumPhotos(a.id) }}
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
selectedAlbum === a.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
@@ -1577,6 +1586,33 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{/* Photo grid */}
<div className="flex-1 overflow-y-auto p-4">
{/* Select all toggle */}
{!loading && photos.length > 0 && (() => {
const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id))
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
if (selectable.length === 0) return null
return (
<button
onClick={() => {
if (allSelected) {
setSelected(new Set())
} else {
setSelected(new Set(selectable.map((a: any) => a.id)))
}
}}
className="mb-3 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
allSelected
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
: 'border-zinc-300 dark:border-zinc-600'
}`}>
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
</div>
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
</button>
)
})()}
{loading ? (
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
+8
View File
@@ -13,6 +13,7 @@ import {
searchPhotos,
streamImmichAsset,
listAlbums,
getAlbumPhotos,
syncAlbumAssets,
getAssetInfo,
isValidAssetId,
@@ -113,6 +114,13 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
res.json({ albums: result.albums });
});
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = await getAlbumPhotos(authReq.user.id, req.params.albumId);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ assets: result.assets });
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
+6
View File
@@ -7,6 +7,7 @@ import {
getSynologyStatus,
testSynologyConnection,
listSynologyAlbums,
getSynologyAlbumPhotos,
syncSynologyAlbumLink,
searchSynologyPhotos,
getSynologyAssetInfo,
@@ -77,6 +78,11 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
handleServiceResult(res, await listSynologyAlbums(authReq.user.id));
});
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId));
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
@@ -285,6 +285,32 @@ export async function listAlbums(
}
}
export async function getAlbumPhotos(
userId: number,
albumId: string,
): Promise<{ assets?: any[]; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await safeFetch(`${creds.immich_url}/api/albums/${albumId}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status };
const albumData = await resp.json() as { assets?: any[] };
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE').map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
}));
return { assets };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
export function listAlbumLinks(tripId: string) {
return db.prepare(`
SELECT tal.*, u.username
@@ -452,6 +452,36 @@ export async function listSynologyAlbums(userId: number): Promise<ServiceResult<
}
export async function getSynologyAlbumPhotos(userId: number, albumId: string): Promise<ServiceResult<AssetsList>> {
const allItems: SynologyPhotoItem[] = [];
const pageSize = 1000;
let offset = 0;
while (true) {
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
api: 'SYNO.Foto.Browse.Item',
method: 'list',
version: 1,
album_id: Number(albumId),
offset,
limit: pageSize,
additional: ['thumbnail'],
});
if (!result.success) return result as ServiceResult<AssetsList>;
const items = result.data.list || [];
allItems.push(...items);
if (items.length < pageSize) break;
offset += pageSize;
}
const assets = allItems.map(item => ({
id: String(item.additional?.thumbnail?.cache_key || item.id || ''),
takenAt: item.time ? new Date(item.time * 1000).toISOString() : '',
})).filter(a => a.id);
return success({ assets, total: assets.length, hasMore: false });
}
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise<ServiceResult<SyncAlbumResult>> {
const response = getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) return response as ServiceResult<SyncAlbumResult>;