@gramps-connect/story 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,21 +6,26 @@
6
6
  //
7
7
  // Needs Mantine: wrap it in a MantineProvider and load @mantine/core's
8
8
  // stylesheet, as with any Mantine component.
9
- import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
9
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
10
10
  import {
11
11
  ActionIcon, Badge, Box, Button, Divider, Group, Modal, Popover, Select, Stack, Text, TextInput, Tooltip, UnstyledButton,
12
12
  } from "@mantine/core";
13
13
  import { useMediaQuery } from "@mantine/hooks";
14
- import type { MediaItem, Slide, StoryDoc } from "../types";
14
+ import type { MediaItem, Slide, SlideMedia, StoryDoc } from "../types";
15
+ import { optimizeImage } from "../image";
15
16
  import { validate, type StoryIssue } from "../validate";
17
+ import { storyPlaces } from "../player/geo";
16
18
  import { StoryPlayer } from "../player/StoryPlayer";
17
19
  import type { PlayerStrings } from "../player/strings";
18
20
  import { suggestEdtf, type DateTextParser } from "./dates";
19
21
  import { DateField } from "./DateField";
20
22
  import {
21
- addSlide, deleteSlide, duplicateSlide, moveSlide, record, redo, startHistory, undo, updateSlide, updateStory,
22
- type History,
23
+ addImage, addSlide, deleteSlide, findMedia, duplicateSlide, moveSlide, record, redo, setSlidePhotos, startHistory, storyPlaceChoices,
24
+ undo, updateMedia, updateSlide, updateStory, type History, type NewImage,
23
25
  } from "./model";
26
+ import { PhotosField } from "./PhotosField";
27
+ import { PlaceField, type PlaceUpdate } from "./PlaceField";
28
+ import { WIKIDATA_PLACE_SEARCH, type PlaceSearch } from "./placeSearch";
24
29
  import { SlideList } from "./SlideList";
25
30
  import { StoryTextField } from "./StoryTextField";
26
31
  import { EDITOR_ENGLISH, type EditorStrings } from "./strings";
@@ -30,15 +35,19 @@ const BAR_HEIGHT = 56;
30
35
  const STORY = -1;
31
36
 
32
37
  export interface StoryEditorProps {
33
- /** The story to start from. Changing it later has no effect: remount
34
- * (a new `key`) to edit another. */
38
+ /** The story to start from, and its files (paths as in media `src`).
39
+ * Changing these later has no effect: remount (a new `key`) to edit
40
+ * another story. */
35
41
  initialStory: StoryDoc;
36
- /** The story's files, for checking that every media file is there. */
37
- files?: { has(path: string): boolean };
38
- /** As for StoryPlayer. */
39
- resolveMedia: (key: string, item: MediaItem) => Promise<string | null>;
40
- /** Called with the document after every change, undo and redo included. */
41
- onChange?: (doc: StoryDoc) => void;
42
+ initialFiles?: Map<string, Uint8Array>;
43
+ /** Called with the document and its files after every change, undo and
44
+ * redo included. The files only grow while editing (an undone photo's
45
+ * file stays); pruneMedia leaves out what nothing uses when saving. */
46
+ onChange?: (doc: StoryDoc, files: Map<string, Uint8Array>) => void;
47
+ /** Turns a chosen image file into what's stored. By default
48
+ * optimizeImage: 1600px WebP (JPEG where the browser can't), with the
49
+ * original's metadata left behind. */
50
+ prepareImage?: (file: Blob) => Promise<NewImage>;
42
51
  /** The host's own buttons (Open, Save, ...), at the right of the top bar. */
43
52
  toolbar?: ReactNode;
44
53
  /** Shown first in the top bar, e.g. ViewerBrand. */
@@ -48,6 +57,9 @@ export interface StoryEditorProps {
48
57
  playerStrings?: Partial<PlayerStrings>;
49
58
  /** Date text -> EDTF. By default gramps-date, in the story's language. */
50
59
  parseDateText?: DateTextParser;
60
+ /** Finding a place by name. By default Wikidata, and only when the
61
+ * person asks; null leaves the search out. */
62
+ placeSearch?: PlaceSearch | null;
51
63
  }
52
64
 
53
65
  /** The slide an issue's JSON Pointer is about, or STORY. */
@@ -56,7 +68,15 @@ function issueSlide(issue: StoryIssue): number {
56
68
  return match ? Number(match[1]) : STORY;
57
69
  }
58
70
 
59
- function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; resolveMedia: StoryEditorProps["resolveMedia"] }) {
71
+ type Resolve = (key: string, item: MediaItem) => Promise<string | null>;
72
+
73
+ /** The default prepareImage. */
74
+ export async function prepareImage(file: Blob): Promise<NewImage> {
75
+ const image = await optimizeImage(file);
76
+ return { data: new Uint8Array(await image.blob.arrayBuffer()), mime: image.mime, ext: image.ext, width: image.width, height: image.height };
77
+ }
78
+
79
+ function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; resolveMedia: Resolve }) {
60
80
  const ref = slide.media?.[0]?.ref;
61
81
  const item = ref && Object.prototype.hasOwnProperty.call(doc.media, ref) ? doc.media[ref] : undefined;
62
82
  const [url, setUrl] = useState<string | null>(null);
@@ -77,8 +97,8 @@ function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; reso
77
97
  }
