@gramps-connect/story 0.1.3 → 0.3.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.
- package/README.md +51 -2
- package/dist/editor.js +3910 -0
- package/dist/editor.js.map +7 -0
- package/dist/embed.js +10 -10
- package/dist/player.js +80 -8
- package/dist/player.js.map +2 -2
- package/dist/types/editor/DateField.d.ts +12 -0
- package/dist/types/editor/PhotosField.d.ts +16 -0
- package/dist/types/editor/SlideList.d.ts +12 -0
- package/dist/types/editor/StoryEditor.d.ts +33 -0
- package/dist/types/editor/StoryTextField.d.ts +9 -0
- package/dist/types/editor/dates.d.ts +6 -0
- package/dist/types/editor/index.d.ts +8 -0
- package/dist/types/editor/model.d.ts +80 -0
- package/dist/types/editor/strings.d.ts +52 -0
- package/dist/types/player/StoryPlayer.d.ts +4 -1
- package/dist/types/player/strings.d.ts +2 -0
- package/package.json +21 -5
- package/src/editor/DateField.tsx +95 -0
- package/src/editor/PhotosField.tsx +134 -0
- package/src/editor/SlideList.tsx +54 -0
- package/src/editor/StoryEditor.tsx +423 -0
- package/src/editor/StoryTextField.tsx +61 -0
- package/src/editor/dates.ts +21 -0
- package/src/editor/index.ts +12 -0
- package/src/editor/model.ts +229 -0
- package/src/editor/strings.ts +106 -0
- package/src/player/StoryPlayer.tsx +71 -6
- package/src/player/strings.ts +7 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// The editor's changes to a story, as pure functions from one StoryDoc to
|
|
2
|
+
// the next -- never mutating, so undo is just keeping the old ones. Every
|
|
3
|
+
// change spreads the object it touches, so fields this version doesn't
|
|
4
|
+
// know (a newer minor version's, an extension's) ride along untouched:
|
|
5
|
+
// opening and saving a story must never lose anything.
|
|
6
|
+
import { FORMAT_ID, FORMAT_VERSION } from "../version";
|
|
7
|
+
import type { MediaItem, Slide, SlideMedia, StoryDate, StoryDoc } from "../types";
|
|
8
|
+
|
|
9
|
+
/** A story with one empty slide: the smallest valid one. */
|
|
10
|
+
export function blankStory(title: string, language?: string): StoryDoc {
|
|
11
|
+
return {
|
|
12
|
+
format: FORMAT_ID,
|
|
13
|
+
version: FORMAT_VERSION,
|
|
14
|
+
title,
|
|
15
|
+
...(language ? { language } : {}),
|
|
16
|
+
slides: [{ id: "s0", title }],
|
|
17
|
+
media: {},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** "s<n>" past every id already of that form, so it's unique however the
|
|
22
|
+
* story's other ids were made. */
|
|
23
|
+
export function nextSlideId(doc: StoryDoc): string {
|
|
24
|
+
let max = -1;
|
|
25
|
+
for (const slide of doc.slides) {
|
|
26
|
+
const match = /^s(\d+)$/.exec(slide.id);
|
|
27
|
+
if (match) max = Math.max(max, Number(match[1]));
|
|
28
|
+
}
|
|
29
|
+
let n = max + 1;
|
|
30
|
+
const ids = new Set(doc.slides.map((s) => s.id));
|
|
31
|
+
while (ids.has(`s${n}`)) n += 1;
|
|
32
|
+
return `s${n}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function withSlides(doc: StoryDoc, slides: Slide[]): StoryDoc {
|
|
36
|
+
return { ...doc, slides };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A new empty slide after `index`; returns the story and the new slide's
|
|
40
|
+
* index. */
|
|
41
|
+
export function addSlide(doc: StoryDoc, index: number): { doc: StoryDoc; index: number } {
|
|
42
|
+
const slides = [...doc.slides];
|
|
43
|
+
const at = Math.min(Math.max(index + 1, 0), slides.length);
|
|
44
|
+
slides.splice(at, 0, { id: nextSlideId(doc) });
|
|
45
|
+
return { doc: withSlides(doc, slides), index: at };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A copy of the slide right after it, with its own id. */
|
|
49
|
+
export function duplicateSlide(doc: StoryDoc, index: number): { doc: StoryDoc; index: number } {
|
|
50
|
+
const slides = [...doc.slides];
|
|
51
|
+
const copy = structuredClone(slides[index]);
|
|
52
|
+
copy.id = nextSlideId(doc);
|
|
53
|
+
slides.splice(index + 1, 0, copy);
|
|
54
|
+
return { doc: withSlides(doc, slides), index: index + 1 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Removes a slide, unless it's the only one (a story has at least one). */
|
|
58
|
+
export function deleteSlide(doc: StoryDoc, index: number): { doc: StoryDoc; index: number } {
|
|
59
|
+
if (doc.slides.length <= 1) return { doc, index };
|
|
60
|
+
const slides = doc.slides.filter((_, i) => i !== index);
|
|
61
|
+
return { doc: withSlides(doc, slides), index: Math.min(index, slides.length - 1) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function moveSlide(doc: StoryDoc, from: number, to: number): StoryDoc {
|
|
65
|
+
if (from === to || to < 0 || to >= doc.slides.length) return doc;
|
|
66
|
+
const slides = [...doc.slides];
|
|
67
|
+
const [slide] = slides.splice(from, 1);
|
|
68
|
+
slides.splice(to, 0, slide);
|
|
69
|
+
return withSlides(doc, slides);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Sets fields on an object; an undefined value removes the field, so
|
|
73
|
+
* cleared text doesn't linger as "". */
|
|
74
|
+
function patched<T extends object>(base: T, patch: Partial<T>): T {
|
|
75
|
+
const next = { ...base } as Record<string, unknown>;
|
|
76
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
77
|
+
if (value === undefined) delete next[key];
|
|
78
|
+
else next[key] = value;
|
|
79
|
+
}
|
|
80
|
+
return next as T;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function updateSlide(doc: StoryDoc, index: number, patch: Partial<Slide>): StoryDoc {
|
|
84
|
+
const slides = [...doc.slides];
|
|
85
|
+
slides[index] = patched(slides[index], patch);
|
|
86
|
+
return withSlides(doc, slides);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function updateStory(doc: StoryDoc, patch: Partial<Omit<StoryDoc, "slides" | "media">>): StoryDoc {
|
|
90
|
+
return patched(doc, patch as Partial<StoryDoc>);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A slide date from what was typed and its precise form. Empty text is no
|
|
94
|
+
* date at all. A new precise form drops a `sortYear` it would contradict
|
|
95
|
+
* (the timeline reads sortYear first); the date's other fields stay. */
|
|
96
|
+
export function slideDate(previous: StoryDate | undefined, text: string, edtf: string | undefined): StoryDate | undefined {
|
|
97
|
+
if (!text.trim()) return undefined;
|
|
98
|
+
const next = patched(previous ?? { text }, { text, edtf: edtf || undefined });
|
|
99
|
+
if (next.edtf !== previous?.edtf) delete next.sortYear;
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A prepared image to add to a story (see optimizeImage). */
|
|
104
|
+
export interface NewImage {
|
|
105
|
+
data: Uint8Array;
|
|
106
|
+
mime: string;
|
|
107
|
+
/** File extension, without the dot. */
|
|
108
|
+
ext: string;
|
|
109
|
+
width?: number;
|
|
110
|
+
height?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sameBytes(a: Uint8Array | undefined, b: Uint8Array): boolean {
|
|
114
|
+
if (!a || a.length !== b.length) return false;
|
|
115
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The media key whose file is exactly these bytes, if any: a photo
|
|
120
|
+
* that's already in the story, chosen again. */
|
|
121
|
+
export function findMedia(doc: StoryDoc, files: Map<string, Uint8Array>, data: Uint8Array): string | undefined {
|
|
122
|
+
for (const [key, item] of Object.entries(doc.media)) {
|
|
123
|
+
if (sameBytes(files.get(item.src), data)) return key;
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Adds an image to the story's media (not to any slide), or finds the
|
|
129
|
+
* identical one already there, so the same photo is stored once. Returns
|
|
130
|
+
* its media key. */
|
|
131
|
+
export function addImage(
|
|
132
|
+
doc: StoryDoc, files: Map<string, Uint8Array>, image: NewImage, stem = "photo",
|
|
133
|
+
): { doc: StoryDoc; files: Map<string, Uint8Array>; key: string } {
|
|
134
|
+
const existing = findMedia(doc, files, image.data);
|
|
135
|
+
if (existing) return { doc, files, key: existing };
|
|
136
|
+
let n = 1;
|
|
137
|
+
const taken = (key: string) => Object.prototype.hasOwnProperty.call(doc.media, key) || files.has(`media/${key}.${image.ext}`);
|
|
138
|
+
while (taken(`${stem}-${n}`)) n += 1;
|
|
139
|
+
const key = `${stem}-${n}`;
|
|
140
|
+
const src = `media/${key}.${image.ext}`;
|
|
141
|
+
const item: MediaItem = {
|
|
142
|
+
src, mime: image.mime,
|
|
143
|
+
...(image.width ? { width: image.width } : {}),
|
|
144
|
+
...(image.height ? { height: image.height } : {}),
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
doc: { ...doc, media: { ...doc.media, [key]: item } },
|
|
148
|
+
files: new Map(files).set(src, image.data),
|
|
149
|
+
key,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A slide's photo list; an empty one removes the field. */
|
|
154
|
+
export function setSlidePhotos(doc: StoryDoc, index: number, media: SlideMedia[]): StoryDoc {
|
|
155
|
+
return updateSlide(doc, index, { media: media.length ? media : undefined });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Changes a media item itself (its description, shared by every slide
|
|
159
|
+
* that shows it). */
|
|
160
|
+
export function updateMedia(doc: StoryDoc, key: string, patch: Partial<MediaItem>): StoryDoc {
|
|
161
|
+
if (!Object.prototype.hasOwnProperty.call(doc.media, key)) return doc;
|
|
162
|
+
return { ...doc, media: { ...doc.media, [key]: patched(doc.media[key], patch) } };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Every media key a slide or overlay refers to. */
|
|
166
|
+
export function usedMedia(doc: StoryDoc): Set<string> {
|
|
167
|
+
const used = new Set<string>();
|
|
168
|
+
for (const slide of doc.slides) {
|
|
169
|
+
for (const m of slide.media ?? []) used.add(m.ref);
|
|
170
|
+
for (const o of slide.place?.overlays ?? []) used.add(o.media);
|
|
171
|
+
}
|
|
172
|
+
return used;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The story as it should be saved: media nothing refers to any more left
|
|
176
|
+
* out, and the files only those that are still used. */
|
|
177
|
+
export function pruneMedia(doc: StoryDoc, files: Map<string, Uint8Array>): { doc: StoryDoc; files: Map<string, Uint8Array> } {
|
|
178
|
+
const used = usedMedia(doc);
|
|
179
|
+
const media = Object.fromEntries(Object.entries(doc.media).filter(([key]) => used.has(key)));
|
|
180
|
+
const srcs = new Set(Object.values(media).map((m) => m.src));
|
|
181
|
+
return {
|
|
182
|
+
doc: { ...doc, media },
|
|
183
|
+
files: new Map([...files].filter(([path]) => srcs.has(path))),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** "Josef Meyer" -> "Josef Meyer.story.zip", minus characters some file
|
|
188
|
+
* systems refuse. */
|
|
189
|
+
export function storyFileName(title: string): string {
|
|
190
|
+
const safe = title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
191
|
+
return `${safe || "story"}.story.zip`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Undo and redo over whole documents. Typing in one field makes one step,
|
|
195
|
+
* not one per keystroke: a change with the same `key` within COALESCE_MS
|
|
196
|
+
* of the last replaces it. */
|
|
197
|
+
export interface History {
|
|
198
|
+
past: StoryDoc[];
|
|
199
|
+
present: StoryDoc;
|
|
200
|
+
future: StoryDoc[];
|
|
201
|
+
lastKey?: string;
|
|
202
|
+
lastAt?: number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const COALESCE_MS = 1500;
|
|
206
|
+
const MAX_STEPS = 200;
|
|
207
|
+
|
|
208
|
+
export function startHistory(doc: StoryDoc): History {
|
|
209
|
+
return { past: [], present: doc, future: [] };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function record(history: History, next: StoryDoc, key?: string, now = Date.now()): History {
|
|
213
|
+
if (next === history.present) return history;
|
|
214
|
+
const coalesce = key !== undefined && key === history.lastKey && history.lastAt !== undefined && now - history.lastAt < COALESCE_MS;
|
|
215
|
+
const past = coalesce ? history.past : [...history.past, history.present].slice(-MAX_STEPS);
|
|
216
|
+
return { past, present: next, future: [], lastKey: key, lastAt: now };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function undo(history: History): History {
|
|
220
|
+
const previous = history.past[history.past.length - 1];
|
|
221
|
+
if (!previous) return history;
|
|
222
|
+
return { past: history.past.slice(0, -1), present: previous, future: [history.present, ...history.future] };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function redo(history: History): History {
|
|
226
|
+
const [next, ...future] = history.future;
|
|
227
|
+
if (!next) return history;
|
|
228
|
+
return { past: [...history.past, history.present], present: next, future };
|
|
229
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The editor's own words. English here; a host with its own translations
|
|
2
|
+
// (Gramps Connect) passes StoryEditor's `strings` instead.
|
|
3
|
+
|
|
4
|
+
export interface EditorStrings {
|
|
5
|
+
story: string;
|
|
6
|
+
storySettings: string;
|
|
7
|
+
title: string;
|
|
8
|
+
language: string;
|
|
9
|
+
languageHint: string;
|
|
10
|
+
map: string;
|
|
11
|
+
mapStandard: string;
|
|
12
|
+
mapHistorical: string;
|
|
13
|
+
mapAuto: string;
|
|
14
|
+
mapNone: string;
|
|
15
|
+
slides: string;
|
|
16
|
+
untitledSlide: string;
|
|
17
|
+
addSlide: string;
|
|
18
|
+
duplicateSlide: string;
|
|
19
|
+
deleteSlide: string;
|
|
20
|
+
moveUp: string;
|
|
21
|
+
moveDown: string;
|
|
22
|
+
slideTitle: string;
|
|
23
|
+
text: string;
|
|
24
|
+
bold: string;
|
|
25
|
+
italic: string;
|
|
26
|
+
date: string;
|
|
27
|
+
dateHint: string;
|
|
28
|
+
preciseDate: string;
|
|
29
|
+
preciseDateHint: string;
|
|
30
|
+
preciseDateFollows: string;
|
|
31
|
+
preciseDateNotUnderstood: string;
|
|
32
|
+
matchDateAsWritten: string;
|
|
33
|
+
noPreciseDate: string;
|
|
34
|
+
place: string;
|
|
35
|
+
photos: string;
|
|
36
|
+
photosHint: string;
|
|
37
|
+
addPhoto: string;
|
|
38
|
+
replacePhoto: string;
|
|
39
|
+
movePhotoUp: string;
|
|
40
|
+
movePhotoDown: string;
|
|
41
|
+
removePhoto: string;
|
|
42
|
+
caption: string;
|
|
43
|
+
credit: string;
|
|
44
|
+
creditHint: string;
|
|
45
|
+
description: string;
|
|
46
|
+
descriptionHint: string;
|
|
47
|
+
photoFailed: (name: string) => string;
|
|
48
|
+
undo: string;
|
|
49
|
+
redo: string;
|
|
50
|
+
present: string;
|
|
51
|
+
preview: string;
|
|
52
|
+
problems: (count: number) => string;
|
|
53
|
+
openingSlide: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const EDITOR_ENGLISH: EditorStrings = {
|
|
57
|
+
story: "Story",
|
|
58
|
+
storySettings: "Title, language and map",
|
|
59
|
+
title: "Title",
|
|
60
|
+
language: "Language",
|
|
61
|
+
languageHint: "The language the story is written in, e.g. en, de or fr-CA. Dates are read in it.",
|
|
62
|
+
map: "Map",
|
|
63
|
+
mapStandard: "Today's map",
|
|
64
|
+
mapHistorical: "Historical maps for each slide's year",
|
|
65
|
+
mapAuto: "Viewer's choice",
|
|
66
|
+
mapNone: "No map",
|
|
67
|
+
slides: "Slides",
|
|
68
|
+
untitledSlide: "Untitled slide",
|
|
69
|
+
addSlide: "Add slide",
|
|
70
|
+
duplicateSlide: "Duplicate",
|
|
71
|
+
deleteSlide: "Delete",
|
|
72
|
+
moveUp: "Move up",
|
|
73
|
+
moveDown: "Move down",
|
|
74
|
+
slideTitle: "Title",
|
|
75
|
+
text: "Text",
|
|
76
|
+
bold: "Bold",
|
|
77
|
+
italic: "Italic",
|
|
78
|
+
date: "Date",
|
|
79
|
+
dateHint: "As readers should see it, e.g. about 1850, 3 May 1850, between 1850 and 1860.",
|
|
80
|
+
preciseDate: "Precise date (EDTF)",
|
|
81
|
+
preciseDateHint: "Places the slide on the timeline and picks historical maps.",
|
|
82
|
+
preciseDateFollows: "Worked out from the date as written.",
|
|
83
|
+
preciseDateNotUnderstood: "Not a date the timeline can use.",
|
|
84
|
+
matchDateAsWritten: "Work it out from the date",
|
|
85
|
+
noPreciseDate: "None: this slide stays off the timeline.",
|
|
86
|
+
place: "Place",
|
|
87
|
+
photos: "Photos",
|
|
88
|
+
photosHint: "Add, drop or paste a photo. It's resized for the screen, and hidden details like camera and location are left out.",
|
|
89
|
+
addPhoto: "Add photo",
|
|
90
|
+
replacePhoto: "Replace",
|
|
91
|
+
movePhotoUp: "Move photo up",
|
|
92
|
+
movePhotoDown: "Move photo down",
|
|
93
|
+
removePhoto: "Remove",
|
|
94
|
+
caption: "Caption",
|
|
95
|
+
credit: "Credit",
|
|
96
|
+
creditHint: "Who took it, or where it's from",
|
|
97
|
+
description: "Description",
|
|
98
|
+
descriptionHint: "What the photo shows, for anyone who can't see it.",
|
|
99
|
+
photoFailed: (name) => `Couldn't use ${name}: this browser can't read it as an image.`,
|
|
100
|
+
undo: "Undo",
|
|
101
|
+
redo: "Redo",
|
|
102
|
+
present: "Present",
|
|
103
|
+
preview: "Preview",
|
|
104
|
+
problems: (count) => (count === 1 ? "1 problem to fix before saving" : `${count} problems to fix before saving`),
|
|
105
|
+
openingSlide: "Opening slide",
|
|
106
|
+
};
|
|
@@ -44,6 +44,9 @@ export interface StoryPlayerProps {
|
|
|
44
44
|
renderMapModeControl?: (props: MapModeControlProps) => ReactNode;
|
|
45
45
|
/** Shown before the title, e.g. ViewerBrand on a standalone page. */
|
|
46
46
|
brand?: ReactNode;
|
|
47
|
+
/** The host's own buttons, at the right of the title bar (before the
|
|
48
|
+
* close button), e.g. the viewer site's Edit. */
|
|
49
|
+
actions?: ReactNode;
|
|
47
50
|
initialSlide?: number;
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -111,15 +114,49 @@ function SlidePhoto({ mediaKey, item, caption, credit, resolveMedia, colors }: {
|
|
|
111
114
|
);
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
|
|
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 }: {
|
|
115
148
|
slide: Slide | undefined;
|
|
116
149
|
story: StoryDoc;
|
|
117
150
|
resolveMedia: StoryPlayerProps["resolveMedia"];
|
|
118
151
|
colors: PlayerColors;
|
|
152
|
+
strings: PlayerStrings;
|
|
119
153
|
}) {
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
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)];
|
|
123
160
|
const eyebrow = [slide?.date?.text, slide?.place?.name].filter(Boolean).join(" · ");
|
|
124
161
|
return (
|
|
125
162
|
<>
|
|
@@ -133,6 +170,22 @@ function SlideContent({ slide, story, resolveMedia, colors }: {
|
|
|
133
170
|
colors={colors}
|
|
134
171
|
/>
|
|
135
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
|
+
)}
|
|
136
189
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, alignItems: "center", maxWidth: 640, textAlign: "center" }}>
|
|
137
190
|
{eyebrow && <div dir="auto" style={{ fontSize: 14, color: colors.muted }}>{eyebrow}</div>}
|
|
138
191
|
{slide?.title && <div dir="auto" style={{ fontSize: 20, fontWeight: 700, color: colors.text }}>{slide.title}</div>}
|
|
@@ -201,7 +254,7 @@ function NavArrows({ index, total, onPrev, onNext, variant, strings, colors }: {
|
|
|
201
254
|
|
|
202
255
|
export function StoryPlayer({
|
|
203
256
|
story, resolveMedia, dark = false, colors: colorOverrides, strings: stringOverrides, onClose,
|
|
204
|
-
keyboard = true, onSlideChange, renderMapModeControl, brand, initialSlide = 0,
|
|
257
|
+
keyboard = true, onSlideChange, renderMapModeControl, brand, actions, initialSlide = 0,
|
|
205
258
|
}: StoryPlayerProps) {
|
|
206
259
|
const colors = useMemo(() => ({ ...defaultColors(dark), ...colorOverrides }), [dark, colorOverrides]);
|
|
207
260
|
const strings = useMemo(
|
|
@@ -323,7 +376,9 @@ export function StoryPlayer({
|
|
|
323
376
|
return item ? resolveMedia(key, item) : Promise.resolve(null);
|
|
324
377
|
}, [story, resolveMedia]);
|
|
325
378
|
|
|
326
|
-
const content =
|
|
379
|
+
const content = (
|
|
380
|
+
<SlideContent key={slide?.id ?? index} slide={slide} story={story} resolveMedia={resolveMedia} colors={colors} strings={strings} />
|
|
381
|
+
);
|
|
327
382
|
const timeline = showTimeline && (
|
|
328
383
|
<TimelineStrip points={datedSlides} currentIndex={index} onSelect={setIndex} colors={colors} />
|
|
329
384
|
);
|
|
@@ -371,6 +426,16 @@ export function StoryPlayer({
|
|
|
371
426
|
</div>
|
|
372
427
|
</div>
|
|
373
428
|
)}
|
|
429
|
+
{actions && (
|
|
430
|
+
<div
|
|
431
|
+
style={{
|
|
432
|
+
position: "absolute", top: 0, right: onClose ? 60 : 12, height: BAR_HEIGHT, zIndex: 3,
|
|
433
|
+
display: "flex", alignItems: "center", gap: 8,
|
|
434
|
+
}}
|
|
435
|
+
>
|
|
436
|
+
{actions}
|
|
437
|
+
</div>
|
|
438
|
+
)}
|
|
374
439
|
{onClose && (
|
|
375
440
|
<RoundButton
|
|
376
441
|
label={strings.close} size={36} onClick={onClose}
|
package/src/player/strings.ts
CHANGED
|
@@ -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
|
|