Skip to content

Commit 58e8078

Browse files
committed
Merge remote-tracking branch 'origin/dev' into feat/library-mcp
# Conflicts: # CLAUDE.md
2 parents 04210d3 + 1fc947d commit 58e8078

17 files changed

Lines changed: 1451 additions & 527 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 455 deletions
Large diffs are not rendered by default.

LIBRARY.md

Lines changed: 516 additions & 0 deletions
Large diffs are not rendered by default.

public/robots.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,6 @@ Disallow: /hy/career-path-of-a-manager-and-a-few-universal-tips/
2323
Disallow: /hy/overengineering_and_demo_readiness/
2424
Disallow: /articles/[page]
2525
Disallow: /uxcore_next/
26+
27+
Sitemap: https://strapi.keepsimple.io/sitemap/index.xml
28+
Sitemap: https://keepsimple.io/library-sitemap.xml
Lines changed: 9 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,12 @@
1-
import axios from 'axios';
1+
import { getPublicLibraryView } from './getPublicLibraryView';
22

3-
import type {
4-
StrapiLibrariesResponse,
5-
StrapiSingleLibraryResponse,
6-
} from '@local-types/library/library';
7-
8-
import { librarySeo } from '@lib/library/seo';
9-
10-
/** Crawler data is always fetched anonymously, including on owner requests. */
3+
/**
4+
* Crawler data is always fetched anonymously, including on owner requests.
5+
*
6+
* One read stands behind this and behind the page's own first paint: see
7+
* `getPublicLibraryView`. Kept as its own name because the share thumbnail
8+
* wants the metadata and nothing else.
9+
*/
1110
export async function getPublicLibrarySeo(username: string) {
12-
const client = axios.create({
13-
baseURL: process.env.NEXT_PUBLIC_STRAPI,
14-
timeout: 5000,
15-
});
16-
let page = 1;
17-
let pageCount = 1;
18-
do {
19-
const { data } = await client.get<StrapiLibrariesResponse>(
20-
'/api/libraries',
21-
{
22-
params: {
23-
'pagination[page]': page,
24-
'pagination[pageSize]': 100,
25-
'sort[0]': 'id:asc',
26-
},
27-
},
28-
);
29-
const entry = data.data.find(
30-
item =>
31-
item.attributes.user?.data?.attributes.username?.toLowerCase() ===
32-
username.toLowerCase(),
33-
);
34-
if (entry) {
35-
const response = await client.get<StrapiSingleLibraryResponse>(
36-
`/api/libraries/${entry.id}`,
37-
{
38-
params: {
39-
'populate[user]': true,
40-
'populate[libraryDetails]': true,
41-
'populate[singleShelves][populate][objects]': true,
42-
},
43-
},
44-
);
45-
return librarySeo(response.data.data);
46-
}
47-
pageCount = data.meta.pagination.pageCount;
48-
page++;
49-
} while (page <= pageCount);
50-
return librarySeo();
11+
return (await getPublicLibraryView(username)).seo;
5112
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import axios from 'axios';
2+
3+
import type {
4+
StrapiLibrariesResponse,
5+
StrapiLibraryEntry,
6+
StrapiSingleLibraryResponse,
7+
} from '@local-types/library/library';
8+
import type { IObject } from '@local-types/library/object';
9+
import type { LibraryTag } from '@local-types/library/tag';
10+
11+
import { librarySeo } from '@lib/library/seo';
12+
13+
/**
14+
* The library as a reader arrives at it, read on the server.
15+
*
16+
* A library used to reach the browser as a shell: the title, the description
17+
* and the schema.org list were in the first response, and every word a person
18+
* (or a crawler that does not run scripts) could actually read was fetched
19+
* afterwards by script. This is the same read the page makes, made once, on
20+
* the request, so the shelves, the books and the About text are in the HTML
21+
* that leaves the server.
22+
*
23+
* Always anonymous, including on an owner's own request: what the server
24+
* paints is what a visitor sees, and the owner's own view arrives a moment
25+
* later from their own session. A private shelf therefore never reaches the
26+
* page source.
27+
*/
28+
29+
export interface PublicLibraryView {
30+
seo: ReturnType<typeof librarySeo>;
31+
/** The library trimmed to what the first paint draws, or null. */
32+
library: StrapiLibraryEntry | null;
33+
/** The library's tags, as the right panel lists them. */
34+
tags: LibraryTag[];
35+
}
36+
37+
const client = () =>
38+
axios.create({
39+
baseURL: process.env.NEXT_PUBLIC_STRAPI,
40+
timeout: 5000,
41+
});
42+
43+
const listPopulate = {
44+
'populate[avatar]': true,
45+
'populate[user]': true,
46+
'populate[libraryDetails]': true,
47+
'populate[singleShelves][populate][objects][populate][coverImage]': true,
48+
'populate[singleShelves][populate][objects][populate][tags]': true,
49+
'populate[singleShelves][sort][0]': 'order:asc',
50+
'populate[singleShelves][populate][objects][sort][0]': 'order:asc',
51+
};
52+
53+
/**
54+
* A cover carries every rendition Strapi cut for it, which is six sizes of
55+
* metadata the page never reads: the card asks for `url` and nothing else.
56+
* Dropping the rest keeps a 165-book library from shipping a third of a
57+
* megabyte of image bookkeeping in the page source.
58+
*/
59+
const trimCover = (cover: IObject['attributes']['coverImage']) => {
60+
const media = cover?.data;
61+
62+
if (!media) return cover ?? { data: null };
63+
64+
const { url, name, mime, size } = media.attributes;
65+
66+
return { data: { id: media.id, attributes: { url, name, mime, size } } };
67+
};
68+
69+
/**
70+
* Notes are the longest thing a library holds and the shelves do not draw
71+
* them: a note is read on the book's own page, which is where it is written
72+
* into the HTML in full. The one book the URL names keeps its note here so
73+
* that page is complete on arrival.
74+
*/
75+
const trimObject = (object: IObject, keepNoteFor: number | null): IObject => {
76+
// The key is dropped rather than emptied: Next refuses to serialize an
77+
// `undefined` into the page's own data, and an empty string would read as a
78+
// note the owner had cleared.
79+
const { description, ...attributes } = object.attributes;
80+
81+
return {
82+
...object,
83+
attributes: {
84+
...attributes,
85+
...(object.id === keepNoteFor && description !== undefined
86+
? { description }
87+
: {}),
88+
coverImage: trimCover(object.attributes.coverImage),
89+
},
90+
};
91+
};
92+
93+
const forFirstPaint = (
94+
entry: StrapiLibraryEntry,
95+
keepNoteFor: number | null,
96+
): StrapiLibraryEntry => ({
97+
...entry,
98+
attributes: {
99+
...entry.attributes,
100+
singleShelves: {
101+
data: (entry.attributes.singleShelves?.data ?? [])
102+
// A visitor's reading of the shelf list, decided here rather than in
103+
// the components: a private shelf must not travel to the browser at
104+
// all, drawn or not.
105+
.filter(shelf => shelf.attributes.visibility !== 'private')
106+
.map(shelf => ({
107+
...shelf,
108+
attributes: {
109+
...shelf.attributes,
110+
objects: {
111+
data: (shelf.attributes.objects?.data ?? []).map(object =>
112+
trimObject(object, keepNoteFor),
113+
),
114+
},
115+
},
116+
})),
117+
},
118+
},
119+
});
120+
121+
/** The numeric id of the library a username owns, or null. */
122+
const findLibraryId = async (username: string): Promise<number | null> => {
123+
const wanted = username.trim().toLowerCase();
124+
125+
if (!wanted) return null;
126+
127+
let page = 1;
128+
let pageCount = 1;
129+
130+
do {
131+
const { data } = await client().get<StrapiLibrariesResponse>(
132+
'/api/libraries',
133+
{
134+
params: {
135+
'pagination[page]': page,
136+
'pagination[pageSize]': 100,
137+
'sort[0]': 'id:asc',
138+
},
139+
},
140+
);
141+
142+
const entry = data.data.find(
143+
item =>
144+
item.attributes.user?.data?.attributes.username?.toLowerCase() ===
145+
wanted,
146+
);
147+
148+
if (entry) return entry.id;
149+
150+
pageCount = data.meta.pagination.pageCount;
151+
page += 1;
152+
} while (page <= pageCount);
153+
154+
return null;
155+
};
156+
157+
export async function getPublicLibraryView(
158+
username: string,
159+
/** The object the URL names, so its note travels with the first paint. */
160+
keepNoteFor: number | null = null,
161+
): Promise<PublicLibraryView> {
162+
const id = await findLibraryId(username);
163+
164+
if (id == null) return { seo: librarySeo(), library: null, tags: [] };
165+
166+
const { data } = await client().get<StrapiSingleLibraryResponse>(
167+
`/api/libraries/${id}`,
168+
{ params: listPopulate },
169+
);
170+
171+
const entry = data.data;
172+
173+
if (!entry) return { seo: librarySeo(), library: null, tags: [] };
174+
175+
const seo = librarySeo(entry);
176+
const library = forFirstPaint(entry, keepNoteFor);
177+
178+
let tags: LibraryTag[] = [];
179+
180+
try {
181+
const answer = await client().get<{
182+
data: {
183+
id: number;
184+
attributes: {
185+
name: string;
186+
color: string;
187+
description?: string;
188+
slug: string;
189+
objects?: number[];
190+
};
191+
}[];
192+
}>('/api/tags', { params: { libraryId: id } });
193+
194+
tags = (answer.data?.data ?? []).map(tag => ({
195+
id: tag.id,
196+
name: tag.attributes.name,
197+
color: tag.attributes.color,
198+
// Omitted rather than undefined: the page's own data is JSON.
199+
...(tag.attributes.description
200+
? { description: tag.attributes.description }
201+
: {}),
202+
slug: tag.attributes.slug,
203+
objects: tag.attributes.objects ?? [],
204+
}));
205+
} catch {
206+
// The shelves stand without the filter panel; the browser re-reads it.
207+
console.error('Library tags unavailable');
208+
}
209+
210+
return { seo, library, tags };
211+
}

src/components/Context/library/DashboardContext.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,23 @@ const DashboardContext = createContext<DashboardContextValue | undefined>(
4747

4848
interface DashboardProviderProps {
4949
children: ReactNode;
50+
/**
51+
* The tags as the server read them for this request, anonymously. Without
52+
* them the panel's first HTML says `No tags yet` to a reader who runs no
53+
* scripts, whatever the library actually holds.
54+
*/
55+
initialTags?: LibraryTag[];
5056
}
5157

52-
export function DashboardProvider({ children }: DashboardProviderProps) {
58+
export function DashboardProvider({
59+
children,
60+
initialTags = [],
61+
}: DashboardProviderProps) {
5362
const { token } = useAuth();
5463
const { currentLibrary } = useGlobalState();
5564
const libraryId = currentLibrary?.id ?? null;
5665

57-
const [libraryTags, setLibraryTags] = useState<LibraryTag[]>([]);
66+
const [libraryTags, setLibraryTags] = useState<LibraryTag[]>(initialTags);
5867
const [activeTagId, setActiveTagId] = useState<number | null>(null);
5968

6069
const refreshLibraryTags = useCallback(async () => {

src/components/Context/library/GlobalStateContext.tsx

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,41 @@ interface GlobalStateProviderProps {
9898
* and a refresh never shows it open for a frame before folding.
9999
*/
100100
initialSidebarCollapsed?: boolean;
101+
/**
102+
* The library as the server read it, anonymously, for this request. The
103+
* panel's About, counts and Author are published here by `LibraryTemplate`
104+
* after it loads, which is a paint too late for a reader who runs no
105+
* scripts: seeded here, the first HTML already carries them.
106+
*/
107+
initialLibrary?: ILibrary | null;
101108
}
102109

110+
/** The panel's reading of the owner, from the same fields the template uses. */
111+
const ownerOf = (library: ILibrary | null): LibraryOwner | null => {
112+
if (!library) return null;
113+
114+
const owner = library.attributes.user?.data;
115+
116+
return {
117+
id: owner?.id,
118+
username: owner?.attributes.username,
119+
name: owner?.attributes.name,
120+
picture: owner?.attributes.picture,
121+
avatar: library.attributes.avatar?.data?.attributes?.url,
122+
aboutMe: library.attributes.aboutMe,
123+
};
124+
};
125+
126+
/** Shelves in their saved sequence, as the page draws them. */
127+
const shelvesOf = (library: ILibrary | null): StrapiSingleShelfEntry[] =>
128+
[...(library?.attributes.singleShelves?.data ?? [])].sort(
129+
(a, b) => (a.attributes.order ?? 0) - (b.attributes.order ?? 0),
130+
);
131+
103132
export function GlobalStateProvider({
104133
children,
105134
initialSidebarCollapsed = false,
135+
initialLibrary = null,
106136
}: GlobalStateProviderProps) {
107137
const { data: session } = useSession();
108138
const { accountData, token } = useAuth();
@@ -118,9 +148,13 @@ export function GlobalStateProvider({
118148
const [isLibrariesLoading, setIsLibrariesLoading] = useState(false);
119149
const [currentShelves, setCurrentShelves] = useState<
120150
StrapiSingleShelfEntry[]
121-
>([]);
122-
const [currentOwner, setCurrentOwner] = useState<LibraryOwner | null>(null);
123-
const [currentLibrary, setCurrentLibrary] = useState<ILibrary | null>(null);
151+
>(() => shelvesOf(initialLibrary));
152+
const [currentOwner, setCurrentOwner] = useState<LibraryOwner | null>(() =>
153+
ownerOf(initialLibrary),
154+
);
155+
const [currentLibrary, setCurrentLibrary] = useState<ILibrary | null>(
156+
initialLibrary,
157+
);
124158
const [isCreateBlocked, setIsCreateBlocked] = useState(false);
125159
const [isOwner, setIsOwner] = useState(false);
126160

src/components/library/molecules/Modal/Modal.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,14 @@ export function Modal(props: ModalProps): JSX.Element {
173173

174174
useLockBodyScroll(true);
175175

176+
// A dialog is drawn into the document body, and on the server there is no
177+
// body to draw into: since the library began arriving server-rendered, an
178+
// address naming an object opened its overview while the page was still
179+
// being written and took the whole response down with it. The page renders
180+
// without the dialog and the browser opens it on arrival, which is what it
181+
// did before the shelves came with the response.
182+
if (typeof document === 'undefined') return null;
183+
176184
return createPortal(
177185
<div className="library">
178186
<div

0 commit comments

Comments
 (0)