release: v0.17.0 (#677)
See CHANGELOG.md, section [0.17.0] — 2026-09-23.
This commit is contained in:
@@ -45,6 +45,7 @@ import {
|
||||
isAlbumReachable,
|
||||
withoutLockedAlbums,
|
||||
findSubpageBySlug,
|
||||
isHttpsRequest,
|
||||
} from '@/lib/auth';
|
||||
|
||||
describe('findSubpageBySlug', () => {
|
||||
@@ -286,3 +287,40 @@ describe('withoutLockedAlbums', () => {
|
||||
expect(withoutLockedAlbums([LOCKED, OPEN], getCookie)).toEqual([LOCKED, OPEN]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The `Secure` flag follows the request, not NODE_ENV (#664): the Docker image
|
||||
* always runs in production, and a browser drops a `Secure` cookie set over
|
||||
* plain HTTP — the password was accepted and every next request answered 401.
|
||||
*/
|
||||
describe('isHttpsRequest', () => {
|
||||
const req = (url: string, proto?: string) =>
|
||||
new Request(url, { headers: proto ? { 'x-forwarded-proto': proto } : {} });
|
||||
|
||||
it('reads the scheme of a direct request', () => {
|
||||
expect(isHttpsRequest(req('https://folio.example/api/auth'))).toBe(true);
|
||||
expect(isHttpsRequest(req('http://192.168.1.10:7211/api/auth'))).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers the scheme a reverse proxy reports', () => {
|
||||
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'https'))).toBe(true);
|
||||
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'HTTPS'))).toBe(true);
|
||||
expect(isHttpsRequest(req('https://folio.example/api/auth', 'http'))).toBe(false);
|
||||
});
|
||||
|
||||
it('takes the first entry of a chained header', () => {
|
||||
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'https, http'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticate — Secure flag', () => {
|
||||
it('marks the cookie Secure by default', async () => {
|
||||
expect(await authenticate('private', 'secret123')).toMatch(/; Secure$/);
|
||||
});
|
||||
|
||||
it('leaves Secure off for a plain-HTTP visitor', async () => {
|
||||
const cookie = await authenticate('private', 'secret123', 'subpage', false);
|
||||
expect(cookie).toContain('lb_auth_private=');
|
||||
expect(cookie).not.toContain('Secure');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('dictionary parity', () => {
|
||||
'lightbox.iso',
|
||||
'lightbox.copyLinkShort',
|
||||
'lightbox.downloadShort',
|
||||
'common.website',
|
||||
'subpage.coverAria',
|
||||
]);
|
||||
|
||||
|
||||
141
lib/__tests__/journal-album.test.ts
Normal file
141
lib/__tests__/journal-album.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown, collectAssetIds } from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
import { expandAlbumBlocks, orderAlbumAssets } from '../journalAlbum';
|
||||
|
||||
const block = (md: string) => parseJournalMarkdown(md).blocks[0];
|
||||
|
||||
describe('album block syntax', () => {
|
||||
it('parses the id and every option', () => {
|
||||
expect(
|
||||
block(
|
||||
['::album abc-123', 'count: 12', 'skip: 2', 'layout: pairs', 'caption: The **day**'].join(
|
||||
'\n',
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'album',
|
||||
albumId: 'abc-123',
|
||||
count: 12,
|
||||
skip: 2,
|
||||
layout: 'pairs',
|
||||
caption: 'The <strong>day</strong>',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to grid, all photos, no skip — and allows an empty id placeholder', () => {
|
||||
expect(block('::album')).toEqual({ type: 'album', albumId: '', layout: 'grid' });
|
||||
expect(block('::album abc')).toEqual({ type: 'album', albumId: 'abc', layout: 'grid' });
|
||||
});
|
||||
|
||||
it('ignores malformed options', () => {
|
||||
expect(block('::album abc\ncount: many\nlayout: mosaic\nskip: -1')).toEqual({
|
||||
type: 'album',
|
||||
albumId: 'abc',
|
||||
layout: 'grid',
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips byte-stable and stays out of the asset ids', () => {
|
||||
const md = ['::album abc-123', 'count: 12', 'skip: 2', 'layout: wide', 'caption: Twelve'].join(
|
||||
'\n',
|
||||
);
|
||||
const parsed = parseJournalMarkdown(md);
|
||||
expect(serializeJournalMarkdown(parsed)).toBe(md);
|
||||
expect(serializeJournalMarkdown(parseJournalMarkdown('::album'))).toBe('::album');
|
||||
expect(collectAssetIds(parsed.blocks)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const assets = (n: number, type = 'IMAGE') =>
|
||||
Array.from({ length: n }, (_, i) => ({ id: `p${i + 1}`, type }));
|
||||
|
||||
describe('expandAlbumBlocks', () => {
|
||||
const lookup = (id: string) => (id === 'alb' ? assets(7) : undefined);
|
||||
const albumBlock = (
|
||||
extra: Partial<Extract<JournalBlock, { type: 'album' }>> = {},
|
||||
): JournalBlock => ({
|
||||
type: 'album',
|
||||
albumId: 'alb',
|
||||
layout: 'grid',
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('turns an album into one grid, keeping the surrounding blocks', () => {
|
||||
const before: JournalBlock = { type: 'heading', level: 2, text: 'Party' };
|
||||
const { blocks, albumAssetIds } = expandAlbumBlocks(
|
||||
[before, albumBlock({ caption: 'All of it' })],
|
||||
lookup,
|
||||
);
|
||||
expect(blocks).toEqual([
|
||||
before,
|
||||
{
|
||||
type: 'photo-grid',
|
||||
assetIds: ['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7'],
|
||||
caption: 'All of it',
|
||||
},
|
||||
]);
|
||||
expect(albumAssetIds).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7']);
|
||||
});
|
||||
|
||||
it('slices with count and skip', () => {
|
||||
const { blocks } = expandAlbumBlocks([albumBlock({ count: 3, skip: 2 })], lookup);
|
||||
expect(blocks).toEqual([{ type: 'photo-grid', assetIds: ['p3', 'p4', 'p5'] }]);
|
||||
});
|
||||
|
||||
it('lays out pairs with an odd tail and puts the caption last', () => {
|
||||
const { blocks } = expandAlbumBlocks(
|
||||
[albumBlock({ count: 5, layout: 'pairs', caption: 'c' })],
|
||||
lookup,
|
||||
);
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'photo-pair', assetIds: ['p1', 'p2'] },
|
||||
{ type: 'photo-pair', assetIds: ['p3', 'p4'] },
|
||||
{ type: 'photo', assetId: 'p5', layout: 'contained', caption: 'c' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lays out wide as one photo per row', () => {
|
||||
const { blocks } = expandAlbumBlocks([albumBlock({ count: 2, layout: 'wide' })], lookup);
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'photo', assetId: 'p1', layout: 'wide' },
|
||||
{ type: 'photo', assetId: 'p2', layout: 'wide' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to a pair or a single photo when a grid has too few', () => {
|
||||
expect(expandAlbumBlocks([albumBlock({ count: 2 })], lookup).blocks).toEqual([
|
||||
{ type: 'photo-pair', assetIds: ['p1', 'p2'] },
|
||||
]);
|
||||
expect(expandAlbumBlocks([albumBlock({ count: 1 })], lookup).blocks).toEqual([
|
||||
{ type: 'photo', assetId: 'p1', layout: 'contained' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops an empty id, an unknown album and an empty slice', () => {
|
||||
expect(expandAlbumBlocks([albumBlock({ albumId: '' })], lookup).blocks).toEqual([]);
|
||||
expect(expandAlbumBlocks([albumBlock({ albumId: 'nope' })], lookup).blocks).toEqual([]);
|
||||
expect(expandAlbumBlocks([albumBlock({ skip: 99 })], lookup).blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts the gallery manual order first, then the album order', () => {
|
||||
const { blocks } = expandAlbumBlocks([albumBlock({ count: 4 })], lookup, {
|
||||
alb: ['p6', 'p2', 'ghost'],
|
||||
});
|
||||
expect(blocks).toEqual([{ type: 'photo-grid', assetIds: ['p6', 'p2', 'p1', 'p3'] }]);
|
||||
});
|
||||
|
||||
it('keeps only images and videos', () => {
|
||||
const mixed = [...assets(2), { id: 'x', type: 'OTHER' }, { id: 'v', type: 'VIDEO' }];
|
||||
const { blocks } = expandAlbumBlocks([albumBlock()], () => mixed);
|
||||
expect(blocks).toEqual([{ type: 'photo-grid', assetIds: ['p1', 'p2', 'v'] }]);
|
||||
});
|
||||
|
||||
it('orderAlbumAssets pins first occurrences only', () => {
|
||||
expect(orderAlbumAssets(assets(3), ['p3', 'p3', 'p1']).map((a) => a.id)).toEqual([
|
||||
'p3',
|
||||
'p1',
|
||||
'p2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
86
lib/__tests__/journal-facts.test.ts
Normal file
86
lib/__tests__/journal-facts.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown, calculateReadingTime } from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
|
||||
const roundTrip = (blocks: JournalBlock[]) =>
|
||||
parseJournalMarkdown(
|
||||
serializeJournalMarkdown({ frontmatter: {}, blocks, referencedAssetIds: [] }),
|
||||
);
|
||||
|
||||
describe('facts block', () => {
|
||||
it('parses a ::facts chunk into label/value items', () => {
|
||||
const parsed = parseJournalMarkdown('::facts\nDistance: 21 km\nElevation: 1,240 m');
|
||||
expect(parsed.blocks).toEqual([
|
||||
{
|
||||
type: 'facts',
|
||||
items: [
|
||||
{ label: 'Distance', value: '21 km' },
|
||||
{ label: 'Elevation', value: '1,240 m' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits at the first colon so a time keeps its value', () => {
|
||||
const parsed = parseJournalMarkdown('::facts\nStart: 08:30');
|
||||
expect(parsed.blocks[0]).toMatchObject({
|
||||
type: 'facts',
|
||||
items: [{ label: 'Start', value: '08:30' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores lines without a colon or with an empty side', () => {
|
||||
const parsed = parseJournalMarkdown('::facts\njust words\n: no label\nNo value:\nOk: yes');
|
||||
expect(parsed.blocks[0]).toEqual({ type: 'facts', items: [{ label: 'Ok', value: 'yes' }] });
|
||||
});
|
||||
|
||||
it('renders inline markdown in values and escapes the rest', () => {
|
||||
const parsed = parseJournalMarkdown(
|
||||
'::facts\nGuide: [Anna](https://example.com)\nNote: <b>x</b>',
|
||||
);
|
||||
expect(parsed.blocks[0]).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
label: 'Guide',
|
||||
value: '<a href="https://example.com" target="_blank" rel="noopener noreferrer">Anna</a>',
|
||||
},
|
||||
{ label: 'Note', value: '<b>x</b>' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips, dropping rows the editor left empty', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'facts',
|
||||
items: [
|
||||
{ label: 'Distance', value: '21 km' },
|
||||
{ label: '', value: '' },
|
||||
{ label: 'Weather', value: 'clear, **cold**' },
|
||||
],
|
||||
};
|
||||
const parsed = roundTrip([block]);
|
||||
expect(parsed.blocks).toEqual([
|
||||
{
|
||||
type: 'facts',
|
||||
items: [
|
||||
{ label: 'Distance', value: '21 km' },
|
||||
{ label: 'Weather', value: 'clear, <strong>cold</strong>' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
// And the serialized form is stable from here on.
|
||||
const once = serializeJournalMarkdown(parsed);
|
||||
expect(serializeJournalMarkdown(parseJournalMarkdown(once))).toBe(once);
|
||||
expect(once).toBe('::facts\nDistance: 21 km\nWeather: clear, **cold**');
|
||||
});
|
||||
|
||||
it('is its own block between paragraphs', () => {
|
||||
const parsed = parseJournalMarkdown('Intro.\n\n::facts\nA: 1\n\nOutro.');
|
||||
expect(parsed.blocks.map((b) => b.type)).toEqual(['paragraph', 'facts', 'paragraph']);
|
||||
});
|
||||
|
||||
it('does not count the directive line as reading', () => {
|
||||
const { words } = calculateReadingTime('::facts\nDistance: 21 km');
|
||||
expect(words).toBe(3);
|
||||
});
|
||||
});
|
||||
70
lib/__tests__/journal-grid.test.ts
Normal file
70
lib/__tests__/journal-grid.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseJournalMarkdown,
|
||||
serializeJournalMarkdown,
|
||||
collectAssetIds,
|
||||
mapBlockAssetIds,
|
||||
} from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
|
||||
const roundTrip = (blocks: JournalBlock[]) =>
|
||||
parseJournalMarkdown(
|
||||
serializeJournalMarkdown({ frontmatter: {}, blocks, referencedAssetIds: [] }),
|
||||
);
|
||||
|
||||
describe('photo grid block', () => {
|
||||
it('parses three or more ids as a grid and keeps them all', () => {
|
||||
const parsed = parseJournalMarkdown('');
|
||||
expect(parsed.blocks).toEqual([
|
||||
{ type: 'photo-grid', assetIds: ['a1', 'b2', 'c3'], caption: 'Three' },
|
||||
]);
|
||||
expect(parsed.referencedAssetIds).toEqual(['a1', 'b2', 'c3']);
|
||||
});
|
||||
|
||||
it('no longer drops the third id on a save (pair used to keep two)', () => {
|
||||
const once = serializeJournalMarkdown(parseJournalMarkdown('![a1, b2, c3]()'));
|
||||
expect(once).toBe('![a1, b2, c3]()');
|
||||
});
|
||||
|
||||
it('keeps two ids a pair', () => {
|
||||
const parsed = parseJournalMarkdown('');
|
||||
expect(parsed.blocks[0]).toMatchObject({ type: 'photo-pair', assetIds: ['a1', 'b2'] });
|
||||
});
|
||||
|
||||
it('round-trips five ids with a markdown caption', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'photo-grid',
|
||||
assetIds: ['a', 'b', 'c', 'd', 'e'],
|
||||
caption: 'A <strong>bold</strong> caption',
|
||||
};
|
||||
expect(roundTrip([block]).blocks).toEqual([block]);
|
||||
});
|
||||
|
||||
it('round-trips unfilled placeholders without referencing them', () => {
|
||||
const block: JournalBlock = { type: 'photo-grid', assetIds: ['', '', ''], caption: undefined };
|
||||
const parsed = roundTrip([block]);
|
||||
expect(parsed.blocks).toEqual([block]);
|
||||
expect(parsed.referencedAssetIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('references only the filled ids of a half-filled grid, once each', () => {
|
||||
const blocks: JournalBlock[] = [
|
||||
{ type: 'photo-grid', assetIds: ['a', '', 'b', 'a'], caption: undefined },
|
||||
];
|
||||
expect(collectAssetIds(blocks)).toEqual(['a', 'b']);
|
||||
expect(roundTrip(blocks).referencedAssetIds).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('maps every id of a grid through the token function', () => {
|
||||
const block: JournalBlock = { type: 'photo-grid', assetIds: ['a', 'b', 'c'] };
|
||||
expect(mapBlockAssetIds(block, (id) => `t:${id}`)).toEqual({
|
||||
type: 'photo-grid',
|
||||
assetIds: ['t:a', 't:b', 't:c'],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves blocks without ids untouched when mapping', () => {
|
||||
const block: JournalBlock = { type: 'heading', level: 2, text: 'Hi' };
|
||||
expect(mapBlockAssetIds(block, () => 'x')).toBe(block);
|
||||
});
|
||||
});
|
||||
251
lib/__tests__/journal-map.test.ts
Normal file
251
lib/__tests__/journal-map.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseJournalMarkdown,
|
||||
serializeJournalMarkdown,
|
||||
collectAssetIds,
|
||||
mapBlockAssetIds,
|
||||
} from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
import { pinsForEntry } from '../journalMap';
|
||||
import type { LocationPrecision } from '../mapPrecision';
|
||||
|
||||
const mapBlock = (md: string) => parseJournalMarkdown(md).blocks[0];
|
||||
|
||||
describe('map block syntax', () => {
|
||||
it('parses ::map with and without a caption, empty by default', () => {
|
||||
expect(mapBlock('::map')).toEqual({ type: 'map', caption: undefined, line: true, items: [] });
|
||||
expect(mapBlock('::map Busan → Seoul')).toMatchObject({ caption: 'Busan → Seoul' });
|
||||
});
|
||||
|
||||
it('does not mistake ::mapping for a map', () => {
|
||||
expect(mapBlock('::mapping things').type).toBe('paragraph');
|
||||
});
|
||||
|
||||
it('reads every line form, in order', () => {
|
||||
const block = mapBlock(
|
||||
[
|
||||
'::map The trip',
|
||||
'Busan harbour: 35.098, 129.036',
|
||||
'photo: aaa, bbb',
|
||||
'photos: all',
|
||||
'37.566, 126.978',
|
||||
'Photo: ccc',
|
||||
'line: off',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(block).toEqual({
|
||||
type: 'map',
|
||||
caption: 'The trip',
|
||||
line: false,
|
||||
items: [
|
||||
{ kind: 'point', label: 'Busan harbour', lat: 35.098, lng: 129.036 },
|
||||
{ kind: 'photo', assetId: 'aaa' },
|
||||
{ kind: 'photo', assetId: 'bbb' },
|
||||
{ kind: 'all-photos' },
|
||||
{ kind: 'point', lat: 37.566, lng: 126.978 },
|
||||
{ kind: 'photo', assetId: 'ccc' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores malformed and out-of-range coordinates and unknown lines', () => {
|
||||
const block = mapBlock(
|
||||
[
|
||||
'::map',
|
||||
'Nowhere: 95, 10',
|
||||
'Also: 10, 200',
|
||||
'Text without numbers',
|
||||
'1.5',
|
||||
'Ok: -33.9, 151.2',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(block).toMatchObject({
|
||||
items: [{ kind: 'point', label: 'Ok', lat: -33.9, lng: 151.2 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips byte-stable and never writes pins', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'map',
|
||||
caption: 'A <strong>trip</strong>',
|
||||
line: false,
|
||||
items: [
|
||||
{ kind: 'point', label: 'Start', lat: 52.51, lng: 13.47 },
|
||||
{ kind: 'photo', assetId: 'abc' },
|
||||
{ kind: 'all-photos' },
|
||||
{ kind: 'point', lat: 52.4, lng: 13.04 },
|
||||
],
|
||||
pins: [{ lat: 1, lng: 2, label: 'never' }],
|
||||
};
|
||||
const md = serializeJournalMarkdown({
|
||||
frontmatter: {},
|
||||
blocks: [block],
|
||||
referencedAssetIds: [],
|
||||
});
|
||||
expect(md).toBe(
|
||||
[
|
||||
'::map A **trip**',
|
||||
'Start: 52.51, 13.47',
|
||||
'photo: abc',
|
||||
'photos: all',
|
||||
'52.4, 13.04',
|
||||
'line: off',
|
||||
].join('\n'),
|
||||
);
|
||||
const reparsed = parseJournalMarkdown(md);
|
||||
const { pins: _pins, ...withoutPins } = block;
|
||||
expect(reparsed.blocks).toEqual([{ ...withoutPins, caption: 'A <strong>trip</strong>' }]);
|
||||
expect(serializeJournalMarkdown(reparsed)).toBe(md);
|
||||
});
|
||||
|
||||
it('drops invalid points and empty photo placeholders on save', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'map',
|
||||
line: true,
|
||||
items: [
|
||||
{ kind: 'point', lat: Number.NaN, lng: Number.NaN },
|
||||
{ kind: 'photo', assetId: '' },
|
||||
{ kind: 'point', label: 'Kept', lat: 1, lng: 2 },
|
||||
],
|
||||
};
|
||||
expect(
|
||||
serializeJournalMarkdown({ frontmatter: {}, blocks: [block], referencedAssetIds: [] }),
|
||||
).toBe('::map\nKept: 1, 2');
|
||||
});
|
||||
|
||||
it('collects and maps the ids of photo pins', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'map',
|
||||
line: true,
|
||||
items: [
|
||||
{ kind: 'photo', assetId: 'abc' },
|
||||
{ kind: 'all-photos' },
|
||||
{ kind: 'photo', assetId: '' },
|
||||
],
|
||||
};
|
||||
expect(collectAssetIds([block])).toEqual(['abc']);
|
||||
expect(mapBlockAssetIds(block, (id) => `t:${id}`)).toMatchObject({
|
||||
items: [
|
||||
{ kind: 'photo', assetId: 't:abc' },
|
||||
{ kind: 'all-photos' },
|
||||
{ kind: 'photo', assetId: 't:' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const asset = (
|
||||
id: string,
|
||||
lat: number | null,
|
||||
lng: number | null,
|
||||
city = 'Busan',
|
||||
country = 'South Korea',
|
||||
) => ({
|
||||
id,
|
||||
exifInfo: {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
city,
|
||||
country,
|
||||
} as unknown as NonNullable<import('../immich').ImmichAsset['exifInfo']>,
|
||||
});
|
||||
|
||||
describe('pinsForEntry', () => {
|
||||
const exact = (): LocationPrecision => 'exact';
|
||||
|
||||
it('publishes typed points as typed, no snapping, no label invented', () => {
|
||||
const pins = pinsForEntry(
|
||||
[
|
||||
{ kind: 'point', label: 'Harbour', lat: 35.098123, lng: 129.036456 },
|
||||
{ kind: 'point', lat: 37.5, lng: 127 },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
exact,
|
||||
);
|
||||
expect(pins).toEqual([
|
||||
{ lat: 35.098123, lng: 129.036456, label: 'Harbour' },
|
||||
{ lat: 37.5, lng: 127 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('places a photo pin on the 1 km grid with the city label', () => {
|
||||
const pins = pinsForEntry(
|
||||
[{ kind: 'photo', assetId: 'a' }],
|
||||
[asset('a', 52.510044, 13.467244, 'Berlin', 'Germany')],
|
||||
[],
|
||||
exact,
|
||||
);
|
||||
expect(pins).toEqual([{ lat: 52.51, lng: 13.47, label: 'Berlin, Germany' }]);
|
||||
});
|
||||
|
||||
it('expands photos: all in entry order, skipping ids listed explicitly', () => {
|
||||
const assets = [asset('a', 1, 1), asset('b', 2, 2), asset('c', 3, 3)];
|
||||
const pins = pinsForEntry(
|
||||
[{ kind: 'photo', assetId: 'c' }, { kind: 'all-photos' }],
|
||||
assets,
|
||||
['a', 'b', 'c'],
|
||||
exact,
|
||||
);
|
||||
expect(pins.map((p) => [p.lat, p.lng])).toEqual([
|
||||
[3, 3],
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the drawing order across mixed items', () => {
|
||||
const pins = pinsForEntry(
|
||||
[
|
||||
{ kind: 'point', label: 'Start', lat: 0.5, lng: 0.5 },
|
||||
{ kind: 'photo', assetId: 'a' },
|
||||
{ kind: 'point', label: 'End', lat: 9, lng: 9 },
|
||||
],
|
||||
[asset('a', 1, 1)],
|
||||
['a'],
|
||||
exact,
|
||||
);
|
||||
expect(pins.map((p) => p.label)).toEqual(['Start', 'Busan, South Korea', 'End']);
|
||||
});
|
||||
|
||||
it('applies precision to photo pins only: city snaps, country and hidden vanish', () => {
|
||||
const levels: Record<string, LocationPrecision> = { a: 'city', b: 'country', c: 'hidden' };
|
||||
const pins = pinsForEntry(
|
||||
[{ kind: 'all-photos' }, { kind: 'point', lat: 52.5163, lng: 13.3777 }],
|
||||
[asset('a', 52.5163, 13.3777, 'Berlin', 'Germany'), asset('b', 2, 2), asset('c', 3, 3)],
|
||||
['a', 'b', 'c'],
|
||||
(id) => levels[id],
|
||||
);
|
||||
expect(pins).toEqual([
|
||||
{ lat: 52.5, lng: 13.4, label: 'Berlin, Germany' },
|
||||
{ lat: 52.5163, lng: 13.3777 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips photos without coordinates but keeps the equator (#635)', () => {
|
||||
const pins = pinsForEntry(
|
||||
[{ kind: 'all-photos' }],
|
||||
[asset('a', null, null), asset('b', 0, 0, '', '')],
|
||||
['a', 'b'],
|
||||
exact,
|
||||
);
|
||||
expect(pins).toEqual([{ lat: 0, lng: 0 }]);
|
||||
});
|
||||
|
||||
it('merges items at one position into one pin', () => {
|
||||
const pins = pinsForEntry(
|
||||
[
|
||||
{ kind: 'point', lat: 52.51, lng: 13.47 },
|
||||
{ kind: 'photo', assetId: 'a' },
|
||||
],
|
||||
[asset('a', 52.510044, 13.467244)],
|
||||
[],
|
||||
exact,
|
||||
);
|
||||
expect(pins).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores photo items the page did not fetch', () => {
|
||||
expect(pinsForEntry([{ kind: 'photo', assetId: 'missing' }], [], [], exact)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown } from '../journal';
|
||||
import type { JournalBlock } from '../journal';
|
||||
|
||||
/**
|
||||
* serializeJournalMarkdown must be the exact inverse of the inline renderer
|
||||
@@ -89,4 +90,43 @@ describe('journal round trip (#628)', () => {
|
||||
expect(once).toContain('[Immich](https://immich.app)');
|
||||
expect(once).toContain("Anna's photos");
|
||||
});
|
||||
|
||||
it('round-trips an unfilled photo placeholder as a photo block, not text (#template placeholders)', () => {
|
||||
for (const layout of ['contained', 'wide', 'fullbleed'] as const) {
|
||||
const block: JournalBlock = { type: 'photo', assetId: '', caption: 'Pick a photo', layout };
|
||||
const serialized = serializeJournalMarkdown({
|
||||
frontmatter: {},
|
||||
blocks: [block],
|
||||
referencedAssetIds: [],
|
||||
});
|
||||
const parsed = parseJournalMarkdown(serialized);
|
||||
expect(parsed.blocks).toEqual([block]);
|
||||
expect(parsed.referencedAssetIds).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips an unfilled photo-pair placeholder without referencing empty ids', () => {
|
||||
const block: JournalBlock = { type: 'photo-pair', assetIds: ['', ''], caption: 'Two to pick' };
|
||||
const serialized = serializeJournalMarkdown({
|
||||
frontmatter: {},
|
||||
blocks: [block],
|
||||
referencedAssetIds: [],
|
||||
});
|
||||
const parsed = parseJournalMarkdown(serialized);
|
||||
expect(parsed.blocks).toEqual([block]);
|
||||
expect(parsed.referencedAssetIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a filled id in a half-filled pair as the only reference', () => {
|
||||
const block: JournalBlock = {
|
||||
type: 'photo-pair',
|
||||
assetIds: ['abc123', ''],
|
||||
caption: undefined,
|
||||
};
|
||||
const parsed = parseJournalMarkdown(
|
||||
serializeJournalMarkdown({ frontmatter: {}, blocks: [block], referencedAssetIds: [] }),
|
||||
);
|
||||
expect(parsed.blocks).toEqual([block]);
|
||||
expect(parsed.referencedAssetIds).toEqual(['abc123']);
|
||||
});
|
||||
});
|
||||
|
||||
42
lib/__tests__/journal-templates.test.ts
Normal file
42
lib/__tests__/journal-templates.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown } from '../journal';
|
||||
import { JOURNAL_TEMPLATES } from '../journalTemplates';
|
||||
|
||||
describe('journal templates', () => {
|
||||
it('has at least one template and unique ids', () => {
|
||||
expect(JOURNAL_TEMPLATES.length).toBeGreaterThan(0);
|
||||
const ids = JOURNAL_TEMPLATES.map((t) => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
for (const template of JOURNAL_TEMPLATES) {
|
||||
it(`round-trips "${template.id}" without losing or reshaping blocks`, () => {
|
||||
const serialized = serializeJournalMarkdown({
|
||||
frontmatter: {},
|
||||
blocks: template.blocks,
|
||||
referencedAssetIds: [],
|
||||
});
|
||||
const parsed = parseJournalMarkdown(serialized);
|
||||
expect(parsed.blocks).toEqual(template.blocks);
|
||||
// Placeholders must not surface as assets to probe or render.
|
||||
expect(parsed.referencedAssetIds).toEqual([]);
|
||||
});
|
||||
|
||||
it(`"${template.id}" never flags a placeholder as a legacy asset reference`, () => {
|
||||
for (const block of template.blocks) {
|
||||
if (block.type === 'photo') expect(block.assetId).toBe('');
|
||||
if (block.type === 'photo-pair' || block.type === 'photo-grid') {
|
||||
for (const id of block.assetIds) expect(id).toBe('');
|
||||
}
|
||||
// A facts row with an empty side is dropped on save; a template must
|
||||
// not ship rows that vanish on first load.
|
||||
if (block.type === 'facts') {
|
||||
for (const item of block.items) {
|
||||
expect(item.label).not.toBe('');
|
||||
expect(item.value).not.toBe('');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
27
lib/auth.ts
27
lib/auth.ts
@@ -185,14 +185,37 @@ export function isProtected(key: string, type: ProtectedType = 'subpage'): boole
|
||||
return !!findPassword(key, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the visitor reached us over HTTPS — decides the `Secure` cookie flag.
|
||||
*
|
||||
* This used to be `NODE_ENV === 'production'`, which is always true in the
|
||||
* Docker image. A browser silently drops a `Secure` cookie set over plain HTTP,
|
||||
* so a deployment at `http://host:7211` accepted the password and then answered
|
||||
* every following request with 401 (#664).
|
||||
*
|
||||
* `X-Forwarded-Proto` is trusted without `TRUSTED_PROXY_HOPS`: a visitor who
|
||||
* forges it only changes the flag on their own cookie, in their own browser.
|
||||
* A proxy that omits it costs an HTTPS site the flag, which the HSTS header on
|
||||
* every page makes close to moot — the browser never asks over HTTP again.
|
||||
*/
|
||||
export function isHttpsRequest(request: Request): boolean {
|
||||
const forwarded = request.headers.get('x-forwarded-proto');
|
||||
if (forwarded) return forwarded.split(',')[0].trim().toLowerCase() === 'https';
|
||||
return new URL(request.url).protocol === 'https:';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a password attempt and return a Set-Cookie header value on success.
|
||||
* Returns null if the password is wrong.
|
||||
*
|
||||
* `secure` comes from `isHttpsRequest()`; it defaults to on so that a caller
|
||||
* which forgets it errs towards the stricter cookie.
|
||||
*/
|
||||
export async function authenticate(
|
||||
key: string,
|
||||
password: string,
|
||||
type: ProtectedType = 'subpage',
|
||||
secure = true,
|
||||
): Promise<string | null> {
|
||||
const storedPassword = findPassword(key, type);
|
||||
if (!storedPassword) return null;
|
||||
@@ -237,9 +260,9 @@ export async function authenticate(
|
||||
|
||||
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
|
||||
const token = authToken(tokenKey(key, type), storedPassword, Date.now() + maxAge * 1000);
|
||||
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
|
||||
const secureFlag = secure ? '; Secure' : '';
|
||||
|
||||
return `${cookieName(key, type)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secure}`;
|
||||
return `${cookieName(key, type)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secureFlag}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,9 +7,13 @@ export {
|
||||
type JournalFrontmatter as EssayFrontmatter,
|
||||
type JournalBlock as EssayBlock,
|
||||
type ParsedJournal as ParsedEssay,
|
||||
type MapItem,
|
||||
isValidCoordinate,
|
||||
sanitizeHtml,
|
||||
renderInlineMarkdown,
|
||||
parseFrontmatter,
|
||||
parseJournalMarkdown as parseEssayMarkdown,
|
||||
serializeJournalMarkdown as serializeEssayMarkdown,
|
||||
collectAssetIds,
|
||||
mapBlockAssetIds,
|
||||
} from './journal';
|
||||
|
||||
@@ -27,6 +27,8 @@ export const de: Dictionary = {
|
||||
backTo: (label: string) => `Zurück zu ${label}`,
|
||||
backToGallery: 'Zurück zur Galerie',
|
||||
backToJournal: 'Zurück zum Journal',
|
||||
email: 'E-Mail',
|
||||
website: 'Website',
|
||||
home: 'Start',
|
||||
photos: (n: number) => plural(n, 'Foto', 'Fotos'),
|
||||
albums: (n: number) => plural(n, 'Album', 'Alben'),
|
||||
@@ -43,6 +45,7 @@ export const de: Dictionary = {
|
||||
entryNavAria: 'Journal-Navigation',
|
||||
loadingGallery: 'Galerie wird geladen',
|
||||
loadingPhotos: 'Fotos werden geladen',
|
||||
downloadAlbum: 'Album herunterladen',
|
||||
},
|
||||
|
||||
home: {
|
||||
@@ -61,6 +64,15 @@ export const de: Dictionary = {
|
||||
reference: (digest: string) => `Referenz: ${digest}`,
|
||||
},
|
||||
|
||||
download: {
|
||||
unavailableTitle: 'Download nicht möglich',
|
||||
notAvailable: 'Dieser Download ist nicht verfügbar.',
|
||||
rateLimited: 'Zu viele Download-Anfragen. Bitte einen Moment warten und erneut versuchen.',
|
||||
immichUnavailable:
|
||||
'Die Fotobibliothek ist gerade nicht erreichbar. Bitte gleich noch einmal versuchen.',
|
||||
back: 'Zurück zur Galerie',
|
||||
},
|
||||
|
||||
theme: {
|
||||
switchToLight: 'Zum hellen Modus wechseln',
|
||||
switchToDark: 'Zum dunklen Modus wechseln',
|
||||
@@ -118,6 +130,7 @@ export const de: Dictionary = {
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Bildansicht',
|
||||
openPhoto: (n: number) => `Foto ${n} ansehen`,
|
||||
close: 'Schließen',
|
||||
closeTitle: 'Schließen (Esc)',
|
||||
previous: 'Vorheriges Foto',
|
||||
@@ -178,7 +191,10 @@ export const de: Dictionary = {
|
||||
linkCopied: 'Link kopiert!',
|
||||
copyList: 'Textliste kopieren (#1, #2...)',
|
||||
listCopied: 'Liste kopiert!',
|
||||
copyManualLink: 'Diesen Link kopieren',
|
||||
copyManualList: 'Diese Liste kopieren',
|
||||
sendEmail: '✉️ E-Mail an den Fotografen',
|
||||
downloadSelected: 'Auswahl herunterladen (.zip)',
|
||||
clearSelection: 'Auswahl löschen',
|
||||
confirmClear: 'Die gesamte Auswahl löschen?',
|
||||
listEmpty: (albumName: string) => `${albumName}: Keine Fotos ausgewählt.`,
|
||||
|
||||
@@ -27,6 +27,9 @@ export const en = {
|
||||
backTo: (label: string) => `Back to ${label}`,
|
||||
backToGallery: 'Back to Gallery',
|
||||
backToJournal: 'Back to Journal',
|
||||
/** Footer contact links, read out by screen readers. */
|
||||
email: 'Email',
|
||||
website: 'Website',
|
||||
home: 'Home',
|
||||
photos: (n: number) => plural(n, 'photo', 'photos'),
|
||||
albums: (n: number) => plural(n, 'album', 'albums'),
|
||||
@@ -43,6 +46,7 @@ export const en = {
|
||||
entryNavAria: 'Journal entry navigation',
|
||||
loadingGallery: 'Loading gallery',
|
||||
loadingPhotos: 'Loading photos',
|
||||
downloadAlbum: 'Download album',
|
||||
},
|
||||
|
||||
home: {
|
||||
@@ -61,6 +65,15 @@ export const en = {
|
||||
reference: (digest: string) => `Reference: ${digest}`,
|
||||
},
|
||||
|
||||
/** The page the ZIP route answers a browser with when it refuses. */
|
||||
download: {
|
||||
unavailableTitle: 'Download unavailable',
|
||||
notAvailable: 'This download is not available.',
|
||||
rateLimited: 'Too many download requests. Please wait a moment and try again.',
|
||||
immichUnavailable: 'The photo library is unavailable right now. Please try again shortly.',
|
||||
back: 'Back to the gallery',
|
||||
},
|
||||
|
||||
theme: {
|
||||
switchToLight: 'Switch to light mode',
|
||||
switchToDark: 'Switch to dark mode',
|
||||
@@ -118,6 +131,8 @@ export const en = {
|
||||
|
||||
lightbox: {
|
||||
viewer: 'Image viewer',
|
||||
/** A grid tile, which opens the viewer on that photo. */
|
||||
openPhoto: (n: number) => `View photo ${n}`,
|
||||
close: 'Close',
|
||||
closeTitle: 'Close (Esc)',
|
||||
previous: 'Previous photo',
|
||||
@@ -178,7 +193,11 @@ export const en = {
|
||||
linkCopied: 'Link Copied!',
|
||||
copyList: 'Copy Text List (#1, #2...)',
|
||||
listCopied: 'List Copied!',
|
||||
/** Shown when the browser offers no clipboard (plain http) — copy by hand. */
|
||||
copyManualLink: 'Copy this link',
|
||||
copyManualList: 'Copy this list',
|
||||
sendEmail: '✉️ Send Email to Photographer',
|
||||
downloadSelected: 'Download selected (.zip)',
|
||||
clearSelection: 'Clear Selection',
|
||||
confirmClear: 'Clear all selected favorites?',
|
||||
listEmpty: (albumName: string) => `${albumName}: No photos selected.`,
|
||||
|
||||
290
lib/journal.ts
290
lib/journal.ts
@@ -19,7 +19,53 @@ export type JournalBlock =
|
||||
| { type: 'paragraph'; html: string }
|
||||
| { type: 'quote'; text: string; author?: string }
|
||||
| { type: 'photo'; assetId: string; caption?: string; layout: 'fullbleed' | 'wide' | 'contained' }
|
||||
| { type: 'photo-pair'; assetIds: [string, string]; caption?: string };
|
||||
| { type: 'photo-pair'; assetIds: [string, string]; caption?: string }
|
||||
| { type: 'photo-grid'; assetIds: string[]; caption?: string }
|
||||
| { type: 'facts'; items: Array<{ label: string; value: string }> }
|
||||
| { type: 'map'; caption?: string; line: boolean; items: MapItem[]; pins?: MapPin[] }
|
||||
| {
|
||||
/**
|
||||
* "x photos from an album". Expanded on the server into photo blocks
|
||||
* (lib/journalAlbum.ts) before the page renders, so the client never
|
||||
* sees this type; the studio expands it through the admin assets route.
|
||||
*/
|
||||
type: 'album';
|
||||
albumId: string;
|
||||
count?: number;
|
||||
skip?: number;
|
||||
layout: AlbumBlockLayout;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type AlbumBlockLayout = 'grid' | 'pairs' | 'wide';
|
||||
|
||||
/**
|
||||
* What an author puts on a journal map, in the order the line is drawn.
|
||||
* A typed point is published as typed. A photo item is placed by its EXIF
|
||||
* position under the album's `location:` precision; `all-photos` expands to
|
||||
* every geotagged photo of the entry not already listed.
|
||||
*/
|
||||
export type MapItem =
|
||||
| { kind: 'point'; label?: string; lat: number; lng: number }
|
||||
| { kind: 'photo'; assetId: string }
|
||||
| { kind: 'all-photos' };
|
||||
|
||||
export function isValidCoordinate(lat: number, lng: number): boolean {
|
||||
return (
|
||||
Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A position on a journal map. Never authored and never serialized: the
|
||||
* server derives pins from the entry's own geotagged photos at render time
|
||||
* (lib/journalMap.ts), already quantised to the album's `location:` setting.
|
||||
*/
|
||||
export interface MapPin {
|
||||
lat: number;
|
||||
lng: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ParsedJournal {
|
||||
frontmatter: JournalFrontmatter;
|
||||
@@ -27,6 +73,51 @@ export interface ParsedJournal {
|
||||
referencedAssetIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every asset id a block refers to, in block order, without duplicates.
|
||||
* An empty id is an unfilled placeholder (the block editor's "+ Pick" tile),
|
||||
* not a reference — it is skipped so nothing probes or renders it.
|
||||
*/
|
||||
export function collectAssetIds(blocks: readonly JournalBlock[]): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'photo') {
|
||||
if (block.assetId) ids.add(block.assetId);
|
||||
} else if (block.type === 'photo-pair' || block.type === 'photo-grid') {
|
||||
for (const id of block.assetIds) if (id) ids.add(id);
|
||||
} else if (block.type === 'map') {
|
||||
for (const item of block.items)
|
||||
if (item.kind === 'photo' && item.assetId) ids.add(item.assetId);
|
||||
}
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same block with every asset id passed through `fn` — the one place that
|
||||
* knows which block types carry ids, so the page that swaps raw UUIDs for
|
||||
* tokens does not grow a branch per type.
|
||||
*/
|
||||
export function mapBlockAssetIds(block: JournalBlock, fn: (id: string) => string): JournalBlock {
|
||||
switch (block.type) {
|
||||
case 'photo':
|
||||
return { ...block, assetId: fn(block.assetId) };
|
||||
case 'photo-pair':
|
||||
return { ...block, assetIds: [fn(block.assetIds[0]), fn(block.assetIds[1])] };
|
||||
case 'photo-grid':
|
||||
return { ...block, assetIds: block.assetIds.map(fn) };
|
||||
case 'map':
|
||||
return {
|
||||
...block,
|
||||
items: block.items.map((item) =>
|
||||
item.kind === 'photo' ? { ...item, assetId: fn(item.assetId) } : item,
|
||||
),
|
||||
};
|
||||
default:
|
||||
return block;
|
||||
}
|
||||
}
|
||||
|
||||
export interface JournalEntrySummary {
|
||||
slug: string;
|
||||
filename: string;
|
||||
@@ -289,11 +380,6 @@ export function parseFrontmatter(content: string): {
|
||||
export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
const { frontmatter, body } = parseFrontmatter(rawContent);
|
||||
const blocks: JournalBlock[] = [];
|
||||
const referencedAssetIds = new Set<string>();
|
||||
|
||||
if (frontmatter.coverAssetId) {
|
||||
referencedAssetIds.add(frontmatter.coverAssetId);
|
||||
}
|
||||
|
||||
// Split body into paragraph chunks separated by blank lines
|
||||
const chunks = body
|
||||
@@ -302,6 +388,106 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
.filter(Boolean);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
// 0a. Map: `::map Caption`, then one line per pin in drawing order —
|
||||
// `Label: lat, lng` or `lat, lng` for a typed point, `photo: <id>` for
|
||||
// a photo placed by its GPS, `photos: all` for every geotagged photo
|
||||
// of the entry, `line: off` to drop the connecting line. Pins are
|
||||
// never in the file — see MapPin. Malformed lines are ignored.
|
||||
const [mapFirst, ...mapRest] = chunk.split('\n');
|
||||
const mapMatch = mapFirst.match(/^::map(?:[ \t]+(\S.*))?[ \t]*$/);
|
||||
if (mapMatch) {
|
||||
const caption = mapMatch[1]?.trim();
|
||||
const items: MapItem[] = [];
|
||||
let line = true;
|
||||
const coords = (s: string) => {
|
||||
const m = s.match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/);
|
||||
if (!m) return null;
|
||||
const lat = Number(m[1]);
|
||||
const lng = Number(m[2]);
|
||||
return isValidCoordinate(lat, lng) ? { lat, lng } : null;
|
||||
};
|
||||
for (const raw of mapRest) {
|
||||
const text = raw.trim();
|
||||
if (!text) continue;
|
||||
const colon = text.indexOf(':');
|
||||
const key = colon === -1 ? '' : text.slice(0, colon).trim();
|
||||
const value = colon === -1 ? text : text.slice(colon + 1).trim();
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey === 'line') {
|
||||
line = !['off', 'false', 'no', '0'].includes(value.toLowerCase());
|
||||
} else if (lowerKey === 'photos') {
|
||||
if (value.toLowerCase() === 'all') items.push({ kind: 'all-photos' });
|
||||
} else if (lowerKey === 'photo') {
|
||||
for (const id of value.split(',')) {
|
||||
const assetId = id.trim();
|
||||
if (assetId) items.push({ kind: 'photo', assetId });
|
||||
}
|
||||
} else {
|
||||
const pos = coords(value);
|
||||
if (pos)
|
||||
items.push(key ? { kind: 'point', label: key, ...pos } : { kind: 'point', ...pos });
|
||||
}
|
||||
}
|
||||
blocks.push({
|
||||
type: 'map',
|
||||
caption: caption ? renderInlineMarkdown(caption) : undefined,
|
||||
line,
|
||||
items,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 0b. Facts: a `::facts` line, then `Label: Value` lines in the same chunk.
|
||||
// `::` because `![facts]` would read as a legacy photo reference and `#`
|
||||
// and `>` are taken; no paragraph starts that way. The first colon
|
||||
// splits, so `Time: 08:30` keeps its value. Lines without one, or with
|
||||
// an empty side, are ignored.
|
||||
if (chunk.startsWith('::facts')) {
|
||||
const items = chunk
|
||||
.split('\n')
|
||||
.slice(1)
|
||||
.flatMap((line) => {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon === -1) return [];
|
||||
const label = line.slice(0, colon).trim();
|
||||
const value = line.slice(colon + 1).trim();
|
||||
return label && value ? [{ label, value: renderInlineMarkdown(value) }] : [];
|
||||
});
|
||||
blocks.push({ type: 'facts', items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 0c. Album: `::album <id>` with `count:`, `skip:`, `layout:` and
|
||||
// `caption:` lines. An empty id is the studio's unfilled placeholder.
|
||||
const [albumFirst, ...albumRest] = chunk.split('\n');
|
||||
const albumMatch = albumFirst.match(/^::album(?:[ \t]+(\S+))?[ \t]*$/);
|
||||
if (albumMatch) {
|
||||
let count: number | undefined;
|
||||
let skip: number | undefined;
|
||||
let layout: AlbumBlockLayout = 'grid';
|
||||
let caption: string | undefined;
|
||||
for (const raw of albumRest) {
|
||||
const colon = raw.indexOf(':');
|
||||
if (colon === -1) continue;
|
||||
const key = raw.slice(0, colon).trim().toLowerCase();
|
||||
const value = raw.slice(colon + 1).trim();
|
||||
if (key === 'count' && /^\d+$/.test(value)) count = Number(value);
|
||||
else if (key === 'skip' && /^\d+$/.test(value)) skip = Number(value);
|
||||
else if (key === 'layout' && (value === 'grid' || value === 'pairs' || value === 'wide'))
|
||||
layout = value;
|
||||
else if (key === 'caption' && value) caption = renderInlineMarkdown(value);
|
||||
}
|
||||
blocks.push({
|
||||
type: 'album',
|
||||
albumId: albumMatch[1] ?? '',
|
||||
...(count !== undefined ? { count } : {}),
|
||||
...(skip ? { skip } : {}),
|
||||
layout,
|
||||
...(caption ? { caption } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Headings (# H1, ## H2, ### H3)
|
||||
// `[ \t]+` rather than `\s+`: next to `(.+)` the two overlap, and the
|
||||
// engine has to try every split of the whitespace run before failing.
|
||||
@@ -333,23 +519,30 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Image syntax:  or 
|
||||
const imgMatch = chunk.match(/^!\[([^\]]+)\]\(([^)]*)\)$/);
|
||||
// 3. Image syntax: ,  for a
|
||||
// pair,  for a grid
|
||||
//
|
||||
// The bracket group allows zero characters (`*`, not `+`) so a template's
|
||||
// unfilled placeholder — `assetId: ''`, serialized as `` or
|
||||
// `` — still parses back into a `photo` block instead of
|
||||
// silently degrading into a text paragraph on the next load.
|
||||
const imgMatch = chunk.match(/^!\[([^\]]*)\]\(([^)]*)\)$/);
|
||||
if (imgMatch) {
|
||||
const rawTarget = imgMatch[1].trim();
|
||||
const caption = imgMatch[2].trim() ? renderInlineMarkdown(imgMatch[2].trim()) : undefined;
|
||||
|
||||
// Side-by-side pair: 
|
||||
// Two ids are a side-by-side pair, three or more a grid. The pair used
|
||||
// to take the first two of any count and drop the rest on the next save.
|
||||
if (rawTarget.includes(',')) {
|
||||
const parts = rawTarget.split(',').map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const id1 = parts[0];
|
||||
const id2 = parts[1];
|
||||
referencedAssetIds.add(id1);
|
||||
referencedAssetIds.add(id2);
|
||||
if (parts.length >= 3) {
|
||||
blocks.push({ type: 'photo-grid', assetIds: parts, caption });
|
||||
continue;
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
blocks.push({
|
||||
type: 'photo-pair',
|
||||
assetIds: [id1, id2],
|
||||
assetIds: [parts[0], parts[1]],
|
||||
caption,
|
||||
});
|
||||
continue;
|
||||
@@ -368,7 +561,6 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
else if (normLayout === 'wide') layout = 'wide';
|
||||
}
|
||||
|
||||
referencedAssetIds.add(assetId);
|
||||
blocks.push({
|
||||
type: 'photo',
|
||||
assetId,
|
||||
@@ -385,11 +577,15 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
blocks,
|
||||
referencedAssetIds: Array.from(referencedAssetIds),
|
||||
};
|
||||
// The cover comes first, as before; collectAssetIds() skips '' placeholders.
|
||||
const referencedAssetIds = Array.from(
|
||||
new Set([
|
||||
...(frontmatter.coverAssetId ? [frontmatter.coverAssetId] : []),
|
||||
...collectAssetIds(blocks),
|
||||
]),
|
||||
);
|
||||
|
||||
return { frontmatter, blocks, referencedAssetIds };
|
||||
}
|
||||
|
||||
/** Converts a ParsedJournal structure back into clean Markdown syntax */
|
||||
@@ -451,6 +647,52 @@ export function serializeJournalMarkdown(journal: ParsedJournal): string {
|
||||
lines.push(`![${block.assetIds[0]}, ${block.assetIds[1]}](${caption})`);
|
||||
break;
|
||||
}
|
||||
case 'photo-grid': {
|
||||
const caption = block.caption
|
||||
? inlineHtmlToMarkdown(block.caption.replace(/[\r\n]+/g, ' '))
|
||||
: '';
|
||||
lines.push(``);
|
||||
break;
|
||||
}
|
||||
case 'map': {
|
||||
const caption = block.caption
|
||||
? inlineHtmlToMarkdown(block.caption.replace(/[\r\n]+/g, ' ')).trim()
|
||||
: '';
|
||||
lines.push(caption ? `::map ${caption}` : '::map');
|
||||
for (const item of block.items) {
|
||||
if (item.kind === 'all-photos') lines.push('photos: all');
|
||||
else if (item.kind === 'photo') {
|
||||
if (item.assetId) lines.push(`photo: ${item.assetId}`);
|
||||
} else if (isValidCoordinate(item.lat, item.lng)) {
|
||||
const label = item.label?.replace(/[\r\n]+/g, ' ').trim();
|
||||
lines.push(label ? `${label}: ${item.lat}, ${item.lng}` : `${item.lat}, ${item.lng}`);
|
||||
}
|
||||
}
|
||||
if (!block.line) lines.push('line: off');
|
||||
break;
|
||||
}
|
||||
case 'album': {
|
||||
lines.push(block.albumId ? `::album ${block.albumId}` : '::album');
|
||||
if (block.count !== undefined) lines.push(`count: ${block.count}`);
|
||||
if (block.skip) lines.push(`skip: ${block.skip}`);
|
||||
if (block.layout !== 'grid') lines.push(`layout: ${block.layout}`);
|
||||
if (block.caption) {
|
||||
const caption = inlineHtmlToMarkdown(block.caption.replace(/[\r\n]+/g, ' ')).trim();
|
||||
if (caption) lines.push(`caption: ${caption}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'facts': {
|
||||
lines.push('::facts');
|
||||
for (const item of block.items) {
|
||||
const label = item.label.trim();
|
||||
const value = inlineHtmlToMarkdown(item.value)
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.trim();
|
||||
if (label && value) lines.push(`${label}: ${value}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
@@ -460,7 +702,11 @@ export function serializeJournalMarkdown(journal: ParsedJournal): string {
|
||||
|
||||
/** Calculate approximate word count and reading time */
|
||||
export function calculateReadingTime(text: string): { words: number; minutes: number } {
|
||||
const plainText = text.replace(/<[^>]+>/g, ' ').replace(/!\[.*?\]\(.*?\)/g, ' ');
|
||||
const plainText = text
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/!\[.*?\]\(.*?\)/g, ' ')
|
||||
// Directive lines (`::facts`, `::map …`) are structure, not reading.
|
||||
.replace(/^::\w+.*$/gm, ' ');
|
||||
const words = plainText.trim().split(/\s+/).filter(Boolean).length;
|
||||
const minutes = Math.max(1, Math.round(words / 200));
|
||||
return { words, minutes };
|
||||
|
||||
107
lib/journalAlbum.ts
Normal file
107
lib/journalAlbum.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Album blocks — "x photos from an album" — expanded into ordinary photo
|
||||
* blocks. Pure and client-safe: the caller supplies the album's assets (the
|
||||
* server from `immich.getAlbumAssetsRaw()`, the studio from the admin assets
|
||||
* route), so the same expansion runs on the page and in the preview.
|
||||
*/
|
||||
|
||||
import type { AlbumBlockLayout, JournalBlock } from './journal';
|
||||
|
||||
export interface AlbumAssetRef {
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gallery's `assetOrder` is a pinned prefix, not a permutation (see
|
||||
* lib/albumSort.ts): pinned ids first in their order, everything else in the
|
||||
* order given — which for a raw album fetch is Immich's own.
|
||||
*/
|
||||
export function orderAlbumAssets<T extends { id: string }>(
|
||||
assets: T[],
|
||||
manualOrder?: string[],
|
||||
): T[] {
|
||||
if (!manualOrder?.length) return assets;
|
||||
const rank = new Map<string, number>();
|
||||
manualOrder.forEach((id, i) => {
|
||||
if (!rank.has(id)) rank.set(id, i);
|
||||
});
|
||||
const pinned: T[] = [];
|
||||
const rest: T[] = [];
|
||||
for (const asset of assets) (rank.has(asset.id) ? pinned : rest).push(asset);
|
||||
pinned.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
|
||||
return [...pinned, ...rest];
|
||||
}
|
||||
|
||||
function photoBlocks(ids: string[], layout: AlbumBlockLayout, caption?: string): JournalBlock[] {
|
||||
if (layout === 'wide') {
|
||||
return ids.map((assetId, i) => ({
|
||||
type: 'photo',
|
||||
assetId,
|
||||
layout: 'wide',
|
||||
...(caption && i === ids.length - 1 ? { caption } : {}),
|
||||
}));
|
||||
}
|
||||
if (layout === 'pairs') {
|
||||
const out: JournalBlock[] = [];
|
||||
for (let i = 0; i < ids.length; i += 2) {
|
||||
const last = i + 2 >= ids.length;
|
||||
if (i + 1 < ids.length) {
|
||||
out.push({
|
||||
type: 'photo-pair',
|
||||
assetIds: [ids[i], ids[i + 1]],
|
||||
...(caption && last ? { caption } : {}),
|
||||
});
|
||||
} else {
|
||||
out.push({
|
||||
type: 'photo',
|
||||
assetId: ids[i],
|
||||
layout: 'contained',
|
||||
...(caption ? { caption } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// grid — a grid needs three; fewer fall back to what the count allows.
|
||||
if (ids.length >= 3)
|
||||
return [{ type: 'photo-grid', assetIds: ids, ...(caption ? { caption } : {}) }];
|
||||
if (ids.length === 2) {
|
||||
return [{ type: 'photo-pair', assetIds: [ids[0], ids[1]], ...(caption ? { caption } : {}) }];
|
||||
}
|
||||
return [{ type: 'photo', assetId: ids[0], layout: 'contained', ...(caption ? { caption } : {}) }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every album block with photo blocks. An album the lookup does not
|
||||
* know (not fetched, unreachable, or the studio still loading it), an empty
|
||||
* id, or a selection that leaves no photos removes the block. Returns the
|
||||
* ids the expansion introduced so the caller can fetch or measure them.
|
||||
*/
|
||||
export function expandAlbumBlocks(
|
||||
blocks: readonly JournalBlock[],
|
||||
lookup: (albumId: string) => readonly AlbumAssetRef[] | undefined,
|
||||
manualOrders?: Record<string, string[]>,
|
||||
): { blocks: JournalBlock[]; albumAssetIds: string[] } {
|
||||
const albumAssetIds: string[] = [];
|
||||
const out = blocks.flatMap((block): JournalBlock[] => {
|
||||
if (block.type !== 'album') return [block];
|
||||
if (!block.albumId) return [];
|
||||
const assets = lookup(block.albumId);
|
||||
if (!assets) return [];
|
||||
|
||||
const ordered = orderAlbumAssets(
|
||||
assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO'),
|
||||
manualOrders?.[block.albumId],
|
||||
);
|
||||
const skip = block.skip ?? 0;
|
||||
const ids = ordered
|
||||
.slice(skip, block.count !== undefined ? skip + block.count : undefined)
|
||||
.map((a) => a.id);
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
albumAssetIds.push(...ids);
|
||||
return photoBlocks(ids, block.layout, block.caption);
|
||||
});
|
||||
return { blocks: out, albumAssetIds: Array.from(new Set(albumAssetIds)) };
|
||||
}
|
||||
117
lib/journalMap.ts
Normal file
117
lib/journalMap.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Pins for a journal map block.
|
||||
*
|
||||
* The block's items say what goes on the map, in order. A typed point is the
|
||||
* author's content and is published as typed. A photo item is derived from
|
||||
* EXIF the author may not have thought about, so it is placed under the same
|
||||
* per-photo precision the EXIF route applies (album `location:`, #469): the
|
||||
* map page is the only other public GPS surface and publishes positions
|
||||
* quantised per album, and a map inside a story must not become a sloppier
|
||||
* one. Everything here runs on the server, from photos the page has already
|
||||
* fetched; only quantised positions and allowed place names reach the client.
|
||||
*/
|
||||
|
||||
import type { ImmichAsset } from './immich';
|
||||
import type { MapItem, MapPin } from './journal';
|
||||
import { applyPrecision, placeLabel, snapToGrid, type LocationPrecision } from './mapPrecision';
|
||||
import { assetLocationPrecision } from './assetLocation';
|
||||
|
||||
type GeoAsset = Pick<ImmichAsset, 'id' | 'exifInfo'>;
|
||||
|
||||
/**
|
||||
* `exact` on the map page means the mean of an album's photos in a city —
|
||||
* never a single photo's coordinates. A journal photo pin is one photo, so
|
||||
* `exact` here is snapped to a 0.01° grid (~1 km): a hike or a trip stays a
|
||||
* route, a house does not become a marker. Decided 2026-09-22.
|
||||
*/
|
||||
const EXACT_STEP = 0.01;
|
||||
const EXACT_DECIMALS = 2;
|
||||
|
||||
/**
|
||||
* Pure. `entryPhotoIds` are the photos the story shows, in order — what
|
||||
* `photos: all` expands to, minus the ids listed as explicit photo items.
|
||||
* `country` produces no pin: it snaps to a 1° cell, a hundred kilometres,
|
||||
* and on a story-sized map that is not a place. Two items at one (quantised)
|
||||
* position become one pin; first appearance wins the order.
|
||||
*/
|
||||
export function pinsForEntry(
|
||||
items: readonly MapItem[],
|
||||
assets: readonly GeoAsset[],
|
||||
entryPhotoIds: readonly string[],
|
||||
precisionOf: (assetId: string) => LocationPrecision,
|
||||
): MapPin[] {
|
||||
const byId = new Map(assets.map((a) => [a.id, a]));
|
||||
const explicitPhotoIds = new Set(
|
||||
items.flatMap((item) => (item.kind === 'photo' ? [item.assetId] : [])),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const pins: MapPin[] = [];
|
||||
|
||||
const push = (pin: MapPin) => {
|
||||
const key = `${pin.lat},${pin.lng}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
pins.push(pin);
|
||||
};
|
||||
|
||||
const photoPin = (id: string): MapPin | null => {
|
||||
const exif = byId.get(id)?.exifInfo;
|
||||
// `== null`, not a falsiness check: 0 is the equator or the prime meridian (#635).
|
||||
if (!exif || exif.latitude == null || exif.longitude == null) return null;
|
||||
|
||||
const level = precisionOf(id);
|
||||
if (level === 'hidden' || level === 'country') return null;
|
||||
|
||||
const quantised = applyPrecision({ lat: exif.latitude, lng: exif.longitude }, level);
|
||||
if (!quantised) return null;
|
||||
const pos =
|
||||
level === 'exact'
|
||||
? {
|
||||
lat: snapToGrid(quantised.lat, EXACT_STEP, EXACT_DECIMALS),
|
||||
lng: snapToGrid(quantised.lng, EXACT_STEP, EXACT_DECIMALS),
|
||||
}
|
||||
: quantised;
|
||||
|
||||
const place = placeLabel(level, { city: exif.city ?? '', country: exif.country ?? '' });
|
||||
const label = [place.city, place.country].filter(Boolean).join(', ');
|
||||
return label ? { lat: pos.lat, lng: pos.lng, label } : { lat: pos.lat, lng: pos.lng };
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind === 'point') {
|
||||
push(
|
||||
item.label
|
||||
? { lat: item.lat, lng: item.lng, label: item.label }
|
||||
: { lat: item.lat, lng: item.lng },
|
||||
);
|
||||
} else if (item.kind === 'photo') {
|
||||
const pin = photoPin(item.assetId);
|
||||
if (pin) push(pin);
|
||||
} else {
|
||||
for (const id of entryPhotoIds) {
|
||||
if (explicitPhotoIds.has(id)) continue;
|
||||
const pin = photoPin(id);
|
||||
if (pin) push(pin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pins;
|
||||
}
|
||||
|
||||
/** Server wrapper: each photo's precision resolved the way the EXIF route does. */
|
||||
export async function entryMapPins(
|
||||
items: readonly MapItem[],
|
||||
assets: readonly GeoAsset[],
|
||||
entryPhotoIds: readonly string[],
|
||||
): Promise<MapPin[]> {
|
||||
const wanted = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item.kind === 'photo') wanted.add(item.assetId);
|
||||
else if (item.kind === 'all-photos') for (const id of entryPhotoIds) wanted.add(id);
|
||||
}
|
||||
const ids = Array.from(wanted);
|
||||
const levels = await Promise.all(ids.map((id) => assetLocationPrecision(id)));
|
||||
const byId = new Map(ids.map((id, i) => [id, levels[i]]));
|
||||
return pinsForEntry(items, assets, entryPhotoIds, (id) => byId.get(id) ?? 'exact');
|
||||
}
|
||||
192
lib/journalTemplates.ts
Normal file
192
lib/journalTemplates.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Starting-point block structures offered in the Journal Studio's Create
|
||||
* modal, as an alternative to an empty editor. Client-safe (no `fs`), same
|
||||
* tier as `lib/journal.ts` — importable directly from `JournalStudio.tsx`.
|
||||
*
|
||||
* Every photo, pair and grid placeholder leaves its `assetId` as `''`:
|
||||
* `parseJournalMarkdown` round-trips that as an unfilled placeholder (see the
|
||||
* comment on the image regex in `lib/journal.ts`), and the block editor
|
||||
* already renders it as a "+ Pick" tile.
|
||||
*/
|
||||
|
||||
import type { JournalBlock } from './journal';
|
||||
|
||||
export interface JournalTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
blocks: JournalBlock[];
|
||||
}
|
||||
|
||||
type PhotoLayout = Extract<JournalBlock, { type: 'photo' }>['layout'];
|
||||
|
||||
// Captions and authors default to `undefined`, not `''`: that is what
|
||||
// `parseJournalMarkdown` produces for an empty `(...)` or a missing `-- author`,
|
||||
// so a template block must start that way too or the round-trip test sees a
|
||||
// reshaped block on first load.
|
||||
const photo = (layout: PhotoLayout, caption?: string): JournalBlock => ({
|
||||
type: 'photo',
|
||||
assetId: '',
|
||||
layout,
|
||||
caption: caption || undefined,
|
||||
});
|
||||
|
||||
const pair = (caption?: string): JournalBlock => ({
|
||||
type: 'photo-pair',
|
||||
assetIds: ['', ''],
|
||||
caption: caption || undefined,
|
||||
});
|
||||
|
||||
const grid = (count: number, caption?: string): JournalBlock => ({
|
||||
type: 'photo-grid',
|
||||
assetIds: Array.from({ length: count }, () => ''),
|
||||
caption: caption || undefined,
|
||||
});
|
||||
|
||||
// Quote text must not contain ` -- `: that is the author separator.
|
||||
const quote = (text: string, author?: string): JournalBlock => ({
|
||||
type: 'quote',
|
||||
text,
|
||||
author: author || undefined,
|
||||
});
|
||||
|
||||
// Values are placeholders too — an empty value would be dropped on save.
|
||||
const facts = (...rows: Array<[label: string, placeholder: string]>): JournalBlock => ({
|
||||
type: 'facts',
|
||||
items: rows.map(([label, value]) => ({ label, value })),
|
||||
});
|
||||
|
||||
// Templates opt into "every geotagged photo of the entry"; named stops are
|
||||
// added in the studio, where a typed point is a label and two numbers.
|
||||
const map = (caption?: string): JournalBlock => ({
|
||||
type: 'map',
|
||||
caption: caption || undefined,
|
||||
line: true,
|
||||
items: [{ kind: 'all-photos' }],
|
||||
});
|
||||
|
||||
// An album block with no album yet: the studio shows "Pick album".
|
||||
const album = (caption?: string): JournalBlock => ({
|
||||
type: 'album',
|
||||
albumId: '',
|
||||
layout: 'grid',
|
||||
...(caption ? { caption } : {}),
|
||||
});
|
||||
|
||||
const heading = (text: string, level = 2): JournalBlock => ({ type: 'heading', level, text });
|
||||
|
||||
const paragraph = (text: string): JournalBlock => ({ type: 'paragraph', html: text });
|
||||
|
||||
export const JOURNAL_TEMPLATES: JournalTemplate[] = [
|
||||
{
|
||||
id: 'wedding',
|
||||
name: 'Wedding',
|
||||
description:
|
||||
'Date and venue up front, one hero image, a line from the vows, the party as a grid.',
|
||||
blocks: [
|
||||
paragraph('[Who, where, and when — a sentence or two.]'),
|
||||
facts(['Date', '[The date]'], ['Venue', '[Where it took place]']),
|
||||
photo('fullbleed', '[The image that carries the whole day.]'),
|
||||
heading('Getting Ready'),
|
||||
pair('[Rings and dress, side by side.]'),
|
||||
heading('Ceremony'),
|
||||
photo('wide', '[The vows, the walk, the kiss.]'),
|
||||
quote('[The line from the day you want remembered.]', '[Who said it]'),
|
||||
heading('Reception'),
|
||||
grid(3, '[First dance, the speeches, the party.]'),
|
||||
paragraph('[A closing line — no need to wrap it up neatly.]'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hiking',
|
||||
name: 'Hiking / Outdoor',
|
||||
description: 'Distance and elevation as facts, the trail as a grid, the route on a map.',
|
||||
blocks: [
|
||||
paragraph('[Route and conditions — keep it factual.]'),
|
||||
facts(['Distance', '[21 km]'], ['Elevation', '[1,240 m]'], ['Duration', '[6 h]']),
|
||||
photo('fullbleed', '[The landscape that sets the scene.]'),
|
||||
heading('The Trail'),
|
||||
grid(3, '[Along the way.]'),
|
||||
photo('wide', '[The summit, or wherever it ended.]'),
|
||||
map('[The route, from the geotagged photos above.]'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'travel',
|
||||
name: 'Travel',
|
||||
description: 'The trip on a map, then one stop per section with two photos each.',
|
||||
blocks: [
|
||||
paragraph('[Where, and over what dates.]'),
|
||||
map('[Where the trip went, e.g. Busan → Seoul.]'),
|
||||
heading('[Day or place 1]'),
|
||||
paragraph('[A short note on this stop.]'),
|
||||
pair(),
|
||||
heading('[Day or place 2]'),
|
||||
paragraph('[A short note on this stop.]'),
|
||||
pair(),
|
||||
heading('[Day or place 3]'),
|
||||
paragraph('[A short note on this stop.]'),
|
||||
pair(),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'birthday',
|
||||
name: 'Birthday / Family',
|
||||
description: 'Almost no text — the whole album as a grid and one full-bleed frame.',
|
||||
blocks: [
|
||||
paragraph('[One personal sentence about the day.]'),
|
||||
album('[The day, as it happened.]'),
|
||||
photo('fullbleed', '[The one moment worth a full-bleed frame.]'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'portrait-session',
|
||||
name: 'Portrait Session',
|
||||
description: 'Solo wide frames with room to breathe, and a line in their own words.',
|
||||
blocks: [
|
||||
paragraph('[Who this session is with, and the idea behind it, in a sentence.]'),
|
||||
photo('wide'),
|
||||
quote('[Something they said during the session.]', '[Their name]'),
|
||||
photo('wide'),
|
||||
photo('wide'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'behind-the-scenes',
|
||||
name: 'Behind the Scenes',
|
||||
description: 'Setup and shoot as a pair, then the finished result to land on.',
|
||||
blocks: [
|
||||
paragraph('[What the assignment or setup was.]'),
|
||||
pair('[Setting up, and mid-shoot.]'),
|
||||
photo('contained', '[Another making-of moment.]'),
|
||||
heading('The Result'),
|
||||
photo('fullbleed', '[The finished shot.]'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pets',
|
||||
name: 'Pets',
|
||||
description: 'Candid pairs, one close-up portrait with personality.',
|
||||
blocks: [
|
||||
paragraph('[Who they are — name, and whatever makes them them.]'),
|
||||
pair(),
|
||||
photo('wide', '[A close-up — the one with personality.]'),
|
||||
pair(),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'new-series',
|
||||
name: 'New Series',
|
||||
description: 'Opens on a statement, then photo-and-text pairs introducing the project.',
|
||||
blocks: [
|
||||
quote('[One sentence on why this series exists.]'),
|
||||
photo('wide'),
|
||||
paragraph('[A line about this image.]'),
|
||||
photo('wide'),
|
||||
paragraph('[A line about this image.]'),
|
||||
photo('wide'),
|
||||
paragraph('[A line about this image.]'),
|
||||
paragraph('[More to come — or however this should close.]'),
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -66,6 +66,11 @@ export function quantiseCoordinate(value: number, level: LocationPrecision): num
|
||||
if (level === 'hidden') return 0;
|
||||
const { step, decimals } = GRID[level];
|
||||
if (step === 0) return value;
|
||||
return snapToGrid(value, step, decimals);
|
||||
}
|
||||
|
||||
/** Round to a fixed grid of `step` degrees; `decimals` trims the float tail. */
|
||||
export function snapToGrid(value: number, step: number, decimals: number): number {
|
||||
return Number((Math.round(value / step) * step).toFixed(decimals));
|
||||
}
|
||||
|
||||
|
||||
12
lib/urls.ts
12
lib/urls.ts
@@ -107,6 +107,18 @@ export function downloadUrl(albumId: string, assetId: string): string {
|
||||
return `/api/download/${encodeAssetId(albumId)}/${encodeAssetId(assetId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint that streams an album (or a selection of its assets) as a ZIP.
|
||||
*
|
||||
* Carries only the album: the route re-checks the allowlist, the `download`
|
||||
* opt-in and every password gate, exactly like the single-asset download. A
|
||||
* `GET` returns the whole album; a `POST` with `{ "assets": [<token>, …] }`
|
||||
* returns just those assets.
|
||||
*/
|
||||
export function archiveUrl(albumId: string): string {
|
||||
return `/api/download/${encodeAssetId(albumId)}/archive`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Immich asset description, trimmed, for use as image alt text.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user