Spent the weekend improving stuff

This commit is contained in:
DrMint 2023-10-30 00:10:11 +01:00
parent 4f807c410b
commit cbd0251ad5
33 changed files with 1189 additions and 1120 deletions

2
.gitignore vendored
View File

@ -168,3 +168,5 @@ dist
# Ignore Data # Ignore Data
mongo/ mongo/
uploads/ uploads/
build
core

BIN
bun.lockb

Binary file not shown.

View File

@ -10,6 +10,7 @@ services:
- node_modules:/home/node/app/node_modules - node_modules:/home/node/app/node_modules
working_dir: /home/node/app/ working_dir: /home/node/app/
command: sh -c "npm install && npm run generate:types && npm run dev" command: sh -c "npm install && npm run generate:types && npm run dev"
# command: sh -c "npm install && npm run generate:types && npm run build:payload && npm run serve"
depends_on: depends_on:
- mongo - mongo
environment: environment:

37
package-lock.json generated
View File

@ -13,11 +13,10 @@
"@payloadcms/bundler-webpack": "^1.0.4", "@payloadcms/bundler-webpack": "^1.0.4",
"@payloadcms/db-mongodb": "^1.0.4", "@payloadcms/db-mongodb": "^1.0.4",
"@payloadcms/richtext-lexical": "^0.1.15", "@payloadcms/richtext-lexical": "^0.1.15",
"clean-deep": "^3.4.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"language-tags": "^1.0.9", "language-tags": "^1.0.9",
"luxon": "^3.4.3", "luxon": "^3.4.3",
"payload": "^2.0.12", "payload": "^2.0.13",
"styled-components": "^6.1.0" "styled-components": "^6.1.0"
}, },
"devDependencies": { "devDependencies": {
@ -5413,19 +5412,6 @@
"node": ">= 10.0" "node": ">= 10.0"
} }
}, },
"node_modules/clean-deep": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/clean-deep/-/clean-deep-3.4.0.tgz",
"integrity": "sha512-Lo78NV5ItJL/jl+B5w0BycAisaieJGXK1qYi/9m4SjR8zbqmrUtO7Yhro40wEShGmmxs/aJLI/A+jNhdkXK8mw==",
"dependencies": {
"lodash.isempty": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.transform": "^4.6.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/clean-stack": { "node_modules/clean-stack": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@ -9321,26 +9307,11 @@
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
}, },
"node_modules/lodash.isempty": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz",
"integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg=="
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
},
"node_modules/lodash.memoize": { "node_modules/lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
"integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag=="
}, },
"node_modules/lodash.transform": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.transform/-/lodash.transform-4.6.0.tgz",
"integrity": "sha512-LO37ZnhmBVx0GvOU/caQuipEh4GN82TcWv3yHlebGDgOxbxiwwzW5Pcx2AcvpIv2WmvmSMoC492yQFNhy/l/UQ=="
},
"node_modules/lodash.uniq": { "node_modules/lodash.uniq": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@ -11140,9 +11111,9 @@
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
}, },
"node_modules/payload": { "node_modules/payload": {
"version": "2.0.12", "version": "2.0.13",
"resolved": "https://registry.npmjs.org/payload/-/payload-2.0.12.tgz", "resolved": "https://registry.npmjs.org/payload/-/payload-2.0.13.tgz",
"integrity": "sha512-M3x9Y53ukiflZC4STri/34Gx4VjMk+UjuLc38dQRiCLPFGXEuqPfFFdt+c1Lh4ZeTQmMlWzzJcRMQ00tK+kWYg==", "integrity": "sha512-rD9ncVH8ClP7SphDymnrtVv0GAwHeyBXt9b1wSQBF15Dx/svU5rD1OEDtDPgEUTQApnySBVsB4NDGM1xO32YjA==",
"dependencies": { "dependencies": {
"@date-io/date-fns": "2.16.0", "@date-io/date-fns": "2.16.0",
"@dnd-kit/core": "6.0.8", "@dnd-kit/core": "6.0.8",

View File

@ -19,18 +19,18 @@
"precommit": "npm run generate:types && npm run prettier && npm run unused-exports && npm run tsc", "precommit": "npm run generate:types && npm run prettier && npm run unused-exports && npm run tsc",
"upgrade": "ncu", "upgrade": "ncu",
"clean": "sudo rm -r uploads mongo", "clean": "sudo rm -r uploads mongo",
"start": "sudo docker compose up" "start": "sudo docker compose up",
"stop": "sudo docker compose down"
}, },
"dependencies": { "dependencies": {
"@fontsource/vollkorn": "^5.0.17", "@fontsource/vollkorn": "^5.0.17",
"@payloadcms/bundler-webpack": "^1.0.4", "@payloadcms/bundler-webpack": "^1.0.4",
"@payloadcms/db-mongodb": "^1.0.4", "@payloadcms/db-mongodb": "^1.0.4",
"@payloadcms/richtext-lexical": "^0.1.15", "@payloadcms/richtext-lexical": "^0.1.15",
"clean-deep": "^3.4.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"language-tags": "^1.0.9", "language-tags": "^1.0.9",
"luxon": "^3.4.3", "luxon": "^3.4.3",
"payload": "^2.0.12", "payload": "^2.0.13",
"styled-components": "^6.1.0" "styled-components": "^6.1.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -2,6 +2,7 @@ import { CollectionConfig } from "payload/types";
import { mustBeAdmin } from "../../accesses/collections/mustBeAdmin"; import { mustBeAdmin } from "../../accesses/collections/mustBeAdmin";
import { CollectionGroups, Collections } from "../../constants"; import { CollectionGroups, Collections } from "../../constants";
import { backPropagationField } from "../../fields/backPropagationField/backPropagationField"; import { backPropagationField } from "../../fields/backPropagationField/backPropagationField";
import { rowField } from "../../fields/rowField/rowField";
import { slugField } from "../../fields/slugField/slugField"; import { slugField } from "../../fields/slugField/slugField";
import { translatedFields } from "../../fields/translatedFields/translatedFields"; import { translatedFields } from "../../fields/translatedFields/translatedFields";
import { buildCollectionConfig } from "../../utils/collectionConfig"; import { buildCollectionConfig } from "../../utils/collectionConfig";
@ -43,25 +44,22 @@ export const ChronologyEras: CollectionConfig = buildCollectionConfig({
endpoints: [importFromStrapi, getAllEndpoint], endpoints: [importFromStrapi, getAllEndpoint],
fields: [ fields: [
slugField({ name: fields.slug }), slugField({ name: fields.slug }),
{ rowField([
type: "row", {
fields: [ name: fields.startingYear,
{ type: "number",
name: fields.startingYear, min: 0,
type: "number", required: true,
min: 0, admin: { description: "The year the era started (year included)" },
required: true, },
admin: { width: "0%", description: "The year the era started (year included)" }, {
}, name: fields.endingYear,
{ type: "number",
name: fields.endingYear, min: 0,
type: "number", required: true,
min: 0, admin: { description: "The year the era ended (year included)" },
required: true, },
admin: { width: "0%", description: "The year the era ended (year included)" }, ]),
},
],
},
translatedFields({ translatedFields({
name: fields.translations, name: fields.translations,
admin: { useAsTitle: fields.translationsTitle }, admin: { useAsTitle: fields.translationsTitle },

View File

@ -5,6 +5,7 @@ import {
publishStatusFilters, publishStatusFilters,
} from "../../components/QuickFilters"; } from "../../components/QuickFilters";
import { CollectionGroups, Collections } from "../../constants"; import { CollectionGroups, Collections } from "../../constants";
import { rowField } from "../../fields/rowField/rowField";
import { translatedFields } from "../../fields/translatedFields/translatedFields"; import { translatedFields } from "../../fields/translatedFields/translatedFields";
import { createEditor } from "../../utils/editor"; import { createEditor } from "../../utils/editor";
import { buildVersionedCollectionConfig } from "../../utils/versionedCollectionConfig"; import { buildVersionedCollectionConfig } from "../../utils/versionedCollectionConfig";
@ -69,32 +70,26 @@ export const ChronologyItems: CollectionConfig = buildVersionedCollectionConfig(
name: fields.date, name: fields.date,
validate: validateDate, validate: validateDate,
fields: [ fields: [
{ rowField([
type: "row", {
fields: [ name: fields.dateYear,
{ type: "number",
name: fields.dateYear, required: true,
type: "number", min: 0,
required: true, },
min: 0, {
admin: { width: "0%" }, name: fields.dateMonth,
}, type: "number",
{ min: 1,
name: fields.dateMonth, max: 12,
type: "number", },
min: 1, {
max: 12, name: fields.dateDay,
admin: { width: "0%" }, type: "number",
}, min: 1,
{ max: 31,
name: fields.dateDay, },
type: "number", ]),
min: 1,
max: 31,
admin: { width: "0%" },
},
],
},
], ],
}, },
{ {

View File

@ -1,9 +1,11 @@
import { sectionBlock } from "../../blocks/sectionBlock"; import { sectionBlock } from "../../blocks/sectionBlock";
import { transcriptBlock } from "../../blocks/transcriptBlock"; import { transcriptBlock } from "../../blocks/transcriptBlock";
import { CollectionGroups, Collections, FileTypes, KeysTypes } from "../../constants"; import { CollectionGroups, Collections, FileTypes, KeysTypes } from "../../constants";
import { backPropagationField } from "../../fields/backPropagationField/backPropagationField";
import { fileField } from "../../fields/fileField/fileField"; import { fileField } from "../../fields/fileField/fileField";
import { imageField } from "../../fields/imageField/imageField"; import { imageField } from "../../fields/imageField/imageField";
import { keysField } from "../../fields/keysField/keysField"; import { keysField } from "../../fields/keysField/keysField";
import { rowField } from "../../fields/rowField/rowField";
import { slugField } from "../../fields/slugField/slugField"; import { slugField } from "../../fields/slugField/slugField";
import { translatedFields } from "../../fields/translatedFields/translatedFields"; import { translatedFields } from "../../fields/translatedFields/translatedFields";
import { beforeDuplicateAddCopyTo } from "../../hooks/beforeDuplicateAddCopyTo"; import { beforeDuplicateAddCopyTo } from "../../hooks/beforeDuplicateAddCopyTo";
@ -13,6 +15,7 @@ import { isDefined } from "../../utils/asserts";
import { createEditor } from "../../utils/editor"; import { createEditor } from "../../utils/editor";
import { buildVersionedCollectionConfig } from "../../utils/versionedCollectionConfig"; import { buildVersionedCollectionConfig } from "../../utils/versionedCollectionConfig";
import { importFromStrapi } from "./endpoints/importFromStrapi"; import { importFromStrapi } from "./endpoints/importFromStrapi";
import { importRelationsFromStrapi } from "./endpoints/importRelationsFromStrapi";
const fields = { const fields = {
slug: "slug", slug: "slug",
@ -35,6 +38,10 @@ const fields = {
audioNotes: "videoNotes", audioNotes: "videoNotes",
status: "status", status: "status",
updatedBy: "updatedBy", updatedBy: "updatedBy",
previousContents: "previousContents",
nextContents: "nextContents",
folders: "folders",
libraryItems: "libraryItems",
} as const satisfies Record<string, string>; } as const satisfies Record<string, string>;
export const Contents = buildVersionedCollectionConfig({ export const Contents = buildVersionedCollectionConfig({
@ -63,51 +70,44 @@ export const Contents = buildVersionedCollectionConfig({
beforeDuplicateAddCopyTo(fields.slug), beforeDuplicateAddCopyTo(fields.slug),
]), ]),
}, },
preview: (doc) => `https://accords-library.com/contents/${doc.slug}`,
}, },
endpoints: [importFromStrapi], endpoints: [importFromStrapi, importRelationsFromStrapi],
fields: [ fields: [
{ rowField([
type: "row", slugField({ name: fields.slug }),
fields: [ imageField({
slugField({ name: fields.slug, admin: { width: "0%" } }), name: fields.thumbnail,
imageField({ relationTo: Collections.ContentsThumbnails,
name: fields.thumbnail, }),
relationTo: Collections.ContentsThumbnails, ]),
admin: { width: "0%" }, rowField([
}), keysField({
], name: fields.categories,
}, relationTo: KeysTypes.Categories,
{ hasMany: true,
type: "row", }),
fields: [ keysField({
keysField({ name: fields.type,
name: fields.categories, relationTo: KeysTypes.Contents,
relationTo: KeysTypes.Categories, }),
hasMany: true, backPropagationField({
admin: { allowCreate: false, width: "0%" }, name: fields.libraryItems,
}), hasMany: true,
keysField({ relationTo: Collections.LibraryItems,
name: fields.type, where: ({ id }) => ({ "contents.content": { equals: id } }),
relationTo: KeysTypes.Contents, }),
admin: { allowCreate: false, width: "0%" }, ]),
}),
],
},
translatedFields({ translatedFields({
name: fields.translations, name: fields.translations,
admin: { useAsTitle: fields.title, hasSourceLanguage: true }, admin: { useAsTitle: fields.title, hasSourceLanguage: true },
required: true, required: true,
minRows: 1, minRows: 1,
fields: [ fields: [
{ rowField([
type: "row", { name: fields.pretitle, type: "text" },
fields: [ { name: fields.title, type: "text", required: true },
{ name: fields.pretitle, type: "text" }, { name: fields.subtitle, type: "text" },
{ name: fields.title, type: "text", required: true }, ]),
{ name: fields.subtitle, type: "text" },
],
},
{ {
name: fields.summary, name: fields.summary,
type: "richText", type: "richText",
@ -137,43 +137,37 @@ export const Contents = buildVersionedCollectionConfig({
alignment: true, alignment: true,
}), }),
}, },
{ rowField([
type: "row", {
fields: [ name: fields.textTranscribers,
{ label: "Transcribers",
name: fields.textTranscribers, type: "relationship",
label: "Transcribers", relationTo: Collections.Recorders,
type: "relationship", hasMany: true,
relationTo: Collections.Recorders, admin: {
hasMany: true, condition: (_, siblingData) =>
admin: { siblingData.language === siblingData.sourceLanguage,
condition: (_, siblingData) =>
siblingData.language === siblingData.sourceLanguage,
width: "0%",
},
}, },
{ },
name: fields.textTranslators, {
label: "Translators", name: fields.textTranslators,
type: "relationship", label: "Translators",
relationTo: Collections.Recorders, type: "relationship",
hasMany: true, relationTo: Collections.Recorders,
admin: { hasMany: true,
condition: (_, siblingData) => admin: {
siblingData.language !== siblingData.sourceLanguage, condition: (_, siblingData) =>
width: "0%", siblingData.language !== siblingData.sourceLanguage,
},
}, },
{ },
name: fields.textProofreaders, {
label: "Proofreaders", name: fields.textProofreaders,
type: "relationship", label: "Proofreaders",
relationTo: Collections.Recorders, type: "relationship",
hasMany: true, relationTo: Collections.Recorders,
admin: { width: "0%" }, hasMany: true,
}, },
], ]),
},
{ {
name: fields.textNotes, name: fields.textNotes,
label: "Notes", label: "Notes",
@ -185,50 +179,63 @@ export const Contents = buildVersionedCollectionConfig({
{ {
label: "Video", label: "Video",
fields: [ fields: [
{ rowField([
type: "row", fileField({
fields: [ name: fields.video,
fileField({ relationTo: FileTypes.ContentVideo,
name: fields.video, }),
relationTo: FileTypes.ContentVideo, {
admin: { width: "0%" }, name: fields.videoNotes,
}), label: "Notes",
{ type: "richText",
name: fields.videoNotes, editor: createEditor({ inlines: true, lists: true, links: true }),
label: "Notes", },
type: "richText", ]),
editor: createEditor({ inlines: true, lists: true, links: true }),
admin: { width: "0%" },
},
],
},
], ],
}, },
{ {
label: "Audio", label: "Audio",
fields: [ fields: [
{ rowField([
type: "row", fileField({
fields: [ name: fields.audio,
fileField({ relationTo: FileTypes.ContentAudio,
name: fields.audio, }),
relationTo: FileTypes.ContentAudio, {
admin: { width: "0%" }, name: fields.audioNotes,
}), label: "Notes",
{ type: "richText",
name: fields.audioNotes, editor: createEditor({ inlines: true, lists: true, links: true }),
label: "Notes", },
type: "richText", ]),
editor: createEditor({ inlines: true, lists: true, links: true }),
admin: { width: "0%" },
},
],
},
], ],
}, },
], ],
}, },
], ],
}), }),
rowField([
backPropagationField({
name: fields.folders,
hasMany: true,
relationTo: Collections.ContentsFolders,
where: ({ id }) => ({ contents: { equals: id } }),
admin: {
description: `You can set the folder(s) from the "Contents Folders" collection`,
},
}),
backPropagationField({
name: fields.previousContents,
relationTo: Collections.Contents,
hasMany: true,
where: ({ id }) => ({ [fields.nextContents]: { equals: id } }),
}),
{
name: fields.nextContents,
type: "relationship",
hasMany: true,
relationTo: Collections.Contents,
},
]),
], ],
}); });

View File

@ -60,6 +60,52 @@ export const importFromStrapi = createStrapiImportEndpoint<StrapiContent>({
image: thumbnail, image: thumbnail,
}); });
const handleTranslation = async ({
language,
title,
description,
pre_title,
subtitle,
text_set,
}: StrapiContent["translations"][number]) => {
if (isUndefined(language.data))
throw new Error("A language is required for a content translation");
if (isUndefined(text_set)) throw new Error("Only content with text_set are supported");
if (isUndefined(text_set.source_language.data))
throw new Error("A language is required for a content translation text_set");
return {
language: language.data.attributes.code,
sourceLanguage: text_set.source_language.data.attributes.code,
title,
pretitle: pre_title,
subtitle,
summary: isNotEmpty(description) ? plainTextToLexical(description) : undefined,
textContent: plainTextToLexical(text_set.text),
textNotes: isNotEmpty(text_set.notes) ? plainTextToLexical(text_set.notes) : undefined,
textTranscribers:
text_set.transcribers.data &&
(await Promise.all(
text_set.transcribers.data?.map(async (recorder) =>
findRecorder(recorder.attributes.username)
)
)),
textTranslators:
text_set.translators.data &&
(await Promise.all(
text_set.translators.data?.map(async (recorder) =>
findRecorder(recorder.attributes.username)
)
)),
textProofreaders:
text_set.proofreaders.data &&
(await Promise.all(
text_set.proofreaders.data?.map(async (recorder) =>
findRecorder(recorder.attributes.username)
)
)),
};
};
const data: MarkOptional<Content, "createdAt" | "id" | "updatedAt" | "updatedBy"> = { const data: MarkOptional<Content, "createdAt" | "id" | "updatedAt" | "updatedBy"> = {
slug, slug,
categories: categories:
@ -69,51 +115,7 @@ export const importFromStrapi = createStrapiImportEndpoint<StrapiContent>({
)), )),
type: type.data && (await findContentType(type.data?.attributes.slug)), type: type.data && (await findContentType(type.data?.attributes.slug)),
thumbnail: thumbnailId, thumbnail: thumbnailId,
translations: await Promise.all( translations: await Promise.all(translations.map(handleTranslation)),
translations.map(
async ({ language, title, description, pre_title, subtitle, text_set }) => {
if (isUndefined(language.data))
throw new Error("A language is required for a content translation");
if (isUndefined(text_set))
throw new Error("Only content with text_set are supported");
if (isUndefined(text_set.source_language.data))
throw new Error("A language is required for a content translation text_set");
return {
language: language.data.attributes.code,
sourceLanguage: text_set.source_language.data.attributes.code,
title,
pretitle: pre_title,
subtitle,
summary: isNotEmpty(description) ? plainTextToLexical(description) : undefined,
textContent: plainTextToLexical(text_set.text),
textNotes: isNotEmpty(text_set.notes)
? plainTextToLexical(text_set.notes)
: undefined,
textTranscribers:
text_set.transcribers.data &&
(await Promise.all(
text_set.transcribers.data?.map(async (recorder) =>
findRecorder(recorder.attributes.username)
)
)),
textTranslators:
text_set.translators.data &&
(await Promise.all(
text_set.translators.data?.map(async (recorder) =>
findRecorder(recorder.attributes.username)
)
)),
textProofreaders:
text_set.proofreaders.data &&
(await Promise.all(
text_set.proofreaders.data?.map(async (recorder) =>
findRecorder(recorder.attributes.username)
)
)),
};
}
)
),
}; };
return data; return data;
}, },