78
98
 
79
99
  export function StoryEditor({
80
- initialStory, files, resolveMedia, onChange, toolbar, brand, dark = false, strings: stringOverrides, playerStrings,
81
- parseDateText = suggestEdtf,
100
+ initialStory, initialFiles, onChange, prepareImage: prepare = prepareImage, toolbar, brand, dark = false,
101
+ strings: stringOverrides, playerStrings, parseDateText = suggestEdtf, placeSearch = WIKIDATA_PLACE_SEARCH,
82
102
  }: StoryEditorProps) {
83
103
  const strings = useMemo(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
84
104
  const [history, setHistory] = useState<History>(() => startHistory(initialStory));
@@ -87,12 +107,39 @@ export function StoryEditor({
87
107
  const [problemsOpen, setProblemsOpen] = useState(false);
88
108
  const wide = useMediaQuery("(min-width: 1000px)", true);
89
109
  const doc = history.present;
110
+ const [files, setFiles] = useState(() => new Map(initialFiles ?? []));
111
+ const [busy, setBusy] = useState(false);
112
+ const [photoError, setPhotoError] = useState<string | null>(null);
113
+ // The latest of each, for work that finishes after a render (photos).
114
+ const docRef = useRef(doc);
115
+ docRef.current = doc;
116
+ const filesRef = useRef(files);
117
+ filesRef.current = files;
118
+
119
+ // A blob: URL per stored file, made once and released with the editor.
120
+ const urls = useRef(new Map<string, string>());
121
+ useEffect(() => () => {
122
+ for (const url of urls.current.values()) URL.revokeObjectURL(url);
123
+ }, []);
124
+ const resolveMedia = useCallback<Resolve>(async (_key, item) => {
125
+ const cached = urls.current.get(item.src);
126
+ if (cached) return cached;
127
+ const data = filesRef.current.get(item.src);
128
+ if (!data) return null;
129
+ const url = URL.createObjectURL(new Blob([data as Uint8Array<ArrayBuffer>], { type: item.mime }));
130
+ urls.current.set(item.src, url);
131
+ return url;
132
+ }, []);
133
+ const urlFor = useCallback((key: string) => {
134
+ const item = Object.prototype.hasOwnProperty.call(docRef.current.media, key) ? docRef.current.media[key] : undefined;
135
+ return item ? resolveMedia(key, item) : Promise.resolve(null);
136
+ }, [resolveMedia]);
90
137
 
91
138
  // Every change goes through here (and so into history and to the host).
92
139
  const apply = useCallback((next: StoryDoc, key?: string) => {
93
140
  setHistory((h) => record(h, next, key));
94
141
  }, []);
95
- useEffect(() => { onChange?.(doc); }, [doc, onChange]);
142
+ useEffect(() => { onChange?.(doc, files); }, [doc, files, onChange]);
96
143
 
97
144
  const index = selected === STORY ? STORY : Math.min(selected, doc.slides.length - 1);
98
145
  const slide = index === STORY ? undefined : doc.slides[index];
@@ -100,8 +147,77 @@ export function StoryEditor({
100
147
  if (index !== STORY) apply(updateSlide(history.present, index, patch), `${slide!.id}:${key}`);
101
148
  };
102
149
 
150
+ // A place change applies to the slide as it is by then: a name typed and
151
+ // a place picked in the same moment both land.
152
+ const setPlace = (slideId: string, update: PlaceUpdate, key: string) => {
153
+ setHistory((h) => {
154
+ const at = h.present.slides.findIndex((s) => s.id === slideId);
155
+ if (at < 0) return h;
156
+ const before = h.present.slides[at].place;
157
+ const after = update(before);
158
+ return after === before ? h : record(h, updateSlide(h.present, at, { place: after }), `${slideId}:${key}`);
159
+ });
160
+ };
161
+ const placeChoices = useMemo(() => storyPlaceChoices(doc, index), [doc, index]);
162
+ const around = useMemo(() => storyPlaces(doc.slides.map((s) => s.place))?.bounds ?? null, [doc.slides]);
163
+
103
164
  const issues = useMemo(() => validate(doc, files).errors, [doc, files]);
104
165
 
166
+ /** Prepares image files and puts them on a slide: added at the end, or
167
+ * in place of photo `replace` (keeping its caption and credit). */
168
+ const addPhotos = useCallback(async (chosen: File[], slideId: string, replace?: number) => {
169
+ setBusy(true);
170
+ setPhotoError(null);
171
+ // A file already in the story (chosen again) is used as it is;
172
+ // anything else is prepared first.
173
+ const chosenImages: (NewImage | string)[] = [];
174
+ for (const file of chosen) {
175
+ try {
176
+ const known = findMedia(docRef.current, filesRef.current, new Uint8Array(await file.arrayBuffer()));
177
+ chosenImages.push(known ?? await prepare(file));
178
+ } catch {
179
+ setPhotoError(strings.photoFailed(file.name || "the image"));
180
+ }
181
+ }
182
+ setBusy(false);
183
+ if (!chosenImages.length) return;
184
+ let next = docRef.current;
185
+ let nextFiles = filesRef.current;
186
+ const keys: string[] = [];
187
+ for (const image of chosenImages) {
188
+ if (typeof image === "string") {
189
+ keys.push(image);
190
+ continue;
191
+ }
192
+ const added = addImage(next, nextFiles, image);
193
+ next = added.doc;
194
+ nextFiles = added.files;
195
+ keys.push(added.key);
196
+ }
197
+ const at = next.slides.findIndex((s) => s.id === slideId);
198
+ if (at < 0) return;
199
+ const photos: SlideMedia[] = [...(next.slides[at].media ?? [])];
200
+ if (replace !== undefined && photos[replace]) photos[replace] = { ...photos[replace], ref: keys[0] };
201
+ else photos.push(...keys.map((ref) => ({ ref })));
202
+ setFiles(nextFiles);
203
+ apply(setSlidePhotos(next, at, photos));
204
+ }, [apply, prepare, strings]);
205
+
206
+ // Pasting an image (outside a text field) adds it to the slide.
207
+ const slideId = slide?.id;
208
+ useEffect(() => {
209
+ if (!slideId) return;
210
+ const onPaste = (e: ClipboardEvent) => {
211
+ if ((e.target as HTMLElement | null)?.closest("input, textarea, [contenteditable=true]")) return;
212
+ const images = [...(e.clipboardData?.files ?? [])].filter((f) => f.type.startsWith("image/"));
213
+ if (!images.length) return;
214
+ e.preventDefault();
215
+ void addPhotos(images, slideId);
216
+ };
217
+ window.addEventListener("paste", onPaste);
218
+ return () => window.removeEventListener("paste", onPaste);
219
+ }, [slideId, addPhotos]);
220
+
105
221
  // Undo/redo from the keyboard, except where a text field has its own.
106
222
  useEffect(() => {
107
223
  const onKey = (e: KeyboardEvent) => {
@@ -181,30 +297,17 @@ export function StoryEditor({
181
297
  label={strings.text} value={slide.text ?? ""} boldLabel={strings.bold} italicLabel={strings.italic} minRows={5}
182
298
  onChange={(text) => setSlide({ text: text || undefined }, "text")}
183
299
  />
184
- {slide.place && (
185
- <Stack gap={2}>
186
- <Text size="sm" fw={500}>{strings.place}</Text>
187
- <Text size="sm">
188
- {slide.place.name ?? ""}
189
- {slide.place.lat !== undefined && slide.place.lon !== undefined && (
190
- <Text span c="dimmed" size="sm"> {slide.place.lat.toFixed(4)}, {slide.place.lon.toFixed(4)}</Text>
191
- )}
192
- </Text>
193
- </Stack>
194
- )}
195
- {slide.media && slide.media.length > 0 && (
196
- <Stack gap={4}>
197
- <Text size="sm" fw={500}>{strings.photos}</Text>
198
- <Group gap="sm">
199
- {slide.media.map((m, i) => (
200
- <Group key={i} gap="xs" wrap="nowrap">
201
- <Thumb doc={doc} slide={{ id: "", media: [m] }} resolveMedia={resolveMedia} />
202
- <Text size="xs" c="dimmed" maw={200} lineClamp={2}>{m.caption ?? ""}</Text>
203
- </Group>
204
- ))}
205
- </Group>
206
- </Stack>
207
- )}
300
+ <PlaceField
301
+ place={slide.place} choices={placeChoices} around={around} language={doc.language} search={placeSearch} dark={dark} strings={strings}
302
+ onChange={(update, key) => setPlace(slide.id, update, key)}
303
+ />
304
+ <PhotosField
305
+ photos={slide.media ?? []} media={doc.media} urlFor={urlFor} strings={strings} busy={busy}
306
+ onChange={(photos, key) => apply(setSlidePhotos(doc, index, photos), `${slide.id}:${key}`)}
307
+ onFiles={(chosen, replace) => void addPhotos(chosen, slide.id, replace)}
308
+ onDescription={(key, alt) => apply(updateMedia(doc, key, { alt: alt || undefined }), `media:${key}:alt`)}
309
+ />
310
+ {photoError && <Text size="xs" c="red" role="alert">{photoError}</Text>}
208
311
  </Stack>
209
312
  );
210
313
 
@@ -1,10 +1,15 @@
1
- export { StoryEditor, type StoryEditorProps } from "./StoryEditor";
1
+ export { prepareImage, StoryEditor, type StoryEditorProps } from "./StoryEditor";
2
+ export { PhotosField, type PhotosFieldProps } from "./PhotosField";
3
+ export { PlaceField, type PlaceFieldProps, type PlaceUpdate } from "./PlaceField";
4
+ export { searchWikidataPlaces, wikidataPoint, WIKIDATA_PLACE_SEARCH, type PlaceSearch, type PlaceSearchResult } from "./placeSearch";
2
5
  export { StoryTextField, type StoryTextFieldProps } from "./StoryTextField";
3
6
  export { SlideList, type SlideListProps } from "./SlideList";
4
7
  export { DateField, type DateFieldProps } from "./DateField";
5
8
  export { suggestEdtf, type DateTextParser } from "./dates";
6
9
  export { EDITOR_ENGLISH, type EditorStrings } from "./strings";
7
10
  export {
8
- addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, moveSlide, nextSlideId, pruneMedia, record, redo,
9
- slideDate, startHistory, storyFileName, undo, updateSlide, updateStory, usedMedia, type History,
11
+ addImage, addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, findMedia, formatLatLon, moveSlide, nextSlideId, parseLatLon,
12
+ patchPlace, pruneMedia, record, redo, roundDegrees, setSlidePhotos, slideDate, startHistory, storyFileName, storyPlaceChoices, undo,
13
+ updateMedia, updateSlide, updateStory, usedMedia,
14
+ type History, type NewImage,
10
15
  } from "./model";
@@ -4,7 +4,7 @@
4
4
  // know (a newer minor version's, an extension's) ride along untouched:
5
5
  // opening and saving a story must never lose anything.
6
6
  import { FORMAT_ID, FORMAT_VERSION } from "../version";
7
- import type { Slide, StoryDate, StoryDoc } from "../types";
7
+ import type { MediaItem, Place, Slide, SlideMedia, StoryDate, StoryDoc } from "../types";
8
8
 
9
9
  /** A story with one empty slide: the smallest valid one. */
10
10
  export function blankStory(title: string, language?: string): StoryDoc {
@@ -100,6 +100,105 @@ export function slideDate(previous: StoryDate | undefined, text: string, edtf: s
100
100
  return next;
101
101
  }
102
102
 
103
+ /** A place with some fields set or (undefined) removed; one left with
104
+ * nothing in it is no place at all. Its shape and overlays, and any field
105
+ * this version doesn't know, stay as they were. */
106
+ export function patchPlace(place: Place | undefined, patch: Partial<Place>): Place | undefined {
107
+ if (place && Object.entries(patch).every(([key, value]) => place[key as keyof Place] === value)) return place;
108
+ const next = patched(place ?? {}, patch);
109
+ return Object.keys(next).length ? next : undefined;
110
+ }
111
+
112
+ /** Degrees to 5 decimals: about a metre, and no float noise. */
113
+ export const roundDegrees = (value: number) => Math.round(value * 1e5) / 1e5;
114
+
115
+ /** "47.3769, 8.5417" (or with a space or semicolon between) -> a point;
116
+ * null when that's not two numbers in range. */
117
+ export function parseLatLon(text: string): { lat: number; lon: number } | null {
118
+ const parts = text.trim().split(/\s*[,;\s]\s*/);
119
+ if (parts.length !== 2 || parts.some((p) => !/^[-+]?(\d+\.?\d*|\.\d+)$/.test(p))) return null;
120
+ const [lat, lon] = parts.map(Number);
121
+ if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
122
+ return { lat, lon };
123
+ }
124
+
125
+ export function formatLatLon(lat: number, lon: number): string {
126
+ return `${roundDegrees(lat)}, ${roundDegrees(lon)}`;
127
+ }
128
+
129
+ /** The places of the story's other slides, one per name (the first slide
130
+ * to use it), for giving another slide the very same place. */
131
+ export function storyPlaceChoices(doc: StoryDoc, exceptIndex?: number): Place[] {
132
+ const byName = new Map<string, Place>();
133
+ doc.slides.forEach((slide, i) => {
134
+ const name = slide.place?.name?.trim();
135
+ if (i !== exceptIndex && name && !byName.has(name)) byName.set(name, slide.place!);
136
+ });
137
+ return [...byName.values()];
138
+ }
139
+
140
+ /** A prepared image to add to a story (see optimizeImage). */
141
+ export interface NewImage {
142
+ data: Uint8Array;
143
+ mime: string;
144
+ /** File extension, without the dot. */
145
+ ext: string;
146
+ width?: number;
147
+ height?: number;
148
+ }
149
+
150
+ function sameBytes(a: Uint8Array | undefined, b: Uint8Array): boolean {
151
+ if (!a || a.length !== b.length) return false;
152
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
153
+ return true;
154
+ }
155
+
156
+ /** The media key whose file is exactly these bytes, if any: a photo
157
+ * that's already in the story, chosen again. */
158
+ export function findMedia(doc: StoryDoc, files: Map<string, Uint8Array>, data: Uint8Array): string | undefined {
159
+ for (const [key, item] of Object.entries(doc.media)) {
160
+ if (sameBytes(files.get(item.src), data)) return key;
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ /** Adds an image to the story's media (not to any slide), or finds the
166
+ * identical one already there, so the same photo is stored once. Returns
167
+ * its media key. */
168
+ export function addImage(
169
+ doc: StoryDoc, files: Map<string, Uint8Array>, image: NewImage, stem = "photo",
170
+ ): { doc: StoryDoc; files: Map<string, Uint8Array>; key: string } {
171
+ const existing = findMedia(doc, files, image.data);
172
+ if (existing) return { doc, files, key: existing };
173
+ let n = 1;
174
+ const taken = (key: string) => Object.prototype.hasOwnProperty.call(doc.media, key) || files.has(`media/${key}.${image.ext}`);
175
+ while (taken(`${stem}-${n}`)) n += 1;
176
+ const key = `${stem}-${n}`;
177
+ const src = `media/${key}.${image.ext}`;
178
+ const item: MediaItem = {
179
+ src, mime: image.mime,
180
+ ...(image.width ? { width: image.width } : {}),
181
+ ...(image.height ? { height: image.height } : {}),
182
+ };
183
+ return {
184
+ doc: { ...doc, media: { ...doc.media, [key]: item } },
185
+ files: new Map(files).set(src, image.data),
186
+ key,
187
+ };
188
+ }
189
+
190
+ /** A slide's photo list; an empty one removes the field. */
191
+ export function setSlidePhotos(doc: StoryDoc, index: number, media: SlideMedia[]): StoryDoc {
192
+ return updateSlide(doc, index, { media: media.length ? media : undefined });
193
+ }
194
+
195
+ /** Changes a media item itself (its description, shared by every slide
196
+ * that shows it). */
197
+ export function updateMedia(doc: StoryDoc, key: string, patch: Partial<MediaItem>): StoryDoc {
198
+ if (!Object.prototype.hasOwnProperty.call(doc.media, key)) return doc;
199
+ return { ...doc, media: { ...doc.media, [key]: patched(doc.media[key], patch) } };
200
+ }
201
+
103
202
  /** Every media key a slide or overlay refers to. */
104
203
  export function usedMedia(doc: StoryDoc): Set<string> {
105
204
  const used = new Set<string>();
@@ -0,0 +1,61 @@
1
+ // Finding a place by name, for the place field's opt-in search: Wikidata's
2
+ // own search, then the chosen item's coordinates (P625). Both endpoints
3
+ // allow any origin and need no key. This is the only thing the editor
4
+ // sends anywhere, and only when asked to: it sends the name typed.
5
+ // (Gramps Connect's place lookup walks the same API further, up the
6
+ // "located in" chain, to build a place hierarchy; a story place is just a
7
+ // name and a point.)
8
+
9
+ const WIKIDATA_API = "https://www.wikidata.org/w/api.php";
10
+ const WIKIDATA_ENTITY_DATA = "https://www.wikidata.org/wiki/Special:EntityData";
11
+
12
+ export interface PlaceSearchResult {
13
+ /** The Wikidata item, e.g. "Q72". */
14
+ id: string;
15
+ label: string;
16
+ /** Usually names the region and country, which tells same-named places
17
+ * apart. */
18
+ description?: string;
19
+ }
20
+
21
+ export interface PlaceSearch {
22
+ search: (term: string, language: string | undefined, signal?: AbortSignal) => Promise<PlaceSearchResult[]>;
23
+ /** The result's position, or null when it has none. */
24
+ point: (result: PlaceSearchResult, signal?: AbortSignal) => Promise<{ lat: number; lon: number } | null>;
25
+ }
26
+
27
+ /** "fr_CA" or "fr-CA" -> "fr-ca", Wikidata's form. */
28
+ function wikidataLanguage(language: string | undefined): string {
29
+ return (language ?? "").trim().toLowerCase().replace(/_/g, "-") || "en";
30
+ }
31
+
32
+ export async function searchWikidataPlaces(term: string, language?: string, signal?: AbortSignal): Promise<PlaceSearchResult[]> {
33
+ const code = wikidataLanguage(language);
34
+ const params = new URLSearchParams({
35
+ action: "wbsearchentities", search: term, language: code, uselang: code, format: "json", limit: "7",
36
+ origin: "*", // what makes an anonymous api.php GET answer cross-origin
37
+ });
38
+ const res = await fetch(`${WIKIDATA_API}?${params}`, { signal });
39
+ if (!res.ok) throw new Error(`Wikidata search failed (${res.status})`);
40
+ const data = (await res.json()) as { search?: { id: string; label?: string; description?: string }[] };
41
+ return (data.search ?? []).map((hit) => ({
42
+ id: hit.id,
43
+ label: hit.label ?? hit.id,
44
+ ...(hit.description ? { description: hit.description } : {}),
45
+ }));
46
+ }
47
+
48
+ export async function wikidataPoint(result: PlaceSearchResult, signal?: AbortSignal): Promise<{ lat: number; lon: number } | null> {
49
+ const res = await fetch(`${WIKIDATA_ENTITY_DATA}/${encodeURIComponent(result.id)}.json`, { signal });
50
+ if (!res.ok) throw new Error(`Wikidata fetch failed for ${result.id} (${res.status})`);
51
+ const data = (await res.json()) as {
52
+ entities?: Record<string, { claims?: Record<string, { mainsnak?: { datavalue?: { value?: unknown } } }[]> }>;
53
+ };
54
+ // A redirected item comes back under its new id: take whichever is there.
55
+ const entity = data.entities?.[result.id] ?? Object.values(data.entities ?? {})[0];
56
+ const value = entity?.claims?.P625?.[0]?.mainsnak?.datavalue?.value as { latitude?: number; longitude?: number } | undefined;
57
+ if (typeof value?.latitude !== "number" || typeof value.longitude !== "number") return null;
58
+ return { lat: value.latitude, lon: value.longitude };
59
+ }
60
+
61
+ export const WIKIDATA_PLACE_SEARCH: PlaceSearch = { search: searchWikidataPlaces, point: wikidataPoint };
@@ -32,7 +32,34 @@ export interface EditorStrings {
32
32
  matchDateAsWritten: string;
33
33
  noPreciseDate: string;
34
34
  place: string;
35
+ placeName: string;
36
+ removePlace: string;
37
+ findPlace: string;
38
+ findPlaceHint: string;
39
+ findPlaceNone: string;
40
+ findPlaceFailed: string;
41
+ findPlaceNoPoint: (name: string) => string;
42
+ coordinates: string;
43
+ coordinatesHint: string;
44
+ coordinatesInvalid: string;
45
+ placeMap: string;
46
+ placeMapHint: string;
47
+ placeShape: string;
48
+ placeOverlays: (count: number) => string;
49
+ placeKeeps: (what: string) => string;
35
50
  photos: string;
51
+ photosHint: string;
52
+ addPhoto: string;
53
+ replacePhoto: string;
54
+ movePhotoUp: string;
55
+ movePhotoDown: string;
56
+ removePhoto: string;
57
+ caption: string;
58
+ credit: string;
59
+ creditHint: string;
60
+ description: string;
61
+ descriptionHint: string;
62
+ photoFailed: (name: string) => string;
36
63
  undo: string;
37
64
  redo: string;
38
65
  present: string;
@@ -72,7 +99,34 @@ export const EDITOR_ENGLISH: EditorStrings = {
72
99
  matchDateAsWritten: "Work it out from the date",
73
100
  noPreciseDate: "None: this slide stays off the timeline.",
74
101
  place: "Place",
102
+ placeName: "Name",
103
+ removePlace: "Remove place",
104
+ findPlace: "Find on Wikidata",
105
+ findPlaceHint: "Looks the name up on Wikidata, to put the pin there. The name is all that's sent, and only when you ask.",
106
+ findPlaceNone: "Wikidata has no place by that name.",
107
+ findPlaceFailed: "Couldn't reach Wikidata.",
108
+ findPlaceNoPoint: (name) => `Wikidata has no map position for ${name}.`,
109
+ coordinates: "Latitude, longitude",
110
+ coordinatesHint: "e.g. 47.3769, 8.5417",
111
+ coordinatesInvalid: "Two numbers: latitude (-90 to 90), then longitude (-180 to 180).",
112
+ placeMap: "Map: click to put the pin",
113
+ placeMapHint: "Click the map to put the pin there, or drag the pin.",
114
+ placeShape: "a shape",
115
+ placeOverlays: (count) => (count === 1 ? "a map overlay" : `${count} map overlays`),
116
+ placeKeeps: (what) => `This place also has ${what}, kept as they are.`,
75
117
  photos: "Photos",
118
+ photosHint: "Add, drop or paste a photo. It's resized for the screen, and hidden details like camera and location are left out.",
119
+ addPhoto: "Add photo",
120
+ replacePhoto: "Replace",
121
+ movePhotoUp: "Move photo up",
122
+ movePhotoDown: "Move photo down",
123
+ removePhoto: "Remove",
124
+ caption: "Caption",
125
+ credit: "Credit",
126
+ creditHint: "Who took it, or where it's from",
127
+ description: "Description",
128
+ descriptionHint: "What the photo shows, for anyone who can't see it.",
129
+ photoFailed: (name) => `Couldn't use ${name}: this browser can't read it as an image.`,
76
130
  undo: "Undo",
77
131
  redo: "Redo",
78
132
  present: "Present",
@@ -114,15 +114,49 @@ function SlidePhoto({ mediaKey, item, caption, credit, resolveMedia, colors }: {
114
114
  );
115
115
  }
116
116
 
117
- function SlideContent({ slide, story, resolveMedia, colors }: {
117
+ /** A small button showing one of a slide's photos. */
118
+ function PhotoThumb({ mediaKey, item, label, selected, onSelect, resolveMedia, colors }: {
119
+ mediaKey: string;
120
+ item: MediaItem;
121
+ label: string;
122
+ selected: boolean;
123
+ onSelect: () => void;
124
+ resolveMedia: StoryPlayerProps["resolveMedia"];
125
+ colors: PlayerColors;
126
+ }) {
127
+ const [src, setSrc] = useState<string | null>(null);
128
+ useEffect(() => {
129
+ let cancelled = false;
130
+ resolveMedia(mediaKey, item).then((url) => { if (!cancelled) setSrc(url); }, () => {});
131
+ return () => { cancelled = true; };
132
+ }, [mediaKey, item, resolveMedia]);
133
+ return (
134
+ <button
135
+ type="button" aria-label={label} title={label} aria-pressed={selected} onClick={onSelect}
136
+ style={{
137
+ width: 44, height: 44, padding: 0, borderRadius: 4, overflow: "hidden", cursor: "pointer",
138
+ border: `2px solid ${selected ? colors.mark : "transparent"}`, background: colors.border,
139
+ opacity: selected ? 1 : 0.7,
140
+ }}
141
+ >
142
+ {src && <img src={src} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />}
143
+ </button>
144
+ );
145
+ }
146
+
147
+ function SlideContent({ slide, story, resolveMedia, colors, strings }: {
118
148
  slide: Slide | undefined;
119
149
  story: StoryDoc;
120
150
  resolveMedia: StoryPlayerProps["resolveMedia"];
121
151
  colors: PlayerColors;
152
+ strings: PlayerStrings;
122
153
  }) {
123
- // The format allows several photos per slide; the player shows the
124
- // first one that exists.
125
- const photo = slide?.media?.find((m) => Object.prototype.hasOwnProperty.call(story.media, m.ref));
154
+ // Every photo whose media exists; with several, thumbnails pick which
155
+ // one is shown. Keyed by slide in the caller, so each slide starts on
156
+ // its first photo.
157
+ const photos = (slide?.media ?? []).filter((m) => Object.prototype.hasOwnProperty.call(story.media, m.ref));
158
+ const [shown, setShown] = useState(0);
159
+ const photo = photos[Math.min(shown, photos.length - 1)];
126
160
  const eyebrow = [slide?.date?.text, slide?.place?.name].filter(Boolean).join(" · ");
127
161
  return (
128
162
  <>
@@ -136,6 +170,22 @@ function SlideContent({ slide, story, resolveMedia, colors }: {
136
170
  colors={colors}
137
171
  />
138
172
  )}
173
+ {photos.length > 1 && (
174
+ <div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "center" }}>
175
+ {photos.map((m, i) => (
176
+ <PhotoThumb
177
+ key={`${i}:${m.ref}`}
178
+ mediaKey={m.ref}
179
+ item={story.media[m.ref]}
180
+ label={strings.photoOf(i + 1, photos.length)}
181
+ selected={photo === m}
182
+ onSelect={() => setShown(i)}
183
+ resolveMedia={resolveMedia}
184
+ colors={colors}
185
+ />
186
+ ))}
187
+ </div>
188
+ )}
139
189
  <div style={{ display: "flex", flexDirection: "column", gap: 4, alignItems: "center", maxWidth: 640, textAlign: "center" }}>
140
190
  {eyebrow && <div dir="auto" style={{ fontSize: 14, color: colors.muted }}>{eyebrow}</div>}
141
191
  {slide?.title && <div dir="auto" style={{ fontSize: 20, fontWeight: 700, color: colors.text }}>{slide.title}</div>}
@@ -326,7 +376,9 @@ export function StoryPlayer({
326
376
  return item ? resolveMedia(key, item) : Promise.resolve(null);
327
377
  }, [story, resolveMedia]);
328
378
 
329
- const content = <SlideContent slide={slide} story={story} resolveMedia={resolveMedia} colors={colors} />;
379
+ const content = (
380
+ <SlideContent key={slide?.id ?? index} slide={slide} story={story} resolveMedia={resolveMedia} colors={colors} strings={strings} />
381
+ );
330
382
  const timeline = showTimeline && (
331
383
  <TimelineStrip points={datedSlides} currentIndex={index} onSelect={setIndex} colors={colors} />
332
384
  );
@@ -15,6 +15,8 @@ export interface PlayerStrings {
15
15
  historicalMapYear: (year: number) => string;
16
16
  noDateStandardMap: string;
17
17
  historicalYear: string;
18
+ /** A photo's thumbnail button, on a slide with several. */
19
+ photoOf: (n: number, total: number) => string;
18
20
  }
19
21
 
20
22
  export const ENGLISH: PlayerStrings = {
@@ -28,6 +30,7 @@ export const ENGLISH: PlayerStrings = {
28
30
  historicalMapYear: (year) => `Historical map, ${year}`,
29
31
  noDateStandardMap: "No date here — standard map",
30
32
  historicalYear: "Historical map year",
33
+ photoOf: (n, total) => `Photo ${n} of ${total}`,
31
34
  };
32
35
 
33
36
  const TABLE: Record<string, PlayerStrings> = {
@@ -43,6 +46,7 @@ const TABLE: Record<string, PlayerStrings> = {
43
46
  historicalMapYear: (year) => `Historische Karte, ${year}`,
44
47
  noDateStandardMap: "Kein Datum hier — Standardkarte",
45
48
  historicalYear: "Jahr der historischen Karte",
49
+ photoOf: (n, total) => `Foto ${n} von ${total}`,
46
50
  },
47
51
  es: {
48
52
  previous: "Anterior",
@@ -55,6 +59,7 @@ const TABLE: Record<string, PlayerStrings> = {
55
59
  historicalMapYear: (year) => `Mapa histórico, ${year}`,
56
60
  noDateStandardMap: "Sin fecha aquí — mapa estándar",
57
61
  historicalYear: "Año del mapa histórico",
62
+ photoOf: (n, total) => `Foto ${n} de ${total}`,
58
63
  },
59
64
  fr: {
60
65
  previous: "Précédent",
@@ -67,6 +72,7 @@ const TABLE: Record<string, PlayerStrings> = {
67
72
  historicalMapYear: (year) => `Carte historique, ${year}`,
68
73
  noDateStandardMap: "Pas de date ici — carte standard",
69
74
  historicalYear: "Année de la carte historique",
75
+ photoOf: (n, total) => `Photo ${n} sur ${total}`,
70
76
  },
71
77
  nl: {
72
78
  previous: "Vorige",
@@ -79,6 +85,7 @@ const TABLE: Record<string, PlayerStrings> = {
79
85
  historicalMapYear: (year) => `Historische kaart, ${year}`,
80
86
  noDateStandardMap: "Geen datum hier — standaardkaart",
81
87
  historicalYear: "Jaar van de historische kaart",
88
+ photoOf: (n, total) => `Foto ${n} van ${total}`,
82
89
  },
83
90
  };
84
91