diff --git a/package-lock.json b/package-lock.json index 595738e..ff48c97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "dotenv": "^8.2.0", "express": "^4.17.1", "iso-639-1": "^2.1.15", - "payload": "^1.11.1" + "payload": "^1.11.1", + "slugify": "^1.6.6" }, "devDependencies": { "@types/express": "^4.17.9", @@ -8504,6 +8505,14 @@ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.6.tgz", "integrity": "sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA==" }, + "node_modules/slugify": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", + "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", diff --git a/package.json b/package.json index 71e9f3f..e90ed28 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "dotenv": "^8.2.0", "express": "^4.17.1", "iso-639-1": "^2.1.15", - "payload": "^1.11.1" + "payload": "^1.11.1", + "slugify": "^1.6.6" }, "devDependencies": { "@types/express": "^4.17.9", diff --git a/src/collections/Categories/Categories.ts b/src/collections/Categories/Categories.ts deleted file mode 100644 index 166fd3f..0000000 --- a/src/collections/Categories/Categories.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { CollectionConfig } from "payload/types"; -import { localizedFields } from "../../elements/translatedFields/translatedFields"; - -const fields = { - id: "id", - translations: "translations", - name: "name", - short: "short", -} as const satisfies Record; - -const labels = { - singular: "Category", - plural: "Categories", -} as const satisfies { singular: string; plural: string }; - -export const Categories: CollectionConfig = { - slug: labels.plural, - labels, - typescript: { interface: labels.singular }, - defaultSort: fields.id, - admin: { - useAsTitle: fields.id, - defaultColumns: [fields.id, fields.translations], - }, - timestamps: false, - fields: [ - { - name: fields.id, - type: "text", - }, - localizedFields({ - name: fields.translations, - interfaceName: "CategoryTranslations", - admin: { - useAsTitle: fields.name, - }, - fields: [ - { - type: "row", - fields: [ - { name: fields.name, type: "text", required: true }, - { name: fields.short, type: "text" }, - ], - }, - ], - }), - ], -}; diff --git a/src/collections/Contents/Blocks/blocks.ts b/src/collections/Contents/Blocks/blocks.ts new file mode 100644 index 0000000..c052ce5 --- /dev/null +++ b/src/collections/Contents/Blocks/blocks.ts @@ -0,0 +1,169 @@ +import { Block, BlockField } from "payload/types"; +import { cueBlock } from "./cueBlock"; +import { textBlock } from "./textBlock"; +import { transcriptBlock } from "./transcriptBlock"; +import { lineBlock } from "./lineBlock"; + +const INITIAL_DEPTH = 1; +const MAX_DEPTH = 4; + +enum BlockName { + Text = "Text", + Section = "Section", + Tabs = "Tabs", + Tab = "Tab", + Columns = "Columns", + Column = "Column", + Transcript = "Transcript", + Collapsible = "Collapsible", + Accordion = "Accordion", + Line = "Line", + Cue = "Cue", +} + +const rootBlocksNames: BlockName[] = [ + BlockName.Section, + BlockName.Collapsible, + BlockName.Columns, + BlockName.Tabs, + BlockName.Accordion, + BlockName.Text, + BlockName.Transcript, +]; + + +const recursiveBlocks: BlockName[] = [ + BlockName.Section, + BlockName.Collapsible, + BlockName.Accordion, + BlockName.Tabs, + BlockName.Tab, + BlockName.Column, + BlockName.Columns, +]; + +const blocksChildren: Record = { + Tabs: [BlockName.Tab], + Columns: [BlockName.Column], + Section: rootBlocksNames, + Collapsible: rootBlocksNames, + Tab: rootBlocksNames, + Column: rootBlocksNames, + Accordion: [BlockName.Collapsible], + Text: [], + Transcript: [BlockName.Line, BlockName.Cue], + Cue: [], + Line: [], +}; + +export type RecursiveBlock = Omit & { + fields: Omit & { + newDepth: (currentDepth: number) => number; + blocks: BlockName[]; + }; +}; + +// TODO: Check for loops in the block graph instead of manually defining recursive blocks +const isNotRecursiveBlock = (name: BlockName): boolean => !recursiveBlocks.includes(name); + +const implementationForRecursiveBlocks = ( + currentDepth: number, + { slug, interfaceName, labels, fields: { newDepth, blocks, ...fieldsProps } }: RecursiveBlock +): Block => ({ + slug: [slug, currentDepth].join("_"), + interfaceName: [interfaceName, currentDepth].join("_"), + labels, + fields: [ + { + ...fieldsProps, + type: "blocks", + blocks: blocks + .filter((block) => { + if (currentDepth < MAX_DEPTH) return true; + if (blocks.filter(isNotRecursiveBlock).length === 0) return true; + return isNotRecursiveBlock(block); + }) + .map((block) => implementations[block](newDepth(currentDepth))), + }, + ], +}); + +const implementations: Record Block> = { + Cue: () => cueBlock, + Text: () => textBlock, + Transcript: () => transcriptBlock, + Line: () => lineBlock, + Section: (currentDepth) => + implementationForRecursiveBlocks(currentDepth, { + slug: "section", + interfaceName: "Section", + labels: { singular: "Section", plural: "Sections" }, + fields: { + name: "content", + newDepth: (depth) => depth + 1, + blocks: blocksChildren.Section, + }, + }), + Accordion: (currentDepth) => + implementationForRecursiveBlocks(currentDepth, { + slug: "accordion", + interfaceName: "Accordion", + labels: { singular: "Accordion", plural: "Accordions" }, + fields: { + name: "content", + newDepth: (depth) => depth + 1, + blocks: blocksChildren.Accordion, + }, + }), + Collapsible: (currentDepth) => + implementationForRecursiveBlocks(currentDepth, { + slug: "collapsible", + interfaceName: "Collapsible", + labels: { singular: "Collapsible", plural: "Collapsibles" }, + fields: { + name: "content", + newDepth: (depth) => depth + 1, + blocks: blocksChildren.Collapsible, + }, + }), + Tabs: (currentDepth) => + implementationForRecursiveBlocks(currentDepth, { + slug: "tabs", + interfaceName: "Tabs", + labels: { singular: "Tabs", plural: "Tabs" }, + fields: { name: "tabs", newDepth: (depth) => depth, blocks: blocksChildren.Tabs }, + }), + Tab: (currentDepth) => + implementationForRecursiveBlocks(currentDepth, { + slug: "tab", + interfaceName: "Tab", + labels: { singular: "Tab", plural: "Tabs" }, + fields: { + name: "content", + newDepth: (depth) => depth + 1, + blocks: blocksChildren.Tab, + }, + }), + Columns: (currentDepth) => + implementationForRecursiveBlocks(currentDepth, { + slug: "columns", + interfaceName: "Columns", + labels: { singular: "Columns", plural: "Columns" }, + fields: { + name: "columns", + newDepth: (depth) => depth, + blocks: blocksChildren.Columns, + }, + }), + Column: (currentDepth) => + implementationForRecursiveBlocks(currentDepth, { + slug: "column", + interfaceName: "Column", + labels: { singular: "Column", plural: "Columns" }, + fields: { name: "content", newDepth: (depth) => depth + 1, blocks: blocksChildren.Column }, + }), +}; + +export const rootBlocks: Block[] = rootBlocksNames + .filter((block) => block in implementations) + .map((block) => implementations[block](INITIAL_DEPTH)); diff --git a/src/collections/Contents/Blocks/cueBlock.ts b/src/collections/Contents/Blocks/cueBlock.ts new file mode 100644 index 0000000..ded0b89 --- /dev/null +++ b/src/collections/Contents/Blocks/cueBlock.ts @@ -0,0 +1,19 @@ +import { Block } from "payload/types"; + +export const cueBlock: Block = { + slug: "cueBlock", + interfaceName: "CueBlock", + labels: { singular: "Cue", plural: "Cues" }, + fields: [ + { + name: "content", + label: false, + type: "textarea", + required: true, + admin: { + description: + "Parenthesis will automatically be added around cues. You don't have to include them here.", + }, + }, + ], +}; diff --git a/src/collections/Contents/Blocks/lineBlock.ts b/src/collections/Contents/Blocks/lineBlock.ts new file mode 100644 index 0000000..fd27574 --- /dev/null +++ b/src/collections/Contents/Blocks/lineBlock.ts @@ -0,0 +1,20 @@ +import { Block } from "payload/types"; + +export const lineBlock: Block = { + slug: "lineBlock", + interfaceName: "LineBlock", + labels: { singular: "Line", plural: "Lines" }, + fields: [ + { + name: "content", + label: false, + type: "richText", + required: true, + admin: { + hideGutter: true, + elements: [], + leaves: ["bold", "italic", "underline", "strikethrough", "code"], + }, + }, + ], +}; diff --git a/src/collections/Contents/Blocks/textBlock.ts b/src/collections/Contents/Blocks/textBlock.ts new file mode 100644 index 0000000..781566c --- /dev/null +++ b/src/collections/Contents/Blocks/textBlock.ts @@ -0,0 +1,19 @@ +import { Block } from "payload/types"; + +export const textBlock: Block = { + slug: "textBlock", + interfaceName: "TextBlock", + labels: { singular: "Text", plural: "Texts" }, + fields: [ + { + name: "content", + type: "richText", + label: false, + required: true, + admin: { + hideGutter: true, + elements: ["ul", "ol", "indent", "link", "relationship", "upload", "blockquote"], + }, + }, + ], +}; diff --git a/src/collections/Contents/Blocks/transcriptBlock.ts b/src/collections/Contents/Blocks/transcriptBlock.ts new file mode 100644 index 0000000..60deb94 --- /dev/null +++ b/src/collections/Contents/Blocks/transcriptBlock.ts @@ -0,0 +1,19 @@ +import { Block } from "payload/types"; +import { lineBlock } from "./lineBlock"; +import { cueBlock } from "./cueBlock"; + +export const transcriptBlock: Block = { + slug: "transcriptBlock", + interfaceName: "TranscriptBlock", + labels: { singular: "Transcript", plural: "Transcripts" }, + fields: [ + { + name: "lines", + type: "blocks", + required: true, + minRows: 1, + admin: { initCollapsed: true }, + blocks: [lineBlock, cueBlock], + }, + ], +}; diff --git a/src/collections/Contents/Contents.ts b/src/collections/Contents/Contents.ts new file mode 100644 index 0000000..a7c2d2b --- /dev/null +++ b/src/collections/Contents/Contents.ts @@ -0,0 +1,208 @@ +import { CollectionConfig } from "payload/types"; +import { collectionSlug } from "../../utils/string"; +import { CollectionGroup, FileTypes, TagsTypes } from "../../constants"; +import { slugField } from "../../fields/slugField/slugField"; +import { imageField } from "../../fields/imageField/imageField"; +import { Tags } from "../Tags/Tags"; +import { localizedFields } from "../../fields/translatedFields/translatedFields"; +import { Recorders } from "../Recorders/Recorders"; +import { isDefined } from "../../utils/asserts"; +import { fileField } from "../../fields/fileField/fileField"; +import { rootBlocks } from "./Blocks/blocks"; + +const fields = { + slug: "slug", + thumbnail: "thumbnail", + categories: "categories", + type: "type", + translations: "translations", + pretitle: "pretitle", + title: "title", + subtitle: "subtitle", + summary: "summary", + textContent: "textContent", + textTranscribers: "textTranscribers", + textTranslators: "textTranslators", + textProofreaders: "textProofreaders", + textNotes: "textNotes", + video: "video", + videoNotes: "videoNotes", + audio: "audio", + audioNotes: "videoNotes", +} as const satisfies Record; + +const labels = { + singular: "Content", + plural: "Contents", +} as const satisfies { singular: string; plural: string }; + +export const Contents: CollectionConfig = { + slug: collectionSlug(labels.plural), + labels, + typescript: { interface: labels.singular }, + defaultSort: fields.slug, + admin: { + useAsTitle: fields.slug, + defaultColumns: [fields.slug, fields.thumbnail, fields.categories], + group: CollectionGroup.Collections, + preview: (doc) => `https://accords-library.com/contents/${doc.slug}`, + }, + timestamps: true, + versions: { drafts: true }, + fields: [ + { + type: "row", + fields: [ + slugField({ name: fields.slug, admin: { width: "50%" } }), + imageField({ name: fields.thumbnail, admin: { width: "50%" } }), + ], + }, + { + name: "testing", + type: "blocks", + admin: { initCollapsed: true }, + blocks: rootBlocks, + }, + { + type: "row", + fields: [ + { + name: fields.categories, + type: "relationship", + relationTo: [Tags.slug], + filterOptions: { type: { equals: TagsTypes.Categories } }, + hasMany: true, + admin: { allowCreate: false, width: "50%" }, + }, + { + name: fields.type, + type: "relationship", + relationTo: [Tags.slug], + filterOptions: { type: { equals: TagsTypes.Contents } }, + admin: { allowCreate: false, width: "50%" }, + }, + ], + }, + localizedFields({ + name: fields.translations, + admin: { useAsTitle: fields.title, hasSourceLanguage: true }, + required: true, + minRows: 1, + fields: [ + { + type: "row", + fields: [ + { name: fields.pretitle, type: "text" }, + { name: fields.title, type: "text", required: true }, + { name: fields.subtitle, type: "text" }, + ], + }, + { name: fields.summary, type: "textarea" }, + { + type: "tabs", + admin: { + condition: (_, siblingData) => + isDefined(siblingData.language) && isDefined(siblingData.sourceLanguage), + }, + tabs: [ + { + label: "Text", + fields: [ + { + type: "row", + fields: [ + { + name: fields.textTranscribers, + label: "Transcribers", + type: "relationship", + relationTo: Recorders.slug, + hasMany: true, + admin: { + condition: (_, siblingData) => + siblingData.language === siblingData.sourceLanguage, + width: "50%", + }, + }, + { + name: fields.textTranslators, + label: "Translators", + type: "relationship", + relationTo: Recorders.slug, + hasMany: true, + admin: { + condition: (_, siblingData) => + siblingData.language !== siblingData.sourceLanguage, + width: "50%", + }, + }, + { + name: fields.textProofreaders, + label: "Proofreaders", + type: "relationship", + relationTo: Recorders.slug, + hasMany: true, + admin: { width: "50%" }, + }, + ], + }, + { + name: fields.textContent, + label: "Content", + type: "richText", + admin: { hideGutter: true }, + }, + { + name: fields.textNotes, + label: "Notes", + type: "textarea", + }, + ], + }, + { + label: "Video", + fields: [ + { + type: "row", + fields: [ + fileField({ + name: fields.video, + filterOptions: { type: { equals: FileTypes.ContentVideo } }, + admin: { width: "50%" }, + }), + { + name: fields.videoNotes, + label: "Notes", + type: "textarea", + admin: { width: "50%" }, + }, + ], + }, + ], + }, + { + label: "Audio", + fields: [ + { + type: "row", + fields: [ + fileField({ + name: fields.audio, + filterOptions: { type: { equals: FileTypes.ContentAudio } }, + admin: { width: "50%" }, + }), + { + name: fields.audioNotes, + label: "Notes", + type: "textarea", + admin: { width: "50%" }, + }, + ], + }, + ], + }, + ], + }, + ], + }), + ], +}; diff --git a/src/collections/Files/Files.ts b/src/collections/Files/Files.ts new file mode 100644 index 0000000..a164561 --- /dev/null +++ b/src/collections/Files/Files.ts @@ -0,0 +1,38 @@ +import { CollectionConfig } from "payload/types"; +import { CollectionGroup, FileTypes } from "../../constants"; +import { collectionSlug } from "../../utils/string"; + +const fields = { + filename: "filename", + type: "type", +} as const satisfies Record; + +const labels = { + singular: "File", + plural: "Files", +} as const satisfies { singular: string; plural: string }; + +export const Files: CollectionConfig = { + slug: collectionSlug(labels.plural), + labels, + typescript: { interface: labels.singular }, + defaultSort: fields.filename, + admin: { + useAsTitle: fields.filename, + group: CollectionGroup.Media, + }, + + fields: [ + { + name: fields.filename, + required: true, + type: "text", + }, + { + name: fields.type, + type: "select", + required: true, + options: Object.entries(FileTypes).map(([value, label]) => ({ label, value })), + }, + ], +}; diff --git a/src/collections/Images/Images.ts b/src/collections/Images/Images.ts index 73ae365..56b9a33 100644 --- a/src/collections/Images/Images.ts +++ b/src/collections/Images/Images.ts @@ -1,4 +1,6 @@ import { CollectionConfig } from "payload/types"; +import { CollectionGroup } from "../../constants"; +import { collectionSlug } from "../../utils/string"; const fields = { filename: "filename", @@ -13,13 +15,13 @@ const labels = { } as const satisfies { singular: string; plural: string }; export const Images: CollectionConfig = { - slug: labels.plural, + slug: collectionSlug(labels.plural), labels, typescript: { interface: labels.singular }, defaultSort: fields.filename, admin: { useAsTitle: fields.filename, - group: "Media", + group: CollectionGroup.Media, }, upload: { diff --git a/src/collections/Languages.ts b/src/collections/Languages.ts index e2d551b..0d93b69 100644 --- a/src/collections/Languages.ts +++ b/src/collections/Languages.ts @@ -1,4 +1,6 @@ import { CollectionConfig } from "payload/types"; +import { CollectionGroup } from "../constants"; +import { collectionSlug } from "../utils/string"; const fields = { id: "id", @@ -11,13 +13,14 @@ const labels = { } as const satisfies { singular: string; plural: string }; export const Languages: CollectionConfig = { - slug: labels.plural, + slug: collectionSlug(labels.plural), labels, typescript: { interface: labels.singular }, defaultSort: fields.name, admin: { useAsTitle: fields.name, defaultColumns: [fields.name, fields.id], + group: CollectionGroup.Meta, }, timestamps: false, fields: [ diff --git a/src/collections/LibraryItems/LibraryItems.ts b/src/collections/LibraryItems/LibraryItems.ts new file mode 100644 index 0000000..364f440 --- /dev/null +++ b/src/collections/LibraryItems/LibraryItems.ts @@ -0,0 +1,148 @@ +import { CollectionConfig } from "payload/types"; +import { CollectionGroup } from "../../constants"; +import { slugField } from "../../fields/slugField/slugField"; +import { imageField } from "../../fields/imageField/imageField"; +import { collectionSlug } from "../../utils/string"; +import { isDefined, isUndefined } from "../../utils/asserts"; + +const fields = { + status: "status", + slug: "slug", + thumbnail: "thumbnail", + pretitle: "pretitle", + title: "title", + subtitle: "subtitle", + rootItem: "rootItem", + primary: "primary", + digital: "digital", + downloadable: "downloadable", + size: "size", + width: "width", + height: "height", + thickness: "thickness", +} as const satisfies Record; + +const labels = { + singular: "Library Item", + plural: "Library Items", +} as const satisfies { singular: string; plural: string }; + +const validateSizeValue = (value?: number) => { + if (isDefined(value) && value <= 0) return "This value must be greater than 0"; + return true; +}; + +const validateRequiredSizeValue = (value?: number) => { + if (isUndefined(value)) return "This field is required."; + if (value <= 0) return "This value must be greater than 0."; + return true; +}; + +export const LibraryItems: CollectionConfig = { + slug: collectionSlug(labels.plural), + labels, + typescript: { interface: labels.singular }, + defaultSort: fields.slug, + admin: { + useAsTitle: fields.slug, + defaultColumns: [fields.slug, fields.thumbnail, fields.status], + group: CollectionGroup.Collections, + preview: (doc) => `https://accords-library.com/library/${doc.slug}`, + }, + timestamps: true, + versions: { drafts: true }, + fields: [ + { + type: "row", + fields: [ + slugField({ name: fields.slug, admin: { width: "50%" } }), + imageField({ name: fields.thumbnail, admin: { width: "50%" } }), + ], + }, + { + type: "row", + fields: [ + { name: fields.pretitle, type: "text" }, + { name: fields.title, type: "text", required: true }, + { name: fields.subtitle, type: "text" }, + ], + }, + { + type: "row", + fields: [ + { + name: fields.rootItem, + type: "checkbox", + required: true, + defaultValue: true, + admin: { + description: "Only items that can be sold separetely should be root items.", + width: "25%", + }, + }, + { + name: fields.primary, + type: "checkbox", + required: true, + defaultValue: true, + admin: { + description: + "A primary item is an official item that focuses primarly on one or more of our Categories.", + width: "25%", + }, + }, + { + name: fields.digital, + type: "checkbox", + required: true, + defaultValue: false, + admin: { + description: + "The item is the digital version of another item, or the item is sold only digitally.", + width: "25%", + }, + }, + { + name: fields.downloadable, + type: "checkbox", + required: true, + defaultValue: false, + admin: { + description: "Are the scans available for download?", + width: "25%", + }, + }, + ], + }, + { + name: "size", + type: "group", + admin: { condition: (data) => !data.digital }, + fields: [ + { + type: "row", + fields: [ + { + name: fields.width, + type: "number", + validate: validateRequiredSizeValue, + admin: { step: 1, width: "33%", description: "in mm." }, + }, + { + name: fields.height, + type: "number", + validate: validateRequiredSizeValue, + admin: { step: 1, width: "33%", description: "in mm." }, + }, + { + name: fields.thickness, + type: "number", + validate: validateSizeValue, + admin: { step: 1, width: "33%", description: "in mm." }, + }, + ], + }, + ], + }, + ], +}; diff --git a/src/collections/Posts/Posts.ts b/src/collections/Posts/Posts.ts new file mode 100644 index 0000000..1317fdf --- /dev/null +++ b/src/collections/Posts/Posts.ts @@ -0,0 +1,142 @@ +import { CollectionConfig } from "payload/types"; +import { slugField } from "../../fields/slugField/slugField"; +import { imageField } from "../../fields/imageField/imageField"; +import { CollectionGroup, TagsTypes } from "../../constants"; +import { Recorders } from "../Recorders/Recorders"; +import { localizedFields } from "../../fields/translatedFields/translatedFields"; +import { isDefined, isUndefined } from "../../utils/asserts"; +import { removeTranslatorsForTranscripts } from "./hooks/beforeValidate"; +import { Tags } from "../Tags/Tags"; +import { collectionSlug } from "../../utils/string"; + +const fields = { + slug: "slug", + thumbnail: "thumbnail", + categories: "categories", + authors: "authors", + publishedDate: "publishedDate", + translations: "translations", + sourceLanguage: "sourceLanguage", + title: "title", + summary: "summary", + content: "content", + translators: "translators", + proofreaders: "proofreaders", +} as const satisfies Record; + +const labels = { + singular: "Post", + plural: "Posts", +} as const satisfies { singular: string; plural: string }; + +export const Posts: CollectionConfig = { + slug: collectionSlug(labels.plural), + labels, + typescript: { interface: labels.singular }, + defaultSort: fields.slug, + admin: { + useAsTitle: fields.slug, + defaultColumns: [fields.slug, fields.thumbnail, fields.categories], + group: CollectionGroup.Collections, + preview: (doc) => `https://accords-library.com/news/${doc.slug}`, + }, + hooks: { + beforeValidate: [removeTranslatorsForTranscripts], + }, + timestamps: true, + versions: { drafts: true }, + fields: [ + { + type: "row", + fields: [ + slugField({ name: fields.slug, admin: { width: "50%" } }), + imageField({ name: fields.thumbnail, admin: { width: "50%" } }), + ], + }, + { + type: "row", + fields: [ + { + name: fields.authors, + type: "relationship", + relationTo: [Recorders.slug], + required: true, + minRows: 1, + hasMany: true, + admin: { width: "50%" }, + }, + { + name: fields.categories, + type: "relationship", + relationTo: [Tags.slug], + filterOptions: { type: { equals: TagsTypes.Categories } }, + hasMany: true, + admin: { allowCreate: false, width: "50%" }, + }, + ], + }, + localizedFields({ + name: fields.translations, + admin: { useAsTitle: fields.title, hasSourceLanguage: true }, + required: true, + minRows: 1, + fields: [ + { name: fields.title, type: "text", required: true }, + { name: fields.summary, type: "textarea" }, + { + type: "row", + fields: [ + { + name: fields.translators, + type: "relationship", + relationTo: Recorders.slug, + hasMany: true, + admin: { + condition: (_, siblingData) => { + if ( + isUndefined(siblingData.language) || + isUndefined(siblingData.sourceLanguage) + ) { + return false; + } + return siblingData.language !== siblingData.sourceLanguage; + }, + width: "50%", + }, + 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: Recorders.slug, + hasMany: true, + admin: { width: "50%" }, + }, + ], + }, + { name: fields.content, type: "richText", admin: { hideGutter: true } }, + ], + }), + { + name: fields.publishedDate, + type: "date", + defaultValue: new Date().toISOString(), + admin: { + date: { pickerAppearance: "dayOnly", displayFormat: "yyyy-MM-dd" }, + position: "sidebar", + }, + required: true, + }, + ], +}; diff --git a/src/collections/Posts/hooks/beforeValidate.ts b/src/collections/Posts/hooks/beforeValidate.ts new file mode 100644 index 0000000..817a205 --- /dev/null +++ b/src/collections/Posts/hooks/beforeValidate.ts @@ -0,0 +1,14 @@ +import { CollectionBeforeValidateHook } from "payload/types"; +import { Post } from "../../../types/collections"; + +export const removeTranslatorsForTranscripts: CollectionBeforeValidateHook = async ({ + data: { translations, ...data }, +}) => ({ + ...data, + translations: translations.map(({ translators, ...translation }) => { + if (translation.language === translation.sourceLanguage) { + return { ...translation, translators: [] }; + } + return { ...translation, translators }; + }), +}); diff --git a/src/collections/Recorders/Recorders.ts b/src/collections/Recorders/Recorders.ts index 3dfc35e..1a295b4 100644 --- a/src/collections/Recorders/Recorders.ts +++ b/src/collections/Recorders/Recorders.ts @@ -1,9 +1,12 @@ import { CollectionConfig } from "payload/types"; -import { localizedFields } from "../../elements/translatedFields/translatedFields"; +import { localizedFields } from "../../fields/translatedFields/translatedFields"; import { Languages } from "../Languages"; import { Images } from "../Images/Images"; -import { ImageCell } from "../Images/components/ImageCell"; +import { Cell } from "../../fields/imageField/Cell"; import { beforeDuplicate } from "./hooks/beforeDuplicate"; +import { imageField } from "../../fields/imageField/imageField"; +import { CollectionGroup } from "../../constants"; +import { collectionSlug } from "../../utils/string"; const fields = { username: "username", @@ -20,7 +23,7 @@ const labels = { } as const satisfies { singular: string; plural: string }; export const Recorders: CollectionConfig = { - slug: labels.plural, + slug: collectionSlug(labels.plural), labels, typescript: { interface: labels.singular }, defaultSort: fields.username, @@ -30,22 +33,14 @@ export const Recorders: CollectionConfig = { description: "Recorders are contributors of the Accord's Library project. Create a Recorder here to be able to credit them in other collections", defaultColumns: [fields.username, fields.anonymize, fields.biographies, fields.languages], + group: CollectionGroup.Meta, }, timestamps: false, fields: [ { type: "row", fields: [ - { - name: fields.avatar, - type: "upload", - relationTo: Images.slug, - admin: { - components: { - Cell: ImageCell, - }, - }, - }, + imageField({ name: fields.avatar }), { name: fields.username, type: "text", diff --git a/src/collections/Tags/Tags.ts b/src/collections/Tags/Tags.ts new file mode 100644 index 0000000..4ded066 --- /dev/null +++ b/src/collections/Tags/Tags.ts @@ -0,0 +1,48 @@ +import { CollectionConfig } from "payload/types"; +import { slugField } from "../../fields/slugField/slugField"; +import { CollectionGroup, TagsTypes } from "../../constants"; +import { localizedFields } from "../../fields/translatedFields/translatedFields"; +import { collectionSlug } from "../../utils/string"; + +const fields = { + slug: "slug", + translations: "translations", + type: "type", + name: "name", +} as const satisfies Record; + +const labels = { + singular: "Tag", + plural: "Tags", +} as const satisfies { singular: string; plural: string }; + +export const Tags: CollectionConfig = { + slug: collectionSlug(labels.plural), + labels, + typescript: { interface: labels.singular }, + defaultSort: fields.slug, + admin: { + useAsTitle: fields.slug, + defaultColumns: [fields.slug, fields.type, fields.translations], + group: CollectionGroup.Meta, + }, + timestamps: false, + versions: false, + fields: [ + slugField({ name: fields.slug }), + { + name: fields.type, + type: "select", + required: true, + options: Object.entries(TagsTypes).map(([value, label]) => ({ label, value })), + }, + localizedFields({ + name: fields.translations, + interfaceName: "CategoryTranslations", + admin: { + useAsTitle: fields.name, + }, + fields: [{ name: fields.name, type: "text", required: true }], + }), + ], +}; diff --git a/src/collections/Users.ts b/src/collections/Users.ts index 7d9c229..87f68b6 100644 --- a/src/collections/Users.ts +++ b/src/collections/Users.ts @@ -1,4 +1,6 @@ import { CollectionConfig } from "payload/types"; +import { CollectionGroup } from "../constants"; +import { collectionSlug } from "../utils/string"; const fields = { email: "email", @@ -10,7 +12,7 @@ const labels = { } as const satisfies { singular: string; plural: string }; export const Users: CollectionConfig = { - slug: labels.plural, + slug: collectionSlug(labels.plural), auth: true, labels, typescript: { interface: labels.singular }, @@ -18,7 +20,7 @@ export const Users: CollectionConfig = { admin: { useAsTitle: fields.email, defaultColumns: [fields.email], - group: "Administration", + group: CollectionGroup.Administration, }, timestamps: false, fields: [], diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..360d235 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,25 @@ +export enum CollectionGroup { + Collections = "Collections", + Media = "Media", + Meta = "Meta", + Administration = "Administration", +} + +export enum TagsTypes { + Contents = "Contents", + LibraryAudio = "Library / Audio", + LibraryVideo = "Library / Video", + LibraryTextual = "Library / Textual", + LibraryGroup = "Library / Group", + Library = "Library", + Weapons = "Weapons", + GamePlatforms = "Game Platforms", + Categories = "Categories", +} + +export enum FileTypes { + LibraryScans = "Library / Scans", + LibrarySoundtracks = "Library / Soundtracks", + ContentVideo = "Content / Video", + ContentAudio = "Content / Audio", +} diff --git a/src/fields/fileField/fileField.ts b/src/fields/fileField/fileField.ts new file mode 100644 index 0000000..779e56b --- /dev/null +++ b/src/fields/fileField/fileField.ts @@ -0,0 +1,10 @@ +import { RelationshipField, UploadField } from "payload/types"; +import { Files } from "../../collections/Files/Files"; + +type Props = Omit; + +export const fileField = (props: Props): RelationshipField => ({ + ...props, + type: "relationship", + relationTo: Files.slug, +}); diff --git a/src/collections/Images/components/ImageCell.tsx b/src/fields/imageField/Cell.tsx similarity index 90% rename from src/collections/Images/components/ImageCell.tsx rename to src/fields/imageField/Cell.tsx index dc0cd61..75f0f7f 100644 --- a/src/collections/Images/components/ImageCell.tsx +++ b/src/fields/imageField/Cell.tsx @@ -2,7 +2,7 @@ import { Props } from "payload/components/views/Cell"; import { useState, useEffect } from "react"; import React from "react"; -export const ImageCell = ({ cellData, field }: Props): JSX.Element => { +export const Cell = ({ cellData, field }: Props): JSX.Element => { const [imageURL, setImageURL] = useState(); useEffect(() => { const fetchUrl = async () => { diff --git a/src/fields/imageField/imageField.ts b/src/fields/imageField/imageField.ts new file mode 100644 index 0000000..61b2af4 --- /dev/null +++ b/src/fields/imageField/imageField.ts @@ -0,0 +1,17 @@ +import { UploadField } from "payload/types"; +import { Images } from "../../collections/Images/Images"; +import { Cell } from "./Cell"; + +type Props = Omit; + +export const imageField = ({ admin, ...otherProps }: Props): UploadField => ({ + ...otherProps, + type: "upload", + relationTo: Images.slug, + admin: { + components: { + Cell, + }, + ...admin, + }, +}); diff --git a/src/fields/slugField/slugField.ts b/src/fields/slugField/slugField.ts new file mode 100644 index 0000000..4157c11 --- /dev/null +++ b/src/fields/slugField/slugField.ts @@ -0,0 +1,23 @@ +import { TextField } from "payload/types"; +import { isUndefined } from "../../utils/asserts"; + +type Props = Omit; + +const validateSlug = (value?: string) => { + if (isUndefined(value) || value === "") return "This field is required."; + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) return "This is not a valid slug."; + return true; +}; + +export const slugField = ({ admin, ...otherProps }: Props): TextField => ({ + ...otherProps, + type: "text", + required: true, + unique: true, + validate: validateSlug, + admin: { + description: + "A slug must only include lowercase letters and digits. Instead of spaces you can use dashes.", + ...admin, + }, +}); diff --git a/src/elements/translatedFields/Cell.tsx b/src/fields/translatedFields/Cell.tsx similarity index 89% rename from src/elements/translatedFields/Cell.tsx rename to src/fields/translatedFields/Cell.tsx index f197e9f..acb2b48 100644 --- a/src/elements/translatedFields/Cell.tsx +++ b/src/fields/translatedFields/Cell.tsx @@ -11,7 +11,7 @@ export const Cell = ({ cellData }: Props): JSX.Element => (
{cellData.map(({ language, title }, index) => (
- +
{isDefined(language) && typeof language === "string" ? formatLanguageCode(language) diff --git a/src/elements/translatedFields/RowLabel.tsx b/src/fields/translatedFields/RowLabel.tsx similarity index 100% rename from src/elements/translatedFields/RowLabel.tsx rename to src/fields/translatedFields/RowLabel.tsx diff --git a/src/elements/translatedFields/translatedFields.ts b/src/fields/translatedFields/translatedFields.ts similarity index 60% rename from src/elements/translatedFields/translatedFields.ts rename to src/fields/translatedFields/translatedFields.ts index 27ff6cb..af34306 100644 --- a/src/elements/translatedFields/translatedFields.ts +++ b/src/fields/translatedFields/translatedFields.ts @@ -1,21 +1,40 @@ -import { ArrayField } from "payload/types"; +import { ArrayField, Field } from "payload/types"; import { hasDuplicates } from "../../utils/validation"; import { isDefined, isUndefined } from "../../utils/asserts"; import { Languages } from "../../collections/Languages"; import { RowLabel } from "./RowLabel"; import { Cell } from "./Cell"; -const LANGUAGE_FIELD_NAME = "language"; +const fieldsNames = { + language: "language", + sourceLanguage: "sourceLanguage", +} as const satisfies Record; type LocalizedFieldsProps = Omit & { - admin?: ArrayField["admin"] & { useAsTitle?: string }; + admin?: ArrayField["admin"] & { useAsTitle?: string; hasSourceLanguage?: boolean }; +}; +type ArrayData = { [fieldsNames.language]?: string }[] | number | undefined; + +const languageField: Field = { + name: fieldsNames.language, + type: "relationship", + relationTo: Languages.slug, + required: true, + admin: { allowCreate: false, width: "50%" }, +}; + +const sourceLanguageField: Field = { + name: fieldsNames.sourceLanguage, + type: "relationship", + relationTo: Languages.slug, + required: true, + admin: { allowCreate: false, width: "50%" }, }; -type ArrayData = { [LANGUAGE_FIELD_NAME]?: string }[] | number | undefined; export const localizedFields = ({ fields, validate, - admin: { useAsTitle, ...admin }, + admin: { useAsTitle, hasSourceLanguage, ...admin }, ...otherProps }: LocalizedFieldsProps): ArrayField => ({ ...otherProps, @@ -45,19 +64,15 @@ export const localizedFields = ({ const languages = data.map((biography) => biography.language); if (hasDuplicates(languages)) { - return `There cannot be multiple ${otherProps.name} with the same ${LANGUAGE_FIELD_NAME}`; + return `There cannot be multiple ${otherProps.name} with the same ${fieldsNames.language}`; } return isDefined(validate) ? validate(value, options) : true; }, fields: [ - { - name: LANGUAGE_FIELD_NAME, - type: "relationship", - relationTo: Languages.slug, - required: true, - admin: { allowCreate: false }, - }, + hasSourceLanguage + ? { type: "row", fields: [languageField, sourceLanguageField] } + : languageField, ...fields, ], }); diff --git a/src/payload.config.ts b/src/payload.config.ts index d7b195a..eb5ab4b 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -4,14 +4,18 @@ import { Users } from "./collections/Users"; import { Languages } from "./collections/Languages"; import { Recorders } from "./collections/Recorders/Recorders"; import { Images } from "./collections/Images/Images"; -import { Categories } from "./collections/Categories/Categories"; +import { Posts } from "./collections/Posts/Posts"; +import { Tags } from "./collections/Tags/Tags"; +import { LibraryItems } from "./collections/LibraryItems/LibraryItems"; +import { Contents } from "./collections/Contents/Contents"; +import { Files } from "./collections/Files/Files"; export default buildConfig({ serverURL: "http://localhost:3000", admin: { user: Users.slug, }, - collections: [Users, Languages, Recorders, Images, Categories], + collections: [LibraryItems, Contents, Posts, Images, Files, Languages, Recorders, Tags, Users], globals: [], telemetry: false, typescript: { diff --git a/src/types/collections.ts b/src/types/collections.ts index a4d68ed..35f16de 100644 --- a/src/types/collections.ts +++ b/src/types/collections.ts @@ -6,6 +6,11 @@ * and re-run `payload generate:types` to regenerate this file. */ +export type CategoryTranslations = { + language: string | Language; + name: string; + id?: string; +}[]; export type RecorderBiographies = { language: string | Language; biography?: string; @@ -14,35 +19,37 @@ export type RecorderBiographies = { export interface Config { collections: { - Users: User; - Languages: Language; - Recorders: Recorder; - Images: Image; + 'library-items': LibraryItem; + contents: Content; + posts: Post; + images: Image; + files: File; + languages: Language; + recorders: Recorder; + tags: Tag; + users: User; }; globals: {}; } -export interface User { +export interface LibraryItem { id: string; - email: string; - resetPasswordToken?: string; - resetPasswordExpiration?: string; - salt?: string; - hash?: string; - loginAttempts?: number; - lockUntil?: string; - password?: string; -} -export interface Language { - id: string; - name: string; -} -export interface Recorder { - id: string; - avatar?: string | Image; - username: string; - anonymize: boolean; - languages?: string[] | Language[]; - biographies?: RecorderBiographies; + slug: string; + thumbnail?: string | Image; + pretitle?: string; + title: string; + subtitle?: string; + rootItem: boolean; + primary: boolean; + digital: boolean; + downloadable: boolean; + size?: { + width?: number; + height?: number; + thickness?: number; + }; + updatedAt: string; + createdAt: string; + _status?: 'draft' | 'published'; } export interface Image { id: string; @@ -56,3 +63,330 @@ export interface Image { width?: number; height?: number; } +export interface Content { + id: string; + slug: string; + thumbnail?: string | Image; + testing?: (Section_1 | Collapsible_1 | Columns_1 | Tabs_1 | Accordion_1 | TextBlock | TranscriptBlock)[]; + categories?: + | { + value: string; + relationTo: 'tags'; + }[] + | { + value: Tag; + relationTo: 'tags'; + }[]; + type?: { + value: string | Tag; + relationTo: 'tags'; + }; + translations: { + language: string | Language; + sourceLanguage: string | Language; + pretitle?: string; + title: string; + subtitle?: string; + summary?: string; + textTranscribers?: string[] | Recorder[]; + textTranslators?: string[] | Recorder[]; + textProofreaders?: string[] | Recorder[]; + textContent?: { + [k: string]: unknown; + }[]; + textNotes?: string; + video?: string | File; + videoNotes?: string; + audio?: string | File; + id?: string; + }[]; + updatedAt: string; + createdAt: string; + _status?: 'draft' | 'published'; +} +export interface Section_1 { + content?: (Section_2 | Collapsible_2 | Columns_2 | Tabs_2 | Accordion_2 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'section_1'; +} +export interface Section_2 { + content?: (Section_3 | Collapsible_3 | Columns_3 | Tabs_3 | Accordion_3 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'section_2'; +} +export interface Section_3 { + content?: (Section_4 | Collapsible_4 | Columns_4 | Tabs_4 | Accordion_4 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'section_3'; +} +export interface Section_4 { + content?: (TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'section_4'; +} +export interface TextBlock { + content: { + [k: string]: unknown; + }[]; + id?: string; + blockName?: string; + blockType: 'textBlock'; +} +export interface TranscriptBlock { + lines: (LineBlock | CueBlock)[]; + id?: string; + blockName?: string; + blockType: 'transcriptBlock'; +} +export interface LineBlock { + content: { + [k: string]: unknown; + }[]; + id?: string; + blockName?: string; + blockType: 'lineBlock'; +} +export interface CueBlock { + content: string; + id?: string; + blockName?: string; + blockType: 'cueBlock'; +} +export interface Collapsible_4 { + content?: (TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'collapsible_4'; +} +export interface Columns_4 { + columns?: Column_4[]; + id?: string; + blockName?: string; + blockType: 'columns_4'; +} +export interface Column_4 { + content?: (TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'column_4'; +} +export interface Tabs_4 { + tabs?: Tab_4[]; + id?: string; + blockName?: string; + blockType: 'tabs_4'; +} +export interface Tab_4 { + content?: (TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'tab_4'; +} +export interface Accordion_4 { + content?: Collapsible_5[]; + id?: string; + blockName?: string; + blockType: 'accordion_4'; +} +export interface Collapsible_5 { + content?: (TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'collapsible_5'; +} +export interface Collapsible_3 { + content?: (Section_4 | Collapsible_4 | Columns_4 | Tabs_4 | Accordion_4 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'collapsible_3'; +} +export interface Columns_3 { + columns?: Column_3[]; + id?: string; + blockName?: string; + blockType: 'columns_3'; +} +export interface Column_3 { + content?: (Section_4 | Collapsible_4 | Columns_4 | Tabs_4 | Accordion_4 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'column_3'; +} +export interface Tabs_3 { + tabs?: Tab_3[]; + id?: string; + blockName?: string; + blockType: 'tabs_3'; +} +export interface Tab_3 { + content?: (Section_4 | Collapsible_4 | Columns_4 | Tabs_4 | Accordion_4 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'tab_3'; +} +export interface Accordion_3 { + content?: Collapsible_4[]; + id?: string; + blockName?: string; + blockType: 'accordion_3'; +} +export interface Collapsible_2 { + content?: (Section_3 | Collapsible_3 | Columns_3 | Tabs_3 | Accordion_3 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'collapsible_2'; +} +export interface Columns_2 { + columns?: Column_2[]; + id?: string; + blockName?: string; + blockType: 'columns_2'; +} +export interface Column_2 { + content?: (Section_3 | Collapsible_3 | Columns_3 | Tabs_3 | Accordion_3 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'column_2'; +} +export interface Tabs_2 { + tabs?: Tab_2[]; + id?: string; + blockName?: string; + blockType: 'tabs_2'; +} +export interface Tab_2 { + content?: (Section_3 | Collapsible_3 | Columns_3 | Tabs_3 | Accordion_3 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'tab_2'; +} +export interface Accordion_2 { + content?: Collapsible_3[]; + id?: string; + blockName?: string; + blockType: 'accordion_2'; +} +export interface Collapsible_1 { + content?: (Section_2 | Collapsible_2 | Columns_2 | Tabs_2 | Accordion_2 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'collapsible_1'; +} +export interface Columns_1 { + columns?: Column_1[]; + id?: string; + blockName?: string; + blockType: 'columns_1'; +} +export interface Column_1 { + content?: (Section_2 | Collapsible_2 | Columns_2 | Tabs_2 | Accordion_2 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'column_1'; +} +export interface Tabs_1 { + tabs?: Tab_1[]; + id?: string; + blockName?: string; + blockType: 'tabs_1'; +} +export interface Tab_1 { + content?: (Section_2 | Collapsible_2 | Columns_2 | Tabs_2 | Accordion_2 | TextBlock | TranscriptBlock)[]; + id?: string; + blockName?: string; + blockType: 'tab_1'; +} +export interface Accordion_1 { + content?: Collapsible_2[]; + id?: string; + blockName?: string; + blockType: 'accordion_1'; +} +export interface Tag { + id: string; + slug: string; + type: + | 'Contents' + | 'LibraryAudio' + | 'LibraryVideo' + | 'LibraryTextual' + | 'LibraryGroup' + | 'Library' + | 'Weapons' + | 'GamePlatforms' + | 'Categories'; + translations?: CategoryTranslations; +} +export interface Language { + id: string; + name: string; +} +export interface Recorder { + id: string; + avatar?: string | Image; + username: string; + anonymize: boolean; + languages?: string[] | Language[]; + biographies?: RecorderBiographies; +} +export interface File { + id: string; + filename: string; + type: 'LibraryScans' | 'LibrarySoundtracks' | 'ContentVideo' | 'ContentAudio'; + updatedAt: string; + createdAt: string; +} +export interface Post { + id: string; + slug: string; + thumbnail?: string | Image; + authors: + | { + value: string; + relationTo: 'recorders'; + }[] + | { + value: Recorder; + relationTo: 'recorders'; + }[]; + categories?: + | { + value: string; + relationTo: 'tags'; + }[] + | { + value: Tag; + relationTo: 'tags'; + }[]; + translations: { + language: string | Language; + sourceLanguage: string | Language; + title: string; + summary?: string; + translators?: string[] | Recorder[]; + proofreaders?: string[] | Recorder[]; + content?: { + [k: string]: unknown; + }[]; + id?: string; + }[]; + publishedDate: string; + updatedAt: string; + createdAt: string; + _status?: 'draft' | 'published'; +} +export interface User { + id: string; + email: string; + resetPasswordToken?: string; + resetPasswordExpiration?: string; + salt?: string; + hash?: string; + loginAttempts?: number; + lockUntil?: string; + password?: string; +} diff --git a/src/utils/components.ts b/src/utils/components.ts deleted file mode 100644 index 789e702..0000000 --- a/src/utils/components.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type AdminComponent> = (props: { - data: Partial; - index?: number; -}) => string; diff --git a/src/utils/string.ts b/src/utils/string.ts index 6522a24..74912fc 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -1,7 +1,11 @@ import ISO6391 from "iso-639-1"; +import slugify from "slugify"; export const shortenEllipsis = (text: string, length: number): string => text.length - 3 > length ? `${text.substring(0, length)}...` : text; export const formatLanguageCode = (code: string): string => ISO6391.validate(code) ? ISO6391.getName(code) : code; + +export const collectionSlug = (text: string): string => + slugify(text, { lower: true, strict: true, trim: true });