View File

@ -0,0 +1,38 @@
import payload from "payload";
import { Collections } from "../../../constants";
import { createStrapiImportEndpoint } from "../../../endpoints/createStrapiImportEndpoint";
import { findContent } from "../../../utils/localApi";
type StrapiContent = {
slug: string;
next_contents: { data: { attributes: { slug: string } }[] };
};
export const importRelationsFromStrapi = createStrapiImportEndpoint<StrapiContent>({
strapi: {
collection: "contents",
params: {
populate: ["next_contents"],
},
},
payload: {
path: "/strapi/related-content",
collection: Collections.Contents,
import: async ({ slug, next_contents }, user) => {
if (next_contents.data.length === 0) return;
const currentContent = await findContent(slug);
const nextContents: string[] = [];
for (const nextContent of next_contents.data) {
const result = await findContent(nextContent.attributes.slug);
nextContents.push(result);
}
payload.update({
collection: Collections.Contents,
id: currentContent,
data: { nextContents },
user,
});
},
},
});

View File

@ -1,4 +1,5 @@
import { CollectionGroups, Collections } from "../../constants"; import { CollectionGroups, Collections } from "../../constants";
import { rowField } from "../../fields/rowField/rowField";
import { slugField } from "../../fields/slugField/slugField"; import { slugField } from "../../fields/slugField/slugField";
import { translatedFields } from "../../fields/translatedFields/translatedFields"; import { translatedFields } from "../../fields/translatedFields/translatedFields";
import { buildCollectionConfig } from "../../utils/collectionConfig"; import { buildCollectionConfig } from "../../utils/collectionConfig";
@ -37,24 +38,19 @@ export const ContentsFolders = buildCollectionConfig({
}, },
fields: [{ name: fields.name, type: "text", required: true }], fields: [{ name: fields.name, type: "text", required: true }],
}), }),
{ rowField([
type: "row", {
fields: [ type: "relationship",
{ name: fields.subfolders,
type: "relationship", relationTo: Collections.ContentsFolders,
name: fields.subfolders, hasMany: true,
relationTo: Collections.ContentsFolders, },
hasMany: true, {
admin: { width: "0%" }, type: "relationship",
}, name: fields.contents,
{ relationTo: Collections.Contents,
type: "relationship", hasMany: true,
name: fields.contents, },
relationTo: Collections.Contents, ]),
hasMany: true,
admin: { width: "0%" },
},
],
},
], ],
}); });

