Lots of things, again, again

This commit is contained in:
DrMint 2023-08-19 21:11:22 +02:00
parent f3253ce972
commit 6074ccceff
42 changed files with 826 additions and 187 deletions

View File

@ -0,0 +1,9 @@
import { RecordersRoles } from "../../constants";
import { Recorder } from "../../types/collections";
import { EndpointAccess } from "../../types/payload";
import { isDefined, isUndefined } from "../../utils/asserts";
export const mustBeAdmin: EndpointAccess<Recorder> = ({ user }) => {
if (isUndefined(user)) return false;
return isDefined(user.role) && user.role.includes(RecordersRoles.Admin);
};

View File

@ -0,0 +1,9 @@
import { RecordersRoles } from "../../constants";
import { Recorder } from "../../types/collections";
import { EndpointAccess } from "../../types/payload";
import { isDefined, isUndefined } from "../../utils/asserts";
export const mustBeApi: EndpointAccess<Recorder> = ({ user }) => {
if (isUndefined(user)) return false;
return isDefined(user.role) && user.role.includes(RecordersRoles.Api);
};

View File

@ -0,0 +1,8 @@
import { Recorder } from "../../types/collections";
import { EndpointAccess } from "../../types/payload";
import { isDefined, isUndefined } from "../../utils/asserts";
export const mustHaveAtLeastOneRole: EndpointAccess<Recorder> = ({ user }) => {
if (isUndefined(user)) return false;
return isDefined(user.role) && user.role.length > 0;
};

View File