View File

@ -4,6 +4,7 @@ import { Collections } from "../../../constants";
import { CollectionEndpoint } from "../../../types/payload"; import { CollectionEndpoint } from "../../../types/payload";
import { StrapiLanguage } from "../../../types/strapi"; import { StrapiLanguage } from "../../../types/strapi";
import { isUndefined } from "../../../utils/asserts"; import { isUndefined } from "../../../utils/asserts";
import { findContent } from "../../../utils/localApi";
type StrapiContentsFolder = { type StrapiContentsFolder = {
id: string; id: string;
@ -11,13 +12,14 @@ type StrapiContentsFolder = {
slug: string; slug: string;
titles?: { title: string; language: StrapiLanguage }[]; titles?: { title: string; language: StrapiLanguage }[];
subfolders: { data: StrapiContentsFolder[] }; subfolders: { data: StrapiContentsFolder[] };
contents: { data: { id: number }[] }; contents: { data: { attributes: { slug: string } }[] };
}; };
}; };
const getStrapiContentFolder = async (id: number): Promise<StrapiContentsFolder> => { const getStrapiContentFolder = async (id: number): Promise<StrapiContentsFolder> => {
const paramsWithPagination = QueryString.stringify({ const paramsWithPagination = QueryString.stringify({
populate: [ populate: [
"contents",
"subfolders", "subfolders",
"subfolders.contents", "subfolders.contents",
"subfolders.titles", "subfolders.titles",
@ -72,17 +74,31 @@ export const importFromStrapi: CollectionEndpoint = {
} }
let foldersCreated = 0; let foldersCreated = 0;
const errors: string[] = [];
const createContentFolder = async (data: StrapiContentsFolder): Promise<string> => { const createContentFolder = async (data: StrapiContentsFolder): Promise<string> => {
const { slug, titles } = data.attributes;
const subfolders = await Promise.all( const subfolders = await Promise.all(
data.attributes.subfolders.data.map(createContentFolder) data.attributes.subfolders.data.map(createContentFolder)
); );
const { slug, titles } = data.attributes;
const contents: string[] = [];
for (const content of data.attributes.contents.data) {
try {
const result = await findContent(content.attributes.slug);
contents.push(result);
} catch (e) {
errors.push(`Couldn't add ${content.attributes.slug} to folder ${slug}`);
}
}
const result = await payload.create({ const result = await payload.create({
collection: Collections.ContentsFolders, collection: Collections.ContentsFolders,
data: { data: {
slug, slug,
subfolders, subfolders,
contents,
translations: titles?.map(({ title, language }) => { translations: titles?.map(({ title, language }) => {
if (isUndefined(language.data)) if (isUndefined(language.data))
throw new Error("A language is required for a content folder translation"); throw new Error("A language is required for a content folder translation");
@ -102,6 +118,8 @@ export const importFromStrapi: CollectionEndpoint = {
res.status(500).json({ message: "Something went wrong", error: e }); res.status(500).json({ message: "Something went wrong", error: e });
} }
res.status(200).json({ message: `${foldersCreated} entries have been added successfully.` }); res
.status(200)
.json({ message: `${foldersCreated} entries have been added successfully.`, errors });
}, },
}; };

View File

@ -2,6 +2,7 @@ import payload from "payload";
import { mustBeAdmin } from "../../accesses/collections/mustBeAdmin"; import { mustBeAdmin } from "../../accesses/collections/mustBeAdmin";
import { QuickFilters } from "../../components/QuickFilters"; import { QuickFilters } from "../../components/QuickFilters";
import { CollectionGroups, Collections, KeysTypes, LanguageCodes } from "../../constants"; import { CollectionGroups, Collections, KeysTypes, LanguageCodes } from "../../constants";
import { rowField } from "../../fields/rowField/rowField";
import { translatedFields } from "../../fields/translatedFields/translatedFields"; import { translatedFields } from "../../fields/translatedFields/translatedFields";
import { beforeDuplicateAddCopyTo } from "../../hooks/beforeDuplicateAddCopyTo"; import { beforeDuplicateAddCopyTo } from "../../hooks/beforeDuplicateAddCopyTo";
import { Key } from "../../types/collections"; import { Key } from "../../types/collections";
@ -95,26 +96,21 @@ export const Keys = buildCollectionConfig({
useAsTitle: fields.translationsName, useAsTitle: fields.translationsName,
}, },
fields: [ fields: [
{ rowField([
type: "row", {
fields: [ name: fields.translationsName,
{ type: "text",
name: fields.translationsName, required: true,
type: "text", },
required: true, {
admin: { width: "0%" }, name: fields.translationsShort,
type: "text",
admin: {
condition: (data: Partial<Key>) =>
isDefined(data.type) && keysTypesWithShort.includes(data.type),
}, },
{ },
name: fields.translationsShort, ]),
type: "text",
admin: {
condition: (data: Partial<Key>) =>
isDefined(data.type) && keysTypesWithShort.includes(data.type),
width: "0%",
},
},
],
},
], ],
}), }),
], ],

View File

@ -1,4 +1,6 @@
import { CollectionGroups, Collections } from "../../constants"; import { CollectionGroups, Collections } from "../../constants";
import { backPropagationField } from "../../fields/backPropagationField/backPropagationField";
import { rowField } from "../../fields/rowField/rowField";
import { slugField } from "../../fields/slugField/slugField"; import { slugField } from "../../fields/slugField/slugField";
import { translatedFields } from "../../fields/translatedFields/translatedFields"; import { translatedFields } from "../../fields/translatedFields/translatedFields";
import { buildCollectionConfig } from "../../utils/collectionConfig"; import { buildCollectionConfig } from "../../utils/collectionConfig";
@ -11,6 +13,7 @@ const fields = {
description: "description", description: "description",
subfolders: "subfolders", subfolders: "subfolders",
items: "items", items: "items",
parentFolders: "parentFolders",
} as const satisfies Record<string, string>; } as const satisfies Record<string, string>;
export const LibraryFolders = buildCollectionConfig({ export const LibraryFolders = buildCollectionConfig({
@ -44,24 +47,25 @@ export const LibraryFolders = buildCollectionConfig({
}, },
], ],
}), }),
{ rowField([
type: "row", backPropagationField({
fields: [ name: fields.parentFolders,
{ relationTo: Collections.LibraryFolders,
type: "relationship", hasMany: true,
name: fields.subfolders, where: ({ id }) => ({ [fields.subfolders]: { equals: id } }),
relationTo: Collections.LibraryFolders, }),
hasMany: true, {
admin: { width: "0%" }, type: "relationship",
}, name: fields.subfolders,
{ relationTo: Collections.LibraryFolders,
type: "relationship", hasMany: true,
name: fields.items, },
relationTo: Collections.LibraryItems, {
hasMany: true, type: "relationship",
admin: { width: "0%" }, name: fields.items,
}, relationTo: Collections.LibraryItems,
], hasMany: true,
}, },
]),
], ],
}); });

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,9 @@
import { sectionBlock } from "../../blocks/sectionBlock";
import { QuickFilters, publishStatusFilters } from "../../components/QuickFilters"; import { QuickFilters, publishStatusFilters } from "../../components/QuickFilters";
import { CollectionGroups, Collections, KeysTypes } from "../../constants"; import { CollectionGroups, Collections, KeysTypes } from "../../constants";
import { imageField } from "../../fields/imageField/imageField"; import { imageField } from "../../fields/imageField/imageField";
import { keysField } from "../../fields/keysField/keysField";
import { rowField } from "../../fields/rowField/rowField";
import { slugField } from "../../fields/slugField/slugField"; import { slugField } from "../../fields/slugField/slugField";
import { translatedFields } from "../../fields/translatedFields/translatedFields"; import { translatedFields } from "../../fields/translatedFields/translatedFields";
import { beforeDuplicateAddCopyTo } from "../../hooks/beforeDuplicateAddCopyTo"; import { beforeDuplicateAddCopyTo } from "../../hooks/beforeDuplicateAddCopyTo";
@ -58,39 +61,24 @@ export const Posts = buildVersionedCollectionConfig({
preview: (doc) => `https://accords-library.com/news/${doc.slug}`, preview: (doc) => `https://accords-library.com/news/${doc.slug}`,
}, },
fields: [ fields: [
{ rowField([
type: "row", slugField({ name: fields.slug }),
fields: [ imageField({
slugField({ name: fields.slug, admin: { width: "0%" } }), name: fields.thumbnail,
imageField({ relationTo: Collections.PostsThumbnails,
name: fields.thumbnail, }),
relationTo: Collections.PostsThumbnails, ]),
admin: { width: "0%" }, rowField([
}), {
], name: fields.authors,
}, type: "relationship",
{ relationTo: [Collections.Recorders],
type: "row", required: true,
fields: [ minRows: 1,
{ hasMany: true,
name: fields.authors, },
type: "relationship", keysField({ name: fields.categories, relationTo: KeysTypes.Categories, hasMany: true }),
relationTo: [Collections.Recorders], ]),
required: true,
minRows: 1,
hasMany: true,
admin: { width: "0%" },
},
{
name: fields.categories,
type: "relationship",
relationTo: [Collections.Keys],
filterOptions: { type: { equals: KeysTypes.Categories } },
hasMany: true,
admin: { allowCreate: false, width: "0%" },
},
],
},
translatedFields({ translatedFields({
name: fields.translations, name: fields.translations,
admin: { useAsTitle: fields.title, hasSourceLanguage: true }, admin: { useAsTitle: fields.title, hasSourceLanguage: true },
@ -104,57 +92,60 @@ export const Posts = buildVersionedCollectionConfig({
editor: createEditor({ inlines: true, lists: true, links: true }), editor: createEditor({ inlines: true, lists: true, links: true }),
}, },
{ {
type: "row", name: fields.content,
fields: [ type: "richText",
{ editor: createEditor({
name: fields.translators, images: true,
type: "relationship", inlines: true,
relationTo: Collections.Recorders, alignment: true,
hasMany: true, blocks: [sectionBlock],
hooks: { links: true,
beforeChange: [ lists: true,
({ siblingData }) => { }),
if (siblingData.language === siblingData.sourceLanguage) {
delete siblingData.translators;
}
},
],
},
admin: {
condition: (_, siblingData) => {
if (
isUndefined(siblingData.language) ||
isUndefined(siblingData.sourceLanguage)
) {
return false;
}
return siblingData.language !== siblingData.sourceLanguage;
},
width: "0%",
},
validate: (translators, { siblingData }) => {
if (isUndefined(siblingData.language) || isUndefined(siblingData.sourceLanguage)) {
return true;
}
if (siblingData.language === siblingData.sourceLanguage) {
return true;
}
if (isDefined(translators) && translators.length > 0) {
return true;
}
return "This field is required when the language is different from the source language.";
},
},
{
name: fields.proofreaders,
type: "relationship",
relationTo: Collections.Recorders,
hasMany: true,
admin: { width: "0%" },
},
],
}, },
{ name: fields.content, type: "richText" }, rowField([
{
name: fields.translators,
type: "relationship",
relationTo: Collections.Recorders,
hasMany: true,
hooks: {
beforeChange: [
({ siblingData }) => {
if (siblingData.language === siblingData.sourceLanguage) {
delete siblingData.translators;
}
},
],
},
admin: {
condition: (_, siblingData) => {
if (isUndefined(siblingData.language) || isUndefined(siblingData.sourceLanguage)) {
return false;
}
return siblingData.language !== siblingData.sourceLanguage;
},
},
validate: (translators, { siblingData }) => {
if (isUndefined(siblingData.language) || isUndefined(siblingData.sourceLanguage)) {
return true;
}
if (siblingData.language === siblingData.sourceLanguage) {
return true;
}
if (isDefined(translators) && translators.length > 0) {
return true;
}
return "This field is required when the language is different from the source language.";
},
},
{
name: fields.proofreaders,
type: "relationship",
relationTo: Collections.Recorders,
hasMany: true,
},
]),
], ],
}), }),
{ {

View File

@ -4,6 +4,7 @@ import { mustBeAdmin as mustBeAdminForFields } from "../../accesses/fields/mustB
import { QuickFilters } from "../../components/QuickFilters"; import { QuickFilters } from "../../components/QuickFilters";
import { CollectionGroups, Collections, RecordersRoles } from "../../constants"; import { CollectionGroups, Collections, RecordersRoles } from "../../constants";
import { imageField } from "../../fields/imageField/imageField"; import { imageField } from "../../fields/imageField/imageField";
import { rowField } from "../../fields/rowField/rowField";
import { translatedFields } from "../../fields/translatedFields/translatedFields"; import { translatedFields } from "../../fields/translatedFields/translatedFields";
import { buildCollectionConfig } from "../../utils/collectionConfig"; import { buildCollectionConfig } from "../../utils/collectionConfig";
import { createEditor } from "../../utils/editor"; import { createEditor } from "../../utils/editor";
@ -77,23 +78,19 @@ export const Recorders = buildCollectionConfig({
endpoints: [importFromStrapi], endpoints: [importFromStrapi],
timestamps: false, timestamps: false,
fields: [ fields: [
{ rowField([
type: "row", {
fields: [ name: fields.username,
{ type: "text",
name: fields.username, unique: true,
type: "text", required: true,
unique: true, admin: { description: "The username must be unique" },
required: true, },
admin: { description: "The username must be unique", width: "0%" }, imageField({
}, name: fields.avatar,
imageField({ relationTo: Collections.RecordersThumbnails,
name: fields.avatar, }),
relationTo: Collections.RecordersThumbnails, ]),
admin: { width: "0%" },
}),
],
},
{ {
name: fields.languages, name: fields.languages,
type: "relationship", type: "relationship",

View File

@ -1,6 +1,7 @@
import { CollectionConfig } from "payload/types"; import { CollectionConfig } from "payload/types";
import { mustBeAdmin } from "../../accesses/collections/mustBeAdmin"; import { mustBeAdmin } from "../../accesses/collections/mustBeAdmin";
import { CollectionGroups, Collections, VideoSources } from "../../constants"; import { CollectionGroups, Collections, VideoSources } from "../../constants";
import { rowField } from "../../fields/rowField/rowField";
import { buildCollectionConfig } from "../../utils/collectionConfig"; import { buildCollectionConfig } from "../../utils/collectionConfig";
import { importFromStrapi } from "./endpoints/importFromStrapi"; import { importFromStrapi } from "./endpoints/importFromStrapi";
@ -47,40 +48,31 @@ export const Videos: CollectionConfig = buildCollectionConfig({
endpoints: [importFromStrapi], endpoints: [importFromStrapi],
timestamps: false, timestamps: false,
fields: [ fields: [
{ rowField([
type: "row", { name: fields.uid, type: "text", required: true, unique: true },
fields: [ {
{ name: fields.uid, type: "text", required: true, unique: true, admin: { width: "0%" } }, name: fields.gone,
{ type: "checkbox",
name: fields.gone, defaultValue: false,
type: "checkbox", required: true,
defaultValue: false, admin: {
required: true, description:
admin: { "Is the video no longer available (deleted, privatized, unlisted, blocked...)",
description:
"Is the video no longer available (deleted, privatized, unlisted, blocked...)",
width: "0%",
},
}, },
{ },
name: fields.source, {
type: "select", name: fields.source,
required: true, type: "select",
options: Object.entries(VideoSources).map(([value, label]) => ({ label, value })), required: true,
admin: { width: "0%" }, options: Object.entries(VideoSources).map(([value, label]) => ({ label, value })),
}, },
], ]),
},
{ name: fields.title, type: "text", required: true }, { name: fields.title, type: "text", required: true },
{ name: fields.description, type: "textarea" }, { name: fields.description, type: "textarea" },
{ rowField([
type: "row", { name: fields.likes, type: "number" },
fields: [ { name: fields.views, type: "number" },
{ name: fields.likes, type: "number", admin: { width: "0%" } }, ]),
{ name: fields.views, type: "number", admin: { width: "0%" } },
],
},
{ {
name: fields.publishedDate, name: fields.publishedDate,
type: "date", type: "date",

View File

@ -1,6 +1,7 @@
import { CollectionConfig } from "payload/types"; import { CollectionConfig } from "payload/types";
import { mustBeAdmin } from "../../accesses/collections/mustBeAdmin"; import { mustBeAdmin } from "../../accesses/collections/mustBeAdmin";
import { CollectionGroups, Collections } from "../../constants"; import { CollectionGroups, Collections } from "../../constants";
import { rowField } from "../../fields/rowField/rowField";
import { buildCollectionConfig } from "../../utils/collectionConfig"; import { buildCollectionConfig } from "../../utils/collectionConfig";
const fields = { const fields = {
@ -30,12 +31,9 @@ export const VideosChannels: CollectionConfig = buildCollectionConfig({
timestamps: false, timestamps: false,
fields: [ fields: [
{ name: fields.uid, type: "text", required: true, unique: true }, { name: fields.uid, type: "text", required: true, unique: true },
{ rowField([
type: "row", { name: fields.title, type: "text", required: true },
fields: [ { name: fields.subscribers, type: "number" },
{ name: fields.title, type: "text", required: true }, ]),
{ name: fields.subscribers, type: "number" },
],
},
], ],
}); });

View File

@ -2,6 +2,7 @@ import { RowLabelArgs } from "payload/dist/admin/components/forms/RowLabel/types
import { CollectionGroups, Collections, KeysTypes } from "../../constants"; import { CollectionGroups, Collections, KeysTypes } from "../../constants";
import { imageField } from "../../fields/imageField/imageField"; import { imageField } from "../../fields/imageField/imageField";
import { keysField } from "../../fields/keysField/keysField"; import { keysField } from "../../fields/keysField/keysField";
import { rowField } from "../../fields/rowField/rowField";
import { slugField } from "../../fields/slugField/slugField"; import { slugField } from "../../fields/slugField/slugField";
import { translatedFields } from "../../fields/translatedFields/translatedFields"; import { translatedFields } from "../../fields/translatedFields/translatedFields";
import { createEditor } from "../../utils/editor"; import { createEditor } from "../../utils/editor";
@ -45,34 +46,25 @@ export const Weapons = buildVersionedCollectionConfig({
}, },
endpoints: [importFromStrapi, getBySlugEndpoint], endpoints: [importFromStrapi, getBySlugEndpoint],
fields: [ fields: [
{ rowField([
type: "row", slugField({ name: fields.slug }),
fields: [ imageField({
slugField({ name: fields.slug, admin: { width: "0%" } }), name: fields.thumbnail,
imageField({ relationTo: Collections.WeaponsThumbnails,
name: fields.thumbnail, }),
relationTo: Collections.WeaponsThumbnails, ]),
admin: { width: "0%" }, rowField([
}), keysField({
], name: fields.type,
}, relationTo: KeysTypes.Weapons,
{ required: true,
type: "row", }),
fields: [ {
keysField({ name: fields.group,
name: fields.type, type: "relationship",
relationTo: KeysTypes.Weapons, relationTo: Collections.WeaponsGroups,
required: true, },
admin: { allowCreate: false, width: "0%" }, ]),
}),
{
name: fields.group,
type: "relationship",
relationTo: Collections.WeaponsGroups,
admin: { width: "0%" },
},
],
},
{ {
name: fields.appearances, name: fields.appearances,
type: "array", type: "array",
@ -91,7 +83,6 @@ export const Weapons = buildVersionedCollectionConfig({
required: true, required: true,
hasMany: true, hasMany: true,
relationTo: KeysTypes.Categories, relationTo: KeysTypes.Categories,
admin: { allowCreate: false },
}), }),
translatedFields({ translatedFields({
name: fields.appearancesTranslations, name: fields.appearancesTranslations,
@ -103,61 +94,46 @@ export const Weapons = buildVersionedCollectionConfig({
hasCredits: true, hasCredits: true,
}, },
fields: [ fields: [
{ rowField([
type: "row", {
fields: [ name: fields.appearancesTranslationsName,
{ type: "text",
name: fields.appearancesTranslationsName, required: true,
type: "text", },
required: true, {
admin: { width: "0%" }, name: fields.appearancesTranslationsDescription,
}, type: "richText",
{ editor: createEditor({ inlines: true }),
name: fields.appearancesTranslationsDescription, },
type: "richText", ]),
editor: createEditor({ inlines: true }), rowField([
admin: { width: "0%" }, {
}, name: fields.appearancesTranslationsLevel1,
], label: "Level 1",
}, type: "richText",
{ editor: createEditor({ inlines: true }),
type: "row", },
fields: [ {
{ name: fields.appearancesTranslationsLevel2,
name: fields.appearancesTranslationsLevel1, label: "Level 2",
label: "Level 1", type: "richText",
type: "richText", editor: createEditor({ inlines: true }),
editor: createEditor({ inlines: true }), },
admin: { width: "0%" }, ]),
}, rowField([
{ {
name: fields.appearancesTranslationsLevel2, name: fields.appearancesTranslationsLevel3,
label: "Level 2", label: "Level 3",
type: "richText", type: "richText",
editor: createEditor({ inlines: true }), editor: createEditor({ inlines: true }),
admin: { width: "0%" }, },
}, {
], name: fields.appearancesTranslationsLevel4,
}, label: "Level 4",
{ type: "richText",
type: "row", editor: createEditor({ inlines: true }),
fields: [ },
{ ]),
name: fields.appearancesTranslationsLevel3,
label: "Level 3",
type: "richText",
editor: createEditor({ inlines: true }),
admin: { width: "0%" },
},
{
name: fields.appearancesTranslationsLevel4,
label: "Level 4",
type: "richText",
editor: createEditor({ inlines: true }),
admin: { width: "0%" },
},
],
},
], ],
}), }),
], ],

View File

@ -38,6 +38,7 @@ type Params<S> = {
params: any; params: any;
}; };
payload: { payload: {
path?: string;
collection: Collections; collection: Collections;
import?: (strapiObject: S, user: any) => Promise<void>; import?: (strapiObject: S, user: any) => Promise<void>;
convert?: ( convert?: (
@ -55,36 +56,38 @@ export const importStrapiEntries = async <S>({
const entries = await getAllStrapiEntries(strapiParams.collection, strapiParams.params); const entries = await getAllStrapiEntries(strapiParams.collection, strapiParams.params);
const errors: string[] = []; const errors: string[] = [];
let currentCount = 1;
await Promise.all( for (const { attributes, id } of entries) {
entries.map(async ({ attributes, id }) => { console.debug(`Handling entry ${currentCount}/${entries.length} (id: ${id})`);
try { currentCount++;
if (isDefined(payloadParams.import)) { try {
await payloadParams.import(attributes, user); if (isDefined(payloadParams.import)) {
} else if (isDefined(payloadParams.convert)) { await payloadParams.import(attributes, user);
await payload.create({ } else if (isDefined(payloadParams.convert)) {
collection: payloadParams.collection, await payload.create({
data: await payloadParams.convert(attributes, user), collection: payloadParams.collection,
user, data: await payloadParams.convert(attributes, user),
}); user,
} else { });
throw new Error("No function was provided to handle importing the Strapi data"); } else {
} throw new Error("No function was provided to handle importing the Strapi data");
} catch (e) {
console.warn(e);
if (typeof e === "object" && isDefined(e) && "name" in e) {
errors.push(`${e.name} with ${id}`);
}
} }
}) } catch (e) {
); console.warn(e);
if (typeof e === "object" && isDefined(e) && "name" in e) {
const message = "message" in e ? ` (${e.message})` : "";
errors.push(`${e.name}${message} with ${id}`);
}
}
}
return { count: entries.length, errors }; return { count: entries.length, errors };
}; };
export const createStrapiImportEndpoint = <S>(params: Params<S>): CollectionEndpoint => ({ export const createStrapiImportEndpoint = <S>(params: Params<S>): CollectionEndpoint => ({
method: "post", method: "post",
path: "/strapi", path: params.payload.path ?? "/strapi",
handler: async (req, res) => { handler: async (req, res) => {
if (!req.user) { if (!req.user) {
return res.status(403).send({ return res.status(403).send({

View File

@ -2,7 +2,7 @@ import payload from "payload";
import { FieldBase } from "payload/dist/fields/config/types"; import { FieldBase } from "payload/dist/fields/config/types";
import { RelationshipField, Where } from "payload/types"; import { RelationshipField, Where } from "payload/types";
import { Collections } from "../../constants"; import { Collections } from "../../constants";
import { isNotEmpty } from "../../utils/asserts"; import { isEmpty } from "../../utils/asserts";
type BackPropagationField = FieldBase & { type BackPropagationField = FieldBase & {
where: (data: any) => Where; where: (data: any) => Where;
@ -30,21 +30,22 @@ export const backPropagationField = ({
], ],
afterRead: [ afterRead: [
...afterRead, ...afterRead,
async ({ data }) => { async ({ data, context }) => {
if (isNotEmpty(data?.id)) { if (isEmpty(data?.id) || context.stopPropagation) {
const result = await payload.find({ return hasMany ? [] : undefined;
collection: params.relationTo, }
where: where(data), const result = await payload.find({
limit: 100, collection: params.relationTo,
depth: 0, where: where(data),
}); limit: 100,
if (hasMany) { depth: 0,
return result.docs.map((doc) => doc.id); context: { stopPropagation: true },
} else { });
return result.docs[0]?.id; if (hasMany) {
} return result.docs.map((doc) => doc.id);
} else {
return result.docs[0]?.id;
} }
return hasMany ? [] : undefined;
}, },
], ],
}, },

View File

@ -0,0 +1,55 @@
import { CollapsibleField, Condition, Field } from "payload/types";
import { capitalize } from "../../utils/string";
type Props = {
name: string;
label?: string;
admin?: {
description?: string;
condition?: Condition;
};
fields: Field[];
};
export const componentField = ({
name,
fields,
label = capitalize(name),
admin,
}: Props): CollapsibleField => {
return {
type: "collapsible",
label: name,
admin: { className: "component-field", condition: admin?.condition },
fields: [
{
name: `${name}Enabled`,
label,
type: "checkbox",
admin: {
className: "component-field-checkbox",
description: admin?.description,
},
},
{
name,
type: "group",
hooks: {
beforeChange: [
({ siblingData }) => {
if (!siblingData[`${name}Enabled`]) {
delete siblingData[name];
}
},
],
},
admin: {
className: "component-field-group",
condition: (_, siblingData) => siblingData[`${name}Enabled`],
hideGutter: true,
},
fields,
},
],
};
};

View File

@ -10,9 +10,14 @@ type KeysField = FieldBase & {
export const keysField = ({ export const keysField = ({
relationTo, relationTo,
hasMany = false, hasMany = false,
admin,
...props ...props
}: KeysField): RelationshipField => ({ }: KeysField): RelationshipField => ({
...props, ...props,
admin: {
allowCreate: false,
...admin,
},
type: "relationship", type: "relationship",
hasMany: hasMany, hasMany: hasMany,
relationTo: Collections.Keys, relationTo: Collections.Keys,

View File

@ -1,14 +0,0 @@
import { ArrayField } from "payload/types";
type Props = Omit<ArrayField, "type" | "maxRows" | "minRows">;
export const optionalGroupField = ({
admin: { className = "", ...otherAdmin } = {},
...otherProps
}: Props): ArrayField => ({
...otherProps,
type: "array",
minRows: 0,
maxRows: 1,
admin: { ...otherAdmin, className: `${className} group-array` },
});

View File

@ -0,0 +1,9 @@
import { Field, RowField } from "payload/types";
export const rowField = (fields: Field[]): RowField => ({
type: "row",
fields: fields.map(({ admin, ...otherConfig }) => ({
...otherConfig,
admin: { width: "0%", ...admin },
})),
});

View File

@ -3,6 +3,7 @@ import { array } from "payload/dist/fields/validations";
import { ArrayField, Field } from "payload/types"; import { ArrayField, Field } from "payload/types";
import { Collections } from "../../constants"; import { Collections } from "../../constants";
import { hasDuplicates, isDefined, isUndefined } from "../../utils/asserts"; import { hasDuplicates, isDefined, isUndefined } from "../../utils/asserts";
import { rowField } from "../rowField/rowField";
import { Cell } from "./Cell"; import { Cell } from "./Cell";
import { RowLabel } from "./RowLabel"; import { RowLabel } from "./RowLabel";
@ -28,7 +29,7 @@ const languageField: Field = {
type: "relationship", type: "relationship",
relationTo: Collections.Languages, relationTo: Collections.Languages,
required: true, required: true,
admin: { allowCreate: false }, admin: { allowCreate: false },
}; };
const sourceLanguageField: Field = { const sourceLanguageField: Field = {
@ -169,9 +170,7 @@ export const translatedFields = ({
return true; return true;
}, },
fields: [ fields: [
hasSourceLanguage rowField(hasSourceLanguage ? [languageField, sourceLanguageField] : [languageField]),
? { type: "row", fields: [languageField, sourceLanguageField] }
: languageField,
...fields, ...fields,
...(hasCredits ? [creditFields] : []), ...(hasCredits ? [creditFields] : []),
], ],

View File

@ -1,3 +1,5 @@
// Theme override
html[data-theme="dark"] { html[data-theme="dark"] {
--color-base-0: #ffffff; --color-base-0: #ffffff;
--color-base-50: #ebeae7; --color-base-50: #ebeae7;
@ -46,8 +48,14 @@ html[data-theme="light"] {
--color-base-1000: #000000; --color-base-1000: #000000;
} }
// Fix height disparities between certain fields
.field-type.row { .field-type.row {
padding: 1rem 0; padding: 1rem 0;
> div > .field-type.checkbox {
padding-top: 2.4rem;
}
} }
.field-type.radio-group { .field-type.radio-group {
@ -56,6 +64,8 @@ html[data-theme="light"] {
} }
} }
// Hide header on blocks with classname "no-label"
.field-type.no-label > header { .field-type.no-label > header {
display: none; display: none;
} }
@ -68,37 +78,49 @@ html[data-theme="light"] {
display: none; display: none;
} }
// Reduce margin on Lexical blocks with the classname "reduced-margins"
.rich-text-lexical.field-type.reduced-margins { .rich-text-lexical.field-type.reduced-margins {
margin-top: -0.75em; margin-top: -0.75em;
margin-bottom: -2rem; margin-bottom: -2rem;
} }
.field-type.array-field.group-array { // CSS for componentField
> .array-field__header {
.array-field__header-actions { .collapsible-field.component-field {
> .collapsible > .collapsible__toggle-wrap {
display: none;
}
.component-field-checkbox {
margin-bottom: 0;
.checkbox-input {
flex-direction: row-reverse;
justify-content: space-between;
display: flex;
gap: 16px;
pointer-events: none;
> .checkbox-input__input {
pointer-events: auto;
}
}
.field-label {
font-weight: 500;
font-family: "Suisse Intl";
font-size: 1.7788461538rem;
padding: 0;
transform: translateY(2px);
}
}
.component-field-group {
margin-top: 2rem;
> .group-field__wrap > .group-field__header {
display: none; display: none;
} }
} }
> div {
> div {
> div {
> .collapsible__toggle-wrap {
.array-actions__duplicate {
display: none;
}
.collapsible__drag,
.collapsible__toggle,
.collapsible__header-wrap,
.collapsible__indicator {
display: none;
}
.collapsible__actions-wrap {
z-index: 1;
}
}
}
}
}
} }

View File

@ -22,32 +22,32 @@ export type RecorderBiographies = {
export interface Config { export interface Config {
collections: { collections: {
'library-folders': LibraryFolder; "library-folders": LibraryFolder;
'library-items': LibraryItem; "library-items": LibraryItem;
contents: Content; contents: Content;
'contents-folders': ContentsFolder; "contents-folders": ContentsFolder;
posts: Post; posts: Post;
'chronology-items': ChronologyItem; "chronology-items": ChronologyItem;
'chronology-eras': ChronologyEra; "chronology-eras": ChronologyEra;
weapons: Weapon; weapons: Weapon;
'weapons-groups': WeaponsGroup; "weapons-groups": WeaponsGroup;
'weapons-thumbnails': WeaponsThumbnail; "weapons-thumbnails": WeaponsThumbnail;
'contents-thumbnails': ContentsThumbnail; "contents-thumbnails": ContentsThumbnail;
'library-items-thumbnails': LibraryItemThumbnail; "library-items-thumbnails": LibraryItemThumbnail;
'library-items-scans': LibraryItemScans; "library-items-scans": LibraryItemScans;
'library-items-gallery': LibraryItemGallery; "library-items-gallery": LibraryItemGallery;
'recorders-thumbnails': RecordersThumbnail; "recorders-thumbnails": RecordersThumbnail;
'posts-thumbnails': PostThumbnail; "posts-thumbnails": PostThumbnail;
files: File; files: File;
Notes: Note; Notes: Note;
videos: Video; videos: Video;
'videos-channels': VideosChannel; "videos-channels": VideosChannel;
languages: Language; languages: Language;
currencies: Currency; currencies: Currency;
recorders: Recorder; recorders: Recorder;
keys: Key; keys: Key;
'payload-preferences': PayloadPreference; "payload-preferences": PayloadPreference;
'payload-migrations': PayloadMigration; "payload-migrations": PayloadMigration;
}; };
globals: {}; globals: {};
} }
@ -62,6 +62,7 @@ export interface LibraryFolder {
}[]; }[];
id?: string; id?: string;
}[]; }[];
parentFolders?: string[] | LibraryFolder[];
subfolders?: string[] | LibraryFolder[]; subfolders?: string[] | LibraryFolder[];
items?: string[] | LibraryItem[]; items?: string[] | LibraryItem[];
} }
@ -71,20 +72,20 @@ export interface Language {
} }
export interface LibraryItem { export interface LibraryItem {
id: string; id: string;
itemType?: 'Textual' | 'Audio' | 'Video' | 'Game' | 'Other'; itemType?: "Textual" | "Audio" | "Video" | "Game" | "Other";
digital: boolean;
slug: string; slug: string;
thumbnail?: string | LibraryItemThumbnail; thumbnail?: string | LibraryItemThumbnail;
pretitle?: string; pretitle?: string;
title: string; title: string;
subtitle?: string; subtitle?: string;
rootItem: boolean; digital: boolean;
primary: boolean;
gallery?: { gallery?: {
image?: string | LibraryItemGallery; image?: string | LibraryItemGallery;
id?: string; id?: string;
}[]; }[];
scansEnabled?: boolean;
scans?: { scans?: {
coverEnabled?: boolean;
cover?: { cover?: {
front?: string | LibraryItemScans; front?: string | LibraryItemScans;
spine?: string | LibraryItemScans; spine?: string | LibraryItemScans;
@ -94,8 +95,8 @@ export interface LibraryItem {
flapBack?: string | LibraryItemScans; flapBack?: string | LibraryItemScans;
insideFlapFront?: string | LibraryItemScans; insideFlapFront?: string | LibraryItemScans;
insideFlapBack?: string | LibraryItemScans; insideFlapBack?: string | LibraryItemScans;
id?: string; };
}[]; dustjacketEnabled?: boolean;
dustjacket?: { dustjacket?: {
front?: string | LibraryItemScans; front?: string | LibraryItemScans;
spine?: string | LibraryItemScans; spine?: string | LibraryItemScans;
@ -107,8 +108,8 @@ export interface LibraryItem {
flapBack?: string | LibraryItemScans; flapBack?: string | LibraryItemScans;
insideFlapFront?: string | LibraryItemScans; insideFlapFront?: string | LibraryItemScans;
insideFlapBack?: string | LibraryItemScans; insideFlapBack?: string | LibraryItemScans;
id?: string; };
}[]; obiEnabled?: boolean;
obi?: { obi?: {
front?: string | LibraryItemScans; front?: string | LibraryItemScans;
spine?: string | LibraryItemScans; spine?: string | LibraryItemScans;
@ -120,22 +121,19 @@ export interface LibraryItem {
flapBack?: string | LibraryItemScans; flapBack?: string | LibraryItemScans;
insideFlapFront?: string | LibraryItemScans; insideFlapFront?: string | LibraryItemScans;
insideFlapBack?: string | LibraryItemScans; insideFlapBack?: string | LibraryItemScans;
id?: string; };
}[];
pages?: { pages?: {
page: number; page: number;
image: string | LibraryItemScans; image: string | LibraryItemScans;
id?: string; id?: string;
}[]; }[];
downloadable: boolean; };
id?: string;
}[];
textual?: { textual?: {
subtype?: string[] | Key[]; subtype?: string[] | Key[];
languages?: string[] | Language[]; languages?: string[] | Language[];
pageCount?: number; pageCount?: number;
bindingType?: 'Paperback' | 'Hardcover'; bindingType?: "Paperback" | "Hardcover";
pageOrder?: 'LeftToRight' | 'RightToLeft'; pageOrder?: "LeftToRight" | "RightToLeft";
}; };
audio?: { audio?: {
audioSubtype?: string[] | Key[]; audioSubtype?: string[] | Key[];
@ -147,6 +145,17 @@ export interface LibraryItem {
}; };
releaseDate?: string; releaseDate?: string;
categories?: string[] | Key[]; categories?: string[] | Key[];
sizeEnabled?: boolean;
size?: {
width: number;
height: number;
thickness?: number;
};
priceEnabled?: boolean;
price?: {
amount: number;
currency: string | Currency;
};
translations?: { translations?: {
language: string | Language; language: string | Language;
description: { description: {
@ -154,21 +163,13 @@ export interface LibraryItem {
}[]; }[];
id?: string; id?: string;
}[]; }[];
size?: {
width: number;
height: number;
thickness?: number;
id?: string;
}[];
price?: {
amount: number;
currency: string | Currency;
id?: string;
}[];
urls?: { urls?: {
url: string; url: string;
id?: string; id?: string;
}[]; }[];
parentFolders?: string[] | LibraryFolder[];
parentItems?: string[] | LibraryItem[];
subitems?: string[] | LibraryItem[];
contents?: { contents?: {
content: string | Content; content: string | Content;
pageStart?: number; pageStart?: number;
@ -183,7 +184,7 @@ export interface LibraryItem {
updatedBy: string | Recorder; updatedBy: string | Recorder;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: 'draft' | 'published'; _status?: "draft" | "published";
} }
export interface LibraryItemThumbnail { export interface LibraryItemThumbnail {
id: string; id: string;
@ -301,22 +302,22 @@ export interface Key {
id: string; id: string;
name: string; name: string;
type: type:
| 'Contents' | "Contents"
| 'LibraryAudio' | "LibraryAudio"
| 'LibraryVideo' | "LibraryVideo"
| 'LibraryTextual' | "LibraryTextual"
| 'LibraryGroup' | "LibraryGroup"
| 'Library' | "Library"
| 'Weapons' | "Weapons"
| 'GamePlatforms' | "GamePlatforms"
| 'Categories' | "Categories"
| 'Wordings'; | "Wordings";
translations?: CategoryTranslations; translations?: CategoryTranslations;
} }
export interface File { export interface File {
id: string; id: string;
filename: string; filename: string;
type: 'LibraryScans' | 'LibrarySoundtracks' | 'ContentVideo' | 'ContentAudio'; type: "LibraryScans" | "LibrarySoundtracks" | "ContentVideo" | "ContentAudio";
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@ -329,6 +330,7 @@ export interface Content {
thumbnail?: string | ContentsThumbnail; thumbnail?: string | ContentsThumbnail;
categories?: string[] | Key[]; categories?: string[] | Key[];
type?: string | Key; type?: string | Key;
libraryItems?: string[] | LibraryItem[];
translations: { translations: {
language: string | Language; language: string | Language;
sourceLanguage: string | Language; sourceLanguage: string | Language;
@ -354,10 +356,13 @@ export interface Content {
audio?: string | File; audio?: string | File;
id?: string; id?: string;
}[]; }[];
folders?: string[] | ContentsFolder[];
previousContents?: string[] | Content[];
nextContents?: string[] | Content[];
updatedBy: string | Recorder; updatedBy: string | Recorder;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: 'draft' | 'published'; _status?: "draft" | "published";
} }
export interface ContentsThumbnail { export interface ContentsThumbnail {
id: string; id: string;
@ -403,7 +408,7 @@ export interface Recorder {
avatar?: string | RecordersThumbnail; avatar?: string | RecordersThumbnail;
languages?: string[] | Language[]; languages?: string[] | Language[];
biographies?: RecorderBiographies; biographies?: RecorderBiographies;
role?: ('Admin' | 'Recorder' | 'Api')[]; role?: ("Admin" | "Recorder" | "Api")[];
anonymize: boolean; anonymize: boolean;
email: string; email: string;
resetPasswordToken?: string; resetPasswordToken?: string;
@ -461,20 +466,20 @@ export interface Post {
thumbnail?: string | PostThumbnail; thumbnail?: string | PostThumbnail;
authors: authors:
| { | {
relationTo: 'recorders'; relationTo: "recorders";
value: string; value: string;
}[] }[]
| { | {
relationTo: 'recorders'; relationTo: "recorders";
value: Recorder; value: Recorder;
}[]; }[];
categories?: categories?:
| { | {
relationTo: 'keys'; relationTo: "keys";
value: string; value: string;
}[] }[]
| { | {
relationTo: 'keys'; relationTo: "keys";
value: Key; value: Key;
}[]; }[];
translations: { translations: {
@ -484,11 +489,11 @@ export interface Post {
summary?: { summary?: {
[k: string]: unknown; [k: string]: unknown;
}[]; }[];
translators?: string[] | Recorder[];
proofreaders?: string[] | Recorder[];
content?: { content?: {
[k: string]: unknown; [k: string]: unknown;
}[]; }[];
translators?: string[] | Recorder[];
proofreaders?: string[] | Recorder[];
id?: string; id?: string;
}[]; }[];
publishedDate: string; publishedDate: string;
@ -496,7 +501,7 @@ export interface Post {
updatedBy: string | Recorder; updatedBy: string | Recorder;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: 'draft' | 'published'; _status?: "draft" | "published";
} }
export interface PostThumbnail { export interface PostThumbnail {
id: string; id: string;
@ -547,11 +552,11 @@ export interface ChronologyItem {
events: { events: {
source?: source?:
| { | {
relationTo: 'contents'; relationTo: "contents";
value: string | Content; value: string | Content;
} }
| { | {
relationTo: 'library-items'; relationTo: "library-items";
value: string | LibraryItem; value: string | LibraryItem;
}; };
translations: { translations: {
@ -574,7 +579,7 @@ export interface ChronologyItem {
updatedBy: string | Recorder; updatedBy: string | Recorder;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: 'draft' | 'published'; _status?: "draft" | "published";
} }
export interface ChronologyEra { export interface ChronologyEra {
id: string; id: string;
@ -630,7 +635,7 @@ export interface Weapon {
updatedBy: string | Recorder; updatedBy: string | Recorder;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: 'draft' | 'published'; _status?: "draft" | "published";
} }
export interface WeaponsThumbnail { export interface WeaponsThumbnail {
id: string; id: string;
@ -700,7 +705,7 @@ export interface Video {
id: string; id: string;
uid: string; uid: string;
gone: boolean; gone: boolean;
source: 'YouTube' | 'NicoNico' | 'Tumblr'; source: "YouTube" | "NicoNico" | "Tumblr";
title: string; title: string;
description?: string; description?: string;
likes?: number; likes?: number;
@ -717,7 +722,7 @@ export interface VideosChannel {
export interface PayloadPreference { export interface PayloadPreference {
id: string; id: string;
user: { user: {
relationTo: 'recorders'; relationTo: "recorders";
value: string | Recorder; value: string | Recorder;
}; };
key?: string; key?: string;
@ -741,7 +746,6 @@ export interface PayloadMigration {
createdAt: string; createdAt: string;
} }
declare module "payload" {
declare module 'payload' {
export interface GeneratedTypes extends Config {} export interface GeneratedTypes extends Config {}
} }

View File

@ -7,6 +7,7 @@ export const findWeaponType = async (name: string): Promise<string> => {
const key = await payload.find({ const key = await payload.find({
collection: Collections.Keys, collection: Collections.Keys,
where: { name: { equals: name }, type: { equals: KeysTypes.Weapons } }, where: { name: { equals: name }, type: { equals: KeysTypes.Weapons } },
depth: 0,
}); });
if (!key.docs[0]) throw new Error(`Weapon type ${name} wasn't found`); if (!key.docs[0]) throw new Error(`Weapon type ${name} wasn't found`);
return key.docs[0].id; return key.docs[0].id;
@ -16,27 +17,40 @@ export const findCategory = async (name: string): Promise<string> => {
const key = await payload.find({ const key = await payload.find({
collection: Collections.Keys, collection: Collections.Keys,
where: { name: { equals: name }, type: { equals: KeysTypes.Categories } }, where: { name: { equals: name }, type: { equals: KeysTypes.Categories } },
depth: 0,
}); });
if (!key.docs[0]) throw new Error(`Category ${name} wasn't found`); if (!key.docs[0]) throw new Error(`Category ${name} wasn't found`);
return key.docs[0]?.id; return key.docs[0].id;
}; };
export const findRecorder = async (name: string): Promise<string> => { export const findRecorder = async (name: string): Promise<string> => {
const recorder = await payload.find({ const recorder = await payload.find({
collection: Collections.Recorders, collection: Collections.Recorders,
where: { username: { equals: name } }, where: { username: { equals: name } },
depth: 0,
}); });
if (!recorder.docs[0]) throw new Error(`Recorder ${name} wasn't found`); if (!recorder.docs[0]) throw new Error(`Recorder ${name} wasn't found`);
return recorder.docs[0]?.id; return recorder.docs[0].id;
}; };
export const findContentType = async (name: string): Promise<string> => { export const findContentType = async (name: string): Promise<string> => {
const key = await payload.find({ const key = await payload.find({
collection: Collections.Keys, collection: Collections.Keys,
where: { name: { equals: name }, type: { equals: KeysTypes.Contents } }, where: { name: { equals: name }, type: { equals: KeysTypes.Contents } },
depth: 0,
}); });
if (!key.docs[0]) throw new Error(`Content type ${name} wasn't found`); if (!key.docs[0]) throw new Error(`Content type ${name} wasn't found`);
return key.docs[0]?.id; return key.docs[0].id;
};
export const findContent = async (slug: string): Promise<string> => {
const content = await payload.find({
collection: Collections.Contents,
where: { slug: { equals: slug } },
depth: 0,
});
if (!content.docs[0]) throw new Error(`Content ${slug} wasn't found`);
return content.docs[0].id;
}; };
type UploadStrapiImage = { type UploadStrapiImage = {

View File

@ -7,7 +7,7 @@ export const shortenEllipsis = (text: string, length: number): string =>
export const formatLanguageCode = (code: string): string => export const formatLanguageCode = (code: string): string =>
tags(code).valid() ? tags(code).language()?.descriptions()[0] ?? code : code; tags(code).valid() ? tags(code).language()?.descriptions()[0] ?? code : code;
const capitalize = (string: string): string => { export const capitalize = (string: string): string => {
const [firstLetter, ...otherLetters] = string; const [firstLetter, ...otherLetters] = string;
if (isUndefined(firstLetter)) return ""; if (isUndefined(firstLetter)) return "";
return [firstLetter.toUpperCase(), ...otherLetters].join(""); return [firstLetter.toUpperCase(), ...otherLetters].join("");

View File

@ -14,7 +14,7 @@ const updatedByField = (): RelationshipField => ({
type: "relationship", type: "relationship",
required: true, required: true,
relationTo: Collections.Recorders, relationTo: Collections.Recorders,
admin: { readOnly: true }, admin: { readOnly: true, hidden: true },
}); });
type BuildVersionedCollectionConfig = Omit<BuildCollectionConfig, "timestamps" | "versions">; type BuildVersionedCollectionConfig = Omit<BuildCollectionConfig, "timestamps" | "versions">;