@ -1,4 +1,3 @@
import { DateTime } from "luxon";
import { CollectionConfig } from "payload/types";
import {
QuickFilters,
@ -7,10 +6,12 @@ import {
} from "../../components/QuickFilters";
import { CollectionGroups, Collections } from "../../constants";
import { translatedFields } from "../../fields/translatedFields/translatedFields";
import { isEmpty, isUndefined } from "../../utils/asserts";
import { buildVersionedCollectionConfig } from "../../utils/versionedCollectionConfig";
import { importFromStrapi } from "./endpoints/importFromStrapi";
import { beforeValidatePopulateNameField } from "./hooks/beforeValidatePopulateNameField";
import { validateDate } from "./validations/validateDate";
import { validateEventsTranslationsDescription } from "./validations/validateEventsTranslationsDescription";
import { validateEventsTranslationsTitle } from "./validations/validateEventsTranslationsTitle";
const fields = {
name: "name",
@ -65,15 +66,7 @@ export const ChronologyItems: CollectionConfig = buildVersionedCollectionConfig(
{
type: "group",
name: fields.date,
validate: ({ year, month, day } = {}) => {
if (isUndefined(day)) return true;
if (isUndefined(month)) return "A month is required if a day is set";
const stringDate = `${year}/${month}/${day}`;
if (!DateTime.fromObject({ year, month, day }).isValid) {
return `The given date (${stringDate}) is not a valid date.`;
}
return true;
},
validate: validateDate,
fields: [
{
type: "row",
@ -116,22 +109,12 @@ export const ChronologyItems: CollectionConfig = buildVersionedCollectionConfig(
fields: [
{
name: fields.eventsTranslationsTitle,
validate: (_, { siblingData: { description, title } }) => {
if (isEmpty(description) && isEmpty(title)) {
return "This field is required if no description is set.";
}
return true;
},
validate: validateEventsTranslationsTitle,
type: "text",
},
{
name: fields.eventsTranslationsDescription,
validate: (_, { siblingData: { description, title } }) => {
if (isEmpty(description) && isEmpty(title)) {
return "This field is required if no title is set.";
}
return true;
},
validate: validateEventsTranslationsDescription,
type: "textarea",
},
{ name: fields.eventsTranslationsNotes, type: "textarea" },

View File

@ -7,17 +7,14 @@ export const beforeValidatePopulateNameField: FieldHook<
ChronologyItem["name"],
ChronologyItem
> = ({ data }) => {
if (isUndefined(data)) {
if (isUndefined(data) || isUndefined(data.date) || isUndefined(data.date.year))
return "????-??-??";
}
const { date } = data;
if (isUndefined(date) || isUndefined(date?.year)) return "????-??-??";
const { year, month, day } = date;
const { year, month, day } = data.date;
let result = String(year).padStart(5, " ");
if (isDefined(month)) {
result += `-${String(date.month).padStart(2, "0")}`;
result += `-${String(month).padStart(2, "0")}`;
if (isDefined(day)) {
result += `-${String(date.day).padStart(2, "0")}`;
result += `-${String(day).padStart(2, "0")}`;
}
}
return result;

View File

@ -0,0 +1,16 @@
import { DateTime } from "luxon";
import { Validate } from "payload/types";
import { ChronologyItem } from "../../../types/collections";
import { isUndefined } from "../../../utils/asserts";
export const validateDate: Validate<ChronologyItem["date"] | undefined> = (date) => {
if (isUndefined(date)) return "This field is required.";
const { year, month, day } = date;
if (isUndefined(day)) return true;
if (isUndefined(month)) return "A month is required if a day is set.";
const stringDate = `${year}/${month}/${day}`;
if (!DateTime.fromObject({ year, month, day }).isValid) {
return `The given date (${stringDate}) is not a valid date.`;
}
return true;
};

View File

@ -0,0 +1,15 @@
import { Validate } from "payload/types";
import { ChronologyItem } from "../../../types/collections";
import { isEmpty } from "../../../utils/asserts";
export const validateEventsTranslationsDescription: Validate<
string | undefined,
ChronologyItem,
ChronologyItem["events"][number]["translations"][number],
unknown
> = (_, { siblingData: { description, title } }) => {
if (isEmpty(description) && isEmpty(title)) {
return "This field is required if no title is set.";
}
return true;
};

View File

@ -0,0 +1,15 @@
import { Validate } from "payload/types";
import { ChronologyItem } from "../../../types/collections";
import { isEmpty } from "../../../utils/asserts";
export const validateEventsTranslationsTitle: Validate<
string | undefined,
ChronologyItem,
ChronologyItem["events"][number]["translations"][number],
unknown
> = (_, { siblingData: { description, title } }) => {
if (isEmpty(description) && isEmpty(title)) {
return "This field is required if no description is set.";
}
return true;
};

View File

@ -46,8 +46,8 @@ export const Contents = buildVersionedCollectionConfig({
description:
"All the contents (textual, audio, and video) from the Library or other online sources.",
defaultColumns: [
fields.slug,
fields.thumbnail,
fields.slug,
fields.categories,
fields.type,
fields.translations,
@ -175,7 +175,7 @@ export const Contents = buildVersionedCollectionConfig({
fields: [
fileField({
name: fields.video,
filterOptions: { type: { equals: FileTypes.ContentVideo } },
relationTo: FileTypes.ContentVideo,
admin: { width: "50%" },
}),
{
@ -196,7 +196,7 @@ export const Contents = buildVersionedCollectionConfig({
fields: [
fileField({
name: fields.audio,
filterOptions: { type: { equals: FileTypes.ContentAudio } },
relationTo: FileTypes.ContentAudio,
admin: { width: "50%" },
}),
{

View File

@ -1,10 +1,13 @@
import { CollectionGroups, Collections } from "../../constants";
import { Collections } from "../../constants";
import { backPropagationField } from "../../fields/backPropagationField/backPropagationField";
import { buildImageCollectionConfig } from "../../utils/imageCollectionConfig";
const fields = {
filename: "filename",
mimeType: "mimeType",
filesize: "filesize",
contents: "contents",
updatedAt: "updatedAt",
} as const satisfies Record<string, string>;
export const ContentsThumbnails = buildImageCollectionConfig({
@ -13,12 +16,7 @@ export const ContentsThumbnails = buildImageCollectionConfig({
singular: "Contents Thumbnail",
plural: "Contents Thumbnails",
},
defaultSort: fields.filename,
admin: {
useAsTitle: fields.filename,
disableDuplicate: true,
group: CollectionGroups.Media,
},
admin: { defaultColumns: [fields.filename, fields.contents, fields.updatedAt] },
upload: {
imageSizes: [
{
@ -39,7 +37,21 @@ export const ContentsThumbnails = buildImageCollectionConfig({
options: { effort: 6, quality: 80, alphaQuality: 80 },
},
},
{
name: "max",
formatOptions: {
format: "webp",
options: { effort: 6, quality: 80, alphaQuality: 80 },
},
},
],
},
fields: [],
fields: [
backPropagationField({
name: fields.contents,
hasMany: true,
relationTo: Collections.Contents,
where: ({ id }) => ({ thumbnail: { equals: id } }),
}),
],
});

View File

@ -1,5 +1,10 @@
import { CollectionGroups, Collections, FileTypes } from "../../constants";
import { File } from "../../types/collections";
import { buildCollectionConfig } from "../../utils/collectionConfig";
import {
beforeValidateCheckFileExists,
generatePathForFile,
} from "./hooks/beforeValidateCheckFileExists";
const fields = {
filename: "filename",
@ -17,6 +22,13 @@ export const Files = buildCollectionConfig({
useAsTitle: fields.filename,
disableDuplicate: true,
group: CollectionGroups.Media,
preview: (doc) => {
const { filename, type } = doc as unknown as File;
return generatePathForFile(type, filename);
},
},
hooks: {
beforeValidate: [beforeValidateCheckFileExists],
},
fields: [
{

View File

@ -0,0 +1,45 @@
import { CollectionBeforeValidateHook } from "payload/types";
import { FileTypes } from "../../../constants";
import { File } from "../../../types/collections";
import { isUndefined } from "../../../utils/asserts";
const reshareSubFolderFromType: Record<keyof typeof FileTypes, string> = {
ContentAudio: "/contents/audios",
ContentVideo: "/contents/videos",
LibraryScans: "/library/scans",
LibrarySoundtracks: "/library/tracks",
};
const expectedMimeFromType = {
ContentAudio: "audio/",
ContentVideo: "video/",
LibraryScans: "application/zip",
LibrarySoundtracks: "audio/",
};
export const generatePathForFile = (type: keyof typeof FileTypes, filename: string) =>
`https://resha.re/accords${reshareSubFolderFromType[type]}/${filename}`;
export const beforeValidateCheckFileExists: CollectionBeforeValidateHook<File> = async ({
data,
}) => {
if (isUndefined(data)) throw new Error("The data is undefined");
const { type, filename } = data;
if (isUndefined(filename)) throw new Error("Filename is undefined");
if (isUndefined(type)) throw new Error("Filename is undefined");
const url = generatePathForFile(type, filename);
const result = await fetch(url, { method: "HEAD" });
if (result.status !== 200) {
throw new Error(`Unable to locate the file at the following address: ${url}`);
}
const contentType = result.headers.get("content-type");
if (isUndefined(contentType) || !contentType.startsWith(expectedMimeFromType[type])) {
throw new Error(
`Wrong MIME type found: ${contentType}. The expected MIME type was ${expectedMimeFromType[type]}`
);
}
};

View File

@ -2,11 +2,13 @@ import { RowLabelArgs } from "payload/dist/admin/components/forms/RowLabel/types
import {
CollectionGroups,
Collections,
FileTypes,
KeysTypes,
LibraryItemsTextualBindingTypes,
LibraryItemsTextualPageOrders,
LibraryItemsTypes,
} from "../../constants";
import { fileField } from "../../fields/fileField/fileField";
import { imageField } from "../../fields/imageField/imageField";
import { keysField } from "../../fields/keysField/keysField";
import { optionalGroupField } from "../../fields/optionalGroupField/optionalGroupField";
@ -66,6 +68,9 @@ const fields = {
audio: "audio",
audioSubtype: "audioSubtype",
audioTracks: "tracks",
audioTracksFile: "file",
audioTracksTitle: "title",
scans: "scans",
@ -129,7 +134,7 @@ export const LibraryItems = buildVersionedCollectionConfig({
description:
"A comprehensive list of all Yokoverses side materials (books, novellas, artbooks, \
stage plays, manga, drama CDs, and comics).",
defaultColumns: [fields.slug, fields.thumbnail, fields.status],
defaultColumns: [fields.thumbnail, fields.slug, fields.status],
group: CollectionGroups.Collections,
hooks: {
beforeDuplicate: beforeDuplicatePiping([
@ -596,6 +601,29 @@ export const LibraryItems = buildVersionedCollectionConfig({
}),
],
},
{
name: fields.audioTracks,
type: "array",
fields: [
{
type: "row",
fields: [
{
name: fields.audioTracksTitle,
type: "text",
required: true,
admin: { width: "50%" },
},
fileField({
name: fields.audioTracksFile,
relationTo: FileTypes.LibrarySoundtracks,
required: true,
admin: { width: "50%" },
}),
],
},
],
},
],
},
],

View File

@ -1,7 +1,6 @@
import React from "react";
import { styled } from "styled-components";
import { isDefined } from "../../../utils/asserts";
import { formatLanguageCode, shortenEllipsis } from "../../../utils/string";
interface Props {
page?: number;

View File

@ -1,10 +1,11 @@
import { CollectionGroups, Collections } from "../../constants";
import { Collections } from "../../constants";
import { buildImageCollectionConfig } from "../../utils/imageCollectionConfig";
const fields = {
filename: "filename",
mimeType: "mimeType",
filesize: "filesize",
updatedAt: "updatedAt",
} as const satisfies Record<string, string>;
export const LibraryItemsGallery = buildImageCollectionConfig({
@ -13,12 +14,7 @@ export const LibraryItemsGallery = buildImageCollectionConfig({
singular: "Library Item Gallery",
plural: "Library Item Gallery",
},
defaultSort: fields.filename,
admin: {
useAsTitle: fields.filename,
disableDuplicate: true,
group: CollectionGroups.Media,
},
admin: { defaultColumns: [fields.filename, fields.updatedAt] },
upload: {
imageSizes: [
{

View File

@ -1,10 +1,11 @@
import { CollectionGroups, Collections } from "../../constants";
import { Collections } from "../../constants";
import { buildImageCollectionConfig } from "../../utils/imageCollectionConfig";
const fields = {
filename: "filename",
mimeType: "mimeType",
filesize: "filesize",
updatedAt: "updatedAt",
} as const satisfies Record<string, string>;
export const LibraryItemsScans = buildImageCollectionConfig({
@ -13,12 +14,7 @@ export const LibraryItemsScans = buildImageCollectionConfig({
singular: "Library Item Scans",
plural: "Library Item Scans",
},
defaultSort: fields.filename,
admin: {
useAsTitle: fields.filename,
disableDuplicate: true,
group: CollectionGroups.Media,
},
admin: { defaultColumns: [fields.filename, fields.updatedAt] },
upload: {
imageSizes: [
{

View File

@ -1,10 +1,13 @@
import { CollectionGroups, Collections } from "../../constants";
import { Collections } from "../../constants";
import { backPropagationField } from "../../fields/backPropagationField/backPropagationField";
import { buildImageCollectionConfig } from "../../utils/imageCollectionConfig";
const fields = {
filename: "filename",
mimeType: "mimeType",
filesize: "filesize",
libraryItem: "libraryItem",
updatedAt: "updatedAt",
} as const satisfies Record<string, string>;
export const LibraryItemsThumbnails = buildImageCollectionConfig({
@ -13,12 +16,7 @@ export const LibraryItemsThumbnails = buildImageCollectionConfig({
singular: "Library Item Thumbnail",
plural: "Library Item Thumbnails",
},
defaultSort: fields.filename,
admin: {
useAsTitle: fields.filename,
disableDuplicate: true,
group: CollectionGroups.Media,
},
admin: { defaultColumns: [fields.filename, fields.libraryItem, fields.updatedAt] },
upload: {
imageSizes: [
{
@ -51,5 +49,12 @@ export const LibraryItemsThumbnails = buildImageCollectionConfig({
},
],
},
fields: [],
fields: [
backPropagationField({
name: fields.libraryItem,
hasMany: true,
relationTo: Collections.LibraryItems,
where: ({ id }) => ({ thumbnail: { equals: id } }),
}),
],
});

View File

@ -37,7 +37,7 @@ export const Posts = buildVersionedCollectionConfig({
description:
"News articles written by our Recorders! Here you will find announcements about \
new merch/items releases, guides, theories, unboxings, showcases...",
defaultColumns: [fields.slug, fields.thumbnail, fields.categories],
defaultColumns: [fields.thumbnail, fields.slug, fields.categories],
group: CollectionGroups.Collections,
components: {
BeforeListTable: [

View File

@ -1,10 +1,13 @@
import { CollectionGroups, Collections } from "../../constants";
import { Collections } from "../../constants";
import { backPropagationField } from "../../fields/backPropagationField/backPropagationField";
import { buildImageCollectionConfig } from "../../utils/imageCollectionConfig";
const fields = {
filename: "filename",
mimeType: "mimeType",
filesize: "filesize",
posts: "posts",
updatedAt: "updatedAt",
} as const satisfies Record<string, string>;
export const PostsThumbnails = buildImageCollectionConfig({
@ -13,12 +16,7 @@ export const PostsThumbnails = buildImageCollectionConfig({
singular: "Post Thumbnail",
plural: "Post Thumbnails",
},
defaultSort: fields.filename,
admin: {
useAsTitle: fields.filename,
disableDuplicate: true,
group: CollectionGroups.Media,
},
admin: { defaultColumns: [fields.filename, fields.posts, fields.updatedAt] },
upload: {
imageSizes: [
{
@ -41,5 +39,12 @@ export const PostsThumbnails = buildImageCollectionConfig({
},
],
},
fields: [],
fields: [
backPropagationField({
name: fields.posts,
hasMany: true,
relationTo: Collections.Posts,
where: ({ id }) => ({ thumbnail: { equals: id } }),
}),
],
});

View File

@ -32,8 +32,8 @@ export const Recorders = buildCollectionConfig({
"Recorders are contributors of the Accord's Library project. Ask an admin to create a \
Recorder here to be able to credit them in other collections.",
defaultColumns: [
fields.username,
fields.avatar,
fields.username,
fields.anonymize,
fields.biographies,
fields.languages,

View File

@ -4,7 +4,7 @@ import { createStrapiImportEndpoint } from "../../../endpoints/createStrapiImpor
import { Recorder } from "../../../types/collections";
import { PayloadCreateData } from "../../../types/payload";
import { StrapiImage, StrapiLanguage } from "../../../types/strapi";
import { isUndefined } from "../../../utils/asserts";
import { isDefined, isUndefined } from "../../../utils/asserts";
import { uploadStrapiImage } from "../../../utils/localApi";
type StrapiRecorder = {
@ -31,9 +31,7 @@ export const importFromStrapi = createStrapiImportEndpoint<Recorder, StrapiRecor
image: avatar,
});
const data: PayloadCreateData<Recorder> = {
email: `${anonymous_code}@accords-library.com`,
password: process.env.RECORDER_DEFAULT_PASSWORD,
const data: Omit<PayloadCreateData<Recorder>, "password" | "email"> = {
username,
anonymize,
languages: languages.data?.map((language) => language.attributes.code),
@ -49,7 +47,26 @@ export const importFromStrapi = createStrapiImportEndpoint<Recorder, StrapiRecor
}),
};
await payload.create({ collection: Collections.Recorders, data, user });
const recorder = (
await payload.find({
collection: Collections.Recorders,
where: { username: { equals: username } },
})
).docs[0] as Recorder | undefined;
if (isDefined(recorder)) {
await payload.update({ collection: Collections.Recorders, id: recorder.id, data, user });
} else {
await payload.create({
collection: Collections.Recorders,
data: {
...data,
email: `${anonymous_code}@accords-library.com`,
password: process.env.RECORDER_DEFAULT_PASSWORD,
},
user,
});
}
},
},
});

View File

@ -1,4 +1,4 @@
import { CollectionGroups, Collections } from "../../constants";
import { Collections } from "../../constants";
import { backPropagationField } from "../../fields/backPropagationField/backPropagationField";
import { buildImageCollectionConfig } from "../../utils/imageCollectionConfig";
@ -7,6 +7,7 @@ const fields = {
mimeType: "mimeType",
filesize: "filesize",
recorder: "recorder",
updatedAt: "updatedAt",
} as const satisfies Record<string, string>;
export const RecordersThumbnails = buildImageCollectionConfig({
@ -15,27 +16,14 @@ export const RecordersThumbnails = buildImageCollectionConfig({
singular: "Recorders Thumbnail",
plural: "Recorders Thumbnails",
},
defaultSort: fields.filename,
admin: {
useAsTitle: fields.filename,
disableDuplicate: true,
group: CollectionGroups.Media,
},
admin: { defaultColumns: [fields.filename, fields.recorder, fields.updatedAt] },
upload: {
imageSizes: [
{
name: "og",
height: 256,
width: 256,
formatOptions: {
format: "jpg",
options: { progressive: true, mozjpeg: true, compressionLevel: 9, quality: 80 },
},
},
{
name: "small",
height: 128,
width: 128,
name: "square",
height: 150,
width: 150,
fit: "cover",
formatOptions: {
format: "webp",
options: { effort: 6, quality: 80, alphaQuality: 80 },

View File

@ -94,7 +94,6 @@ export const Videos: CollectionConfig = buildCollectionConfig({
name: fields.channel,
type: "relationship",
relationTo: Collections.VideosChannels,
required: true,
admin: { position: "sidebar" },
},
],

View File

@ -1,9 +1,9 @@
import payload from "payload";
import { Collections } from "../../../constants";
import { Collections, VideoSources } from "../../../constants";
import { createStrapiImportEndpoint } from "../../../endpoints/createStrapiImportEndpoint";
import { Video, VideosChannel } from "../../../types/collections";
import { PayloadCreateData } from "../../../types/payload";
import { isUndefined } from "../../../utils/asserts";
import { isDefined, isUndefined } from "../../../utils/asserts";
type StapiVideo = {
uid: string;
@ -16,7 +16,7 @@ type StapiVideo = {
};
views: number;
likes: number;
source?: "YouTube" | "NicoNico" | "Tumblr";
source?: VideoSources;
gone: boolean;
channel: { data?: { attributes: { uid: string; title: string; subscribers: number } } };
};
@ -43,31 +43,34 @@ export const importFromStrapi = createStrapiImportEndpoint<Video, StapiVideo>({
user
) => {
if (isUndefined(source)) throw new Error("A source is required to create a Video");
if (isUndefined(channel.data)) throw new Error("A channel is required to create a Video");
if (source === VideoSources.YouTube && isUndefined(channel.data))
throw new Error("A channel is required to create a YouTube Video");
try {
const videoChannel: PayloadCreateData<VideosChannel> = {
uid: channel.data.attributes.uid,
title: channel.data.attributes.title,
subscribers: channel.data.attributes.subscribers,
};
await payload.create({
let videoChannelId;
if (isDefined(channel.data)) {
try {
const videoChannel: PayloadCreateData<VideosChannel> = {
uid: channel.data.attributes.uid,
title: channel.data.attributes.title,
subscribers: channel.data.attributes.subscribers,
};
await payload.create({
collection: Collections.VideosChannels,
data: videoChannel,
user,
});
} catch (e) {}
const result = await payload.find({
collection: Collections.VideosChannels,
data: videoChannel,
user,
where: { uid: { equals: channel.data.attributes.uid } },
});
} catch (e) {}
const result = await payload.find({
collection: Collections.VideosChannels,
where: { uid: { equals: channel.data.attributes.uid } },
});
if (result.docs.length === 0) {
throw new Error("A video channel is required to create a video");
if (result.docs.length > 0) {
videoChannelId = result.docs[0].id;
}
}
const videoChannel = result.docs[0] as VideosChannel;
const video: PayloadCreateData<Video> = {
uid,
title,
@ -77,7 +80,7 @@ export const importFromStrapi = createStrapiImportEndpoint<Video, StapiVideo>({
gone,
source,
publishedDate: `${year}-${month}-${day}`,
channel: videoChannel.id,
channel: videoChannelId,
};
await payload.create({

View File

@ -2,7 +2,6 @@ import { CollectionConfig } from "payload/types";
import { mustBeAdmin } from "../../accesses/collections/mustBeAdmin";
import { CollectionGroups, Collections } from "../../constants";
import { buildCollectionConfig } from "../../utils/collectionConfig";
import { importFromStrapi } from "./endpoints/importFromStrapi";
const fields = {
uid: "uid",
@ -28,7 +27,6 @@ export const VideosChannels: CollectionConfig = buildCollectionConfig({
create: mustBeAdmin,
delete: mustBeAdmin,
},
endpoints: [importFromStrapi],
timestamps: false,
fields: [
{ name: fields.uid, type: "text", required: true, unique: true },

View File

@ -1,24 +0,0 @@
import { Collections } from "../../../constants";
import { createStrapiImportEndpoint } from "../../../endpoints/createStrapiImportEndpoint";
import { VideosChannel } from "../../../types/collections";
type StrapiVideoChannel = {
uid: string;
title: string;
subscribers: number;
};
export const importFromStrapi = createStrapiImportEndpoint<VideosChannel, StrapiVideoChannel>({
strapi: {
collection: "video-channels",
params: {},
},
payload: {
collection: Collections.VideosChannels,
convert: ({ uid, title, subscribers }) => ({
uid,
title,
subscribers,
}),
},
});

View File

@ -1,5 +1,7 @@
import { RowLabelArgs } from "payload/dist/admin/components/forms/RowLabel/types";
import { CollectionGroups, Collections, KeysTypes } from "../../constants";
import { createGetByEndpoint } from "../../endpoints/createGetByEndpoint";
import { createGetSlugsEndpoint } from "../../endpoints/createGetSlugsEndpoint";
import { imageField } from "../../fields/imageField/imageField";
import { keysField } from "../../fields/keysField/keysField";
import { slugField } from "../../fields/slugField/slugField";
@ -32,8 +34,8 @@ export const Weapons = buildVersionedCollectionConfig({
admin: {
useAsTitle: fields.slug,
defaultColumns: [
fields.slug,
fields.thumbnail,
fields.slug,
fields.group,
fields.type,
fields.appearances,
@ -41,7 +43,11 @@ export const Weapons = buildVersionedCollectionConfig({
],
group: CollectionGroups.Collections,
},
endpoints: [importFromStrapi],
endpoints: [
importFromStrapi,
createGetSlugsEndpoint(Collections.Weapons),
createGetByEndpoint(Collections.Weapons, "slug"),
],
fields: [
{
type: "row",

View File

@ -1,10 +1,13 @@
import { CollectionGroups, Collections } from "../../constants";
import { Collections } from "../../constants";
import { backPropagationField } from "../../fields/backPropagationField/backPropagationField";
import { buildImageCollectionConfig } from "../../utils/imageCollectionConfig";
const fields = {
filename: "filename",
mimeType: "mimeType",
filesize: "filesize",
weapon: "weapon",
updatedAt: "updatedAt",
} as const satisfies Record<string, string>;
export const WeaponsThumbnails = buildImageCollectionConfig({
@ -13,12 +16,7 @@ export const WeaponsThumbnails = buildImageCollectionConfig({
singular: "Weapons Thumbnail",
plural: "Weapons Thumbnails",
},
defaultSort: fields.filename,
admin: {
useAsTitle: fields.filename,
disableDuplicate: true,
group: CollectionGroups.Media,
},
admin: { defaultColumns: [fields.filename, fields.weapon, fields.updatedAt] },
upload: {
imageSizes: [
{
@ -62,5 +60,12 @@ export const WeaponsThumbnails = buildImageCollectionConfig({
},
],
},
fields: [],
fields: [
backPropagationField({
name: fields.weapon,
hasMany: false,
relationTo: Collections.Weapons,
where: ({ id }) => ({ thumbnail: { equals: id } }),
}),
],
});

View File

@ -0,0 +1,69 @@
.grid {
margin-bottom: 25px;
> .grid__header {
margin-bottom: 15px;
display: flex;
gap: 1.5rem;
}
> .grid__cells {
display: grid;
grid-gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
> .grid__cells__cell {
display: grid;
grid-template-rows: 1fr;
background-color: var(--theme-elevation-50);
border: 1px solid var(--theme-elevation-100);
position: relative;
> .grid__cells__cell__filename {
position: relative;
aspect-ratio: 1;
.thumbnail {
> img {
position: absolute;
inset: 0;
object-fit: contain;
background-size: 9% 9%;
background-position: center;
background-image: radial-gradient(
circle,
var(--theme-elevation-100) 1px,
var(--theme-elevation-0) 1px
);
}
}
}
> .grid__cells__cell__selector {
position: absolute;
left: 0.75rem;
top: 0.75rem;
}
> .grid__cells__cell__info {
display: grid;
line-height: 1.5;
padding: 1rem;
gap: 0.5rem;
overflow-x: hidden;
> .grid__cells__cell__title {
font-weight: 600;
}
> .grid__cells__cell__others {
display: grid;
line-height: 1.5;
color: var(--theme-elevation-600);
width: 100%;
font-size: 90%;
}
}
}
}
}

View File

@ -0,0 +1,100 @@
import { useTableColumns } from "payload/dist/admin/components/elements/TableColumns";
import React, { Fragment } from "react";
import { Link } from "react-router-dom";
import "payload/dist/admin/components/elements/Table/index.scss";
import { Column } from "payload/dist/admin/components/elements/Table/types";
import Thumbnail from "payload/dist/admin/components/elements/Thumbnail";
import { SanitizedCollectionConfig } from "payload/types";
import "./index.scss";
const baseClass = "grid";
type Props = {
data: any[];
collection: SanitizedCollectionConfig;
};
const fieldNames = {
filename: "filename",
select: "_select",
};
export const Grid: React.FC<Props> = ({ data, collection }) => {
const { columns: columnsFromContext } = useTableColumns();
const fields = columnsFromContext;
const otherFields = fields?.filter(
(col) => col.active && ![fieldNames.filename, fieldNames.select].includes(col.accessor)
);
const filenameField = fields.find((col) => col.accessor === fieldNames.filename);
const selectorField = fields.find((col) => col.accessor === fieldNames.select);
const headerColumns = fields
.sort((a, b) => {
const sortingValue = (value: Column) => {
switch (value.accessor) {
case fieldNames.select:
return 2;
case fieldNames.filename:
return 1;
default:
return 0;
}
};
return sortingValue(b) - sortingValue(a);
})
.filter(({ active, accessor }) => active || accessor === fieldNames.filename);
return (
<div className={baseClass}>
<div className={`${baseClass}__header`}>
{headerColumns.map((col, index) => (
<div key={index} id={`heading-${col.accessor}`}>
{col.components.Heading}
</div>
))}
</div>
<div className={`${baseClass}__cells`}>
{data &&
data.map((gridCell, cellIndex) => (
<div key={cellIndex} className={`${baseClass}__cells__cell`}>
{filenameField && (
<Link
className={`${baseClass}__cells__cell__filename`}
to={`${collection.slug}/${gridCell.id}`}>
<Thumbnail collection={collection} doc={gridCell} />
</Link>
)}
{selectorField && (
<div className={`${baseClass}__cells__cell__selector`}>
{selectorField.components.renderCell(gridCell, gridCell[selectorField.accessor])}
</div>
)}
<div className={`${baseClass}__cells__cell__info`}>
{filenameField && (
<Link
className={`${baseClass}__cells__cell__title`}
to={`${collection.slug}/${gridCell.id}`}>
{String(gridCell[filenameField.accessor])}
</Link>
)}
{otherFields.length > 0 && (
<div className={`${baseClass}__cells__cell__others`}>
{otherFields.map((col, colIndex) => (
<Fragment key={colIndex}>
{col.components.renderCell(gridCell, gridCell[col.accessor])}
</Fragment>
))}
</div>
)}
</div>
</div>
))}
</div>
</div>
);
};
export default Grid;

View File

@ -0,0 +1,185 @@
import { useWindowInfo } from "@faceless-ui/window-info";
import Button from "payload/dist/admin/components/elements/Button";
import DeleteMany from "payload/dist/admin/components/elements/DeleteMany";
import EditMany from "payload/dist/admin/components/elements/EditMany";
import Eyebrow from "payload/dist/admin/components/elements/Eyebrow";
import { Gutter } from "payload/dist/admin/components/elements/Gutter";
import ListControls from "payload/dist/admin/components/elements/ListControls";
import ListSelection from "payload/dist/admin/components/elements/ListSelection";
import Paginator from "payload/dist/admin/components/elements/Paginator";
import PerPage from "payload/dist/admin/components/elements/PerPage";
import Pill from "payload/dist/admin/components/elements/Pill";
import PublishMany from "payload/dist/admin/components/elements/PublishMany";
import { StaggeredShimmers } from "payload/dist/admin/components/elements/ShimmerEffect";
import UnpublishMany from "payload/dist/admin/components/elements/UnpublishMany";
import ViewDescription from "payload/dist/admin/components/elements/ViewDescription";
import Meta from "payload/dist/admin/components/utilities/Meta";
import { RelationshipProvider } from "payload/dist/admin/components/views/collections/List/RelationshipProvider";
import { SelectionProvider } from "payload/dist/admin/components/views/collections/List/SelectionProvider";
import { Props } from "payload/dist/admin/components/views/collections/List/types";
import formatFilesize from "payload/dist/uploads/formatFilesize";
import { getTranslation } from "payload/dist/utilities/getTranslation";
import React, { Fragment } from "react";
import { useTranslation } from "react-i18next";
import Grid from "./Grid";
const baseClass = "collection-list";
export const UploadsGridView: React.ComponentType<Props> = (props) => {
const {
collection,
collection: {
labels: { singular: singularLabel, plural: pluralLabel },
admin: {
description,
components: { BeforeList, BeforeListTable, AfterListTable, AfterList } = {},
} = {},
},
data,
newDocumentURL,
limit,
hasCreatePermission,
disableEyebrow,
modifySearchParams,
handleSortChange,
handleWhereChange,
handlePageChange,
handlePerPageChange,
customHeader,
resetParams,
} = props;
const {
breakpoints: { s: smallBreak },
} = useWindowInfo();
const { t, i18n } = useTranslation("general");
let formattedDocs = data.docs || [];
if (collection.upload) {
formattedDocs = formattedDocs?.map((doc) => {
return {
...doc,
filesize: formatFilesize(doc.filesize),
};
});
}
return (
<div className={baseClass}>
{Array.isArray(BeforeList) &&
BeforeList.map((Component, i) => <Component key={i} {...props} />)}
<Meta title={getTranslation(collection.labels.plural, i18n)} />
<SelectionProvider docs={data.docs} totalDocs={data.totalDocs}>
{!disableEyebrow && <Eyebrow />}
<Gutter className={`${baseClass}__wrap`}>
<header className={`${baseClass}__header`}>
{customHeader && customHeader}
{!customHeader && (
<Fragment>
<h1>{getTranslation(pluralLabel, i18n)}</h1>
{hasCreatePermission && (
<Pill
to={newDocumentURL}
aria-label={t("createNewLabel", {
label: getTranslation(singularLabel, i18n),
})}>
{t("createNew")}
</Pill>
)}
{!smallBreak && (
<ListSelection label={getTranslation(collection.labels.plural, i18n)} />
)}
{description && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription description={description} />
</div>
)}
</Fragment>
)}
</header>
<ListControls
collection={collection}
modifySearchQuery={modifySearchParams}
handleSortChange={handleSortChange}
handleWhereChange={handleWhereChange}
resetParams={resetParams}
/>
{Array.isArray(BeforeListTable) &&
BeforeListTable.map((Component, i) => <Component key={i} {...props} />)}
{!data.docs && (
<StaggeredShimmers
className={[`${baseClass}__shimmer`, `${baseClass}__shimmer--rows`].join(" ")}
count={6}
/>
)}
{data.docs && data.docs.length > 0 && (
<RelationshipProvider>
<Grid data={formattedDocs} collection={collection} />
</RelationshipProvider>
)}
{data.docs && data.docs.length === 0 && (
<div className={`${baseClass}__no-results`}>
<p>{t("noResults", { label: getTranslation(pluralLabel, i18n) })}</p>
{hasCreatePermission && newDocumentURL && (
<Button el="link" to={newDocumentURL}>
{t("createNewLabel", { label: getTranslation(singularLabel, i18n) })}
</Button>
)}
</div>
)}
{Array.isArray(AfterListTable) &&
AfterListTable.map((Component, i) => <Component key={i} {...props} />)}
<div className={`${baseClass}__page-controls`}>
<Paginator
limit={data.limit}
totalPages={data.totalPages}
page={data.page}
hasPrevPage={data.hasPrevPage}
hasNextPage={data.hasNextPage}
prevPage={data.prevPage ?? undefined}
nextPage={data.nextPage ?? undefined}
numberOfNeighbors={1}
disableHistoryChange={modifySearchParams === false}
onChange={handlePageChange}
/>
{data?.totalDocs > 0 && (
<Fragment>
<div className={`${baseClass}__page-info`}>
{data.page ?? 1 * data.limit - (data.limit - 1)}-
{data.totalPages > 1 && data.totalPages !== data.page
? data.limit * (data.page ?? 1)
: data.totalDocs}{" "}
{t("of")} {data.totalDocs}
</div>
<PerPage
limits={collection?.admin?.pagination?.limits}
limit={limit}
modifySearchParams={modifySearchParams}
handleChange={handlePerPageChange}
resetPage={data.totalDocs <= data.pagingCounter}
/>
<div className={`${baseClass}__list-selection`}>
{smallBreak && (
<Fragment>
<ListSelection label={getTranslation(collection.labels.plural, i18n)} />
<div className={`${baseClass}__list-selection-actions`}>
<EditMany collection={collection} resetParams={resetParams} />
<PublishMany collection={collection} resetParams={resetParams} />
<UnpublishMany collection={collection} resetParams={resetParams} />
<DeleteMany collection={collection} resetParams={resetParams} />
</div>
</Fragment>
)}
</div>
</Fragment>
)}
</div>
</Gutter>
</SelectionProvider>
{Array.isArray(AfterList) &&
AfterList.map((Component, i) => <Component key={i} {...props} />)}
</div>
);
};

View File

@ -79,6 +79,7 @@ export enum LibraryItemsTextualPageOrders {
export enum RecordersRoles {
Admin = "Admin",
Recorder = "Recorder",
Api = "Api",
}
export enum CollectionStatus {

View File

@ -4,7 +4,7 @@ import { CollectionEndpoint } from "../types/payload";
export const createGetByEndpoint = <T, R>(
collection: string,
attribute: string,
handler: (doc: T) => Promise<R>
handler: (doc: T) => Promise<R> | R = (doc) => doc as unknown as R
): CollectionEndpoint => ({
path: `/${attribute}/:${attribute}`,
method: "get",

View File

@ -0,0 +1,39 @@
import payload from "payload";
import { mustBeApi } from "../accesses/endpoints/mustBeApi";
import { Collections } from "../constants";
import { CollectionEndpoint } from "../types/payload";
export const createGetSlugsEndpoint = (collection: Collections): CollectionEndpoint => ({
path: "/slugs",
method: "get",
handler: async (req, res) => {
if (!mustBeApi(req)) {
return res.status(403).send({
errors: [
{
message: "You are not allowed to perform this action.",
},
],
});
}
let page = 1;
let totalPage = 1;
const slugs: string[] = [];
while (page <= totalPage) {
const entries = await payload.find({
collection,
page,
user: req.user,
});
entries.docs.forEach(({ slug }: { slug: string }) => slugs.push(slug));
totalPage = entries.totalPages;
page++;
}
res.status(200).json(slugs);
},
});

View File

@ -0,0 +1,66 @@
import payload from "payload";
import { mustBeAdmin } from "../accesses/endpoints/mustBeAdmin";
import { Collections } from "../constants";
import { CollectionEndpoint } from "../types/payload";
import { isDefined } from "../utils/asserts";
type Image = {
filename: string;
id: string | number;
};
export const createImageRegenerationEndpoint = (collection: Collections): CollectionEndpoint => ({
method: "get",
path: "/regenerate",
handler: async (req, res) => {
if (!mustBeAdmin(req)) {
return res.status(403).send({
errors: [
{
message: "You are not allowed to perform this action.",
},
],
});
}
let page = 1;
let totalPage = 1;
let count = 0;
const errors: string[] = [];
while (page <= totalPage) {
const images = await payload.find({
collection,
page,
user: req.user,
});
await Promise.all(
images.docs.map(async (image: Image) => {
try {
await payload.update({
collection,
id: image.id,
data: {},
filePath: `uploads/${collection}/${image.filename}`,
overwriteExistingFiles: true,
});
} catch (e) {
console.warn(e);
if (typeof e === "object" && isDefined(e) && "name" in e) {
errors.push(`${e.name} with ${image.id}`);
}
}
})
);
totalPage = images.totalPages;
count += images.docs.length;
page++;
}
res
.status(200)
.json({ message: `${count} entries have been regenerated successfully.`, errors });
},
});

View File

@ -40,7 +40,7 @@ export const backPropagationField = ({
if (hasMany) {
return result.docs.map((doc) => doc.id);
} else {
return result.docs[0].id;
return result.docs[0]?.id;
}
}
return hasMany ? [] : undefined;

View File

@ -1,10 +1,23 @@
import { RelationshipField, UploadField } from "payload/types";
import { Collections } from "../../constants";
import { FieldBase, RelationshipField } from "payload/dist/fields/config/types";
import { Collections, FileTypes } from "../../constants";
type Props = Omit<UploadField, "type" | "relationTo">;
type FileField = FieldBase & {
relationTo: FileTypes;
hasMany?: boolean;
admin?: RelationshipField["admin"];
};
export const fileField = (props: Props): RelationshipField => ({
export const fileField = ({
relationTo,
hasMany = false,
...props
}: FileField): RelationshipField => ({
...props,
type: "relationship",
hasMany: hasMany,
relationTo: Collections.Files,
filterOptions: { type: { equals: getFileTypesKey(relationTo) } },
});
const getFileTypesKey = (fileType: FileTypes): string | undefined =>
Object.entries(FileTypes).find(([, value]) => value === fileType)?.[0];

View File

@ -4,7 +4,7 @@ import { Collections, KeysTypes } from "../../constants";
type KeysField = FieldBase & {
relationTo: KeysTypes;
hasMany?: boolean;
admin: RelationshipField["admin"];
admin?: RelationshipField["admin"];
};
export const keysField = ({

View File

@ -120,6 +120,11 @@ export interface LibraryItem {
};
audio?: {
audioSubtype?: string[] | Key[];
tracks?: {
title: string;
file: string | File;
id?: string;
}[];
};
releaseDate?: string;
categories?: string[] | Key[];
@ -159,6 +164,7 @@ export interface LibraryItem {
}
export interface LibraryItemThumbnail {
id: string;
libraryItem?: string[] | LibraryItem[];
updatedAt: string;
createdAt: string;
url?: string;
@ -304,6 +310,13 @@ export interface Language {
id: string;
name: string;
}
export interface File {
id: string;
filename: string;
type: "LibraryScans" | "LibrarySoundtracks" | "ContentVideo" | "ContentAudio";
updatedAt: string;
createdAt: string;
}
export interface Currency {
id: string;
}
@ -337,6 +350,7 @@ export interface Content {
}
export interface ContentsThumbnail {
id: string;
contents?: string[] | Content[];
updatedAt: string;
createdAt: string;
url?: string;
@ -370,6 +384,14 @@ export interface ContentsThumbnail {
filesize?: number;
filename?: string;
};
max?: {
url?: string;
width?: number;
height?: number;
mimeType?: string;
filesize?: number;
filename?: string;
};
};
}
export interface Recorder {
@ -378,7 +400,7 @@ export interface Recorder {
avatar?: string | RecordersThumbnail;
languages?: string[] | Language[];
biographies?: RecorderBiographies;
role?: ("Admin" | "Recorder")[];
role?: ("Admin" | "Recorder" | "Api")[];
anonymize: boolean;
email: string;
resetPasswordToken?: string;
@ -409,15 +431,7 @@ export interface RecordersThumbnail {
filesize?: number;
filename?: string;
};
og?: {
url?: string;
width?: number;
height?: number;
mimeType?: string;
filesize?: number;
filename?: string;
};
small?: {
square?: {
url?: string;
width?: number;
height?: number;
@ -614,13 +628,6 @@ export interface Tabs_Tab_Section_Section_Section_Section {
blockName?: string;
blockType: "section";
}
export interface File {
id: string;
filename: string;
type: "LibraryScans" | "LibrarySoundtracks" | "ContentVideo" | "ContentAudio";
updatedAt: string;
createdAt: string;
}
export interface ContentsFolder {
id: string;
slug: string;
@ -671,6 +678,7 @@ export interface Post {
}
export interface PostThumbnail {
id: string;
posts?: string[] | Post[];
updatedAt: string;
createdAt: string;
url?: string;
@ -788,6 +796,7 @@ export interface Weapon {
}
export interface WeaponsThumbnail {
id: string;
weapon?: string | Weapon;
updatedAt: string;
createdAt: string;
url?: string;
@ -859,7 +868,7 @@ export interface Video {
likes?: number;
views?: number;
publishedDate: string;
channel: string | VideosChannel;
channel?: string | VideosChannel;
}
export interface VideosChannel {
id: string;

View File

@ -1,4 +1,4 @@
import { CollectionConfig } from "payload/types";
import { CollectionConfig, PayloadRequest } from "payload/types";
export type PayloadCreateData<T> = Omit<
T,
@ -6,3 +6,5 @@ export type PayloadCreateData<T> = Omit<
>;
export type CollectionEndpoint = NonNullable<CollectionConfig["endpoints"]>[number];
export type EndpointAccess<U> = (req: PayloadRequest<U>) => boolean;

View File

@ -1,5 +1,8 @@
import { ImageSize } from "payload/dist/uploads/types";
import { CollectionConfig } from "payload/types";
import { UploadsGridView } from "../components/UploadsGridView/UploadsGridView";
import { CollectionGroups } from "../constants";
import { createImageRegenerationEndpoint } from "../endpoints/createImageRegenerationEndpoint";
import { BuildCollectionConfig, buildCollectionConfig } from "./collectionConfig";
type BuildImageCollectionConfig = Omit<BuildCollectionConfig, "upload"> & {
@ -7,11 +10,21 @@ type BuildImageCollectionConfig = Omit<BuildCollectionConfig, "upload"> & {
};
export const buildImageCollectionConfig = ({
admin,
upload: { imageSizes },
...otherConfig
}: BuildImageCollectionConfig): CollectionConfig =>
buildCollectionConfig({
...otherConfig,
defaultSort: "-updatedAt",
admin: {
disableDuplicate: true,
useAsTitle: "filename",
group: CollectionGroups.Media,
components: { views: { List: UploadsGridView } },
...admin,
},
endpoints: [createImageRegenerationEndpoint(otherConfig.slug)],
upload: {
staticDir: `../uploads/${otherConfig.slug}`,
mimeTypes: ["image/*"],

View File

@ -97,8 +97,8 @@
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
"alwaysStrict": true /* Ensure 'use strict' is always emitted. */,
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
"noUnusedLocals": true /* Enable error reporting when local variables aren't read. */,
"noUnusedParameters": true /* Raise an error when a function parameter isn't read. */,
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
"noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */,