Skip to content

Commit efe4d5f

Browse files
committed
feat(cms): implement file area block and seed a dedicated page
closed COD-292
1 parent 68c1639 commit efe4d5f

119 files changed

Lines changed: 18051 additions & 4471 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"runAllTestsOnStartup": false,
1818
"type": "on-save"
1919
},
20+
"nxConsole.generateAiAgentRules": true,
2021
"vitest.filesWatcherInclude": "apps/**,libs/**,packages/**",
2122
"[json]": {
2223
"editor.codeActionsOnSave": {

apps/cms/.env.local

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@ SENDGRID_FROM_NAME=
3838
# Ethereal email credentials (provide all details to enable)
3939
# Create an account at https://ethereal.email/create
4040
# Read your email at https://ethereal.email/messages
41-
ETHEREAL_FROM_ADDRESS=
42-
ETHEREAL_FROM_NAME=
41+
ETHEREAL_FROM_ADDRESS=info@ethereal.email
42+
ETHEREAL_FROM_NAME=Codeware Ethereal
4343
ETHEREAL_HOST=smtp.ethereal.email
4444
ETHEREAL_PORT=587
45-
ETHEREAL_USERNAME=
46-
ETHEREAL_PASSWORD=
45+
ETHEREAL_USERNAME=nestor.jones@ethereal.email
46+
ETHEREAL_PASSWORD=1TgEpNJqdeeKpuQFF7
4747

4848
# Set to true to prevent database sync and behave as if it's production.
4949
# This is required when serving the app after running migrations locally.

apps/cms/src/app/(payload)/admin/importMap.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { GlobalViewRedirect as GlobalViewRedirect_d6d5f193a167989e2ee7d14202901e
44
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc';
55
import { OverviewComponent as OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
66
import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
7-
import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
87
import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
8+
import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
99
import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
1010
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
1111
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
@@ -34,13 +34,15 @@ import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc056
3434

3535
import { default as default_1b21cdd8d72b60f58886e03c7a7a4ebd } from '@codeware/app-cms/ui/blocks/card/CardBlockArrayRowLabel.client';
3636
import { default as default_75fddbc22d1b88f24f1cec1b82919953 } from '@codeware/app-cms/ui/blocks/social-media/SocialMediaBlockArrayRowLabel.client';
37+
import { default as default_4cdb396fdb94ce9776953fed2fe6bc6a } from '@codeware/app-cms/ui/components/Callout';
3738
import { default as default_83b0dfab156f3636ed94b94854d15ad5 } from '@codeware/app-cms/ui/components/RedirectNotifier';
3839
import { default as default_7925a79d2af6389df70d2dd269ffbfbb } from '@codeware/app-cms/ui/components/VerifyTenantDomain';
3940
import { default as default_06af4458abd1296f9d6bccce90425927 } from '@codeware/app-cms/ui/fields/code/Code.client';
4041
import { default as default_8586e6ac5ddd6a3f87ffe1dd472673f5 } from '@codeware/app-cms/ui/fields/color-picker/ColorPickerField.client';
4142
import { default as default_ae19db27eee762af26f037dd7af0b736 } from '@codeware/app-cms/ui/fields/icon-picker/IconPickerField.client';
4243
import { default as default_42ab7a6f795fd44e8c166a2bb6b2adc0 } from '@codeware/apps/cms/components/Logo.client';
4344
import { default as default_d497a38447405736d600359900364450 } from '@codeware/apps/cms/components/NavigationArrayRowLabel';
45+
import { default as default_4a3552dc4f000a1797b4eb36166f8ff8 } from '@codeware/apps/cms/components/TenantsArrayField';
4446
import { default as default_dec1059b7bb8eb8da3a9f0fc400fffbd } from '@codeware/apps/cms/components/TenantsArrayRowLabel';
4547

4648
export const importMap = {
@@ -88,6 +90,8 @@ export const importMap = {
8890
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
8991
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
9092
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
93+
'@codeware/app-cms/ui/components/Callout#default':
94+
default_4cdb396fdb94ce9776953fed2fe6bc6a,
9195
'@codeware/apps/cms/components/NavigationArrayRowLabel#default':
9296
default_d497a38447405736d600359900364450,
9397
'@payloadcms/plugin-seo/client#OverviewComponent':
@@ -114,6 +118,8 @@ export const importMap = {
114118
default_06af4458abd1296f9d6bccce90425927,
115119
'@codeware/app-cms/ui/blocks/social-media/SocialMediaBlockArrayRowLabel.client#default':
116120
default_75fddbc22d1b88f24f1cec1b82919953,
121+
'@codeware/apps/cms/components/TenantsArrayField#default':
122+
default_4a3552dc4f000a1797b4eb36166f8ff8,
117123
'@codeware/apps/cms/components/TenantsArrayRowLabel#default':
118124
default_dec1059b7bb8eb8da3a9f0fc400fffbd,
119125
'@codeware/apps/cms/components/Logo.client#default':

apps/cms/src/collections/categories/categories.collection.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const categories: CollectionConfig = {
1414
slug: 'categories',
1515
admin: {
1616
group: adminGroups.content,
17-
defaultColumns: ['name', 'slug', 'tenant'],
17+
defaultColumns: ['name', 'slug'],
1818
useAsTitle: 'name'
1919
},
2020
access: {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { Access, Where } from 'payload';
2+
3+
import { verifyApiKeyAccess } from '@codeware/app-cms/util/access';
4+
import type { Media } from '@codeware/shared/util/payload-types';
5+
6+
/**
7+
* This access control ensures unauthenticated static file request
8+
* must have external property enabled.
9+
*
10+
* For all other requests, api key access is verified via `verifyApiKeyAccess`,
11+
* which is required for all tenant enabled collections.
12+
*
13+
* @param secret - The secret used to verify the api key
14+
*/
15+
export const externalOrApiKeyAccess =
16+
(secret: string): Access<Media> =>
17+
async (args) => {
18+
const { data, isReadingStaticFile, req } = args;
19+
const { payload, user } = req;
20+
21+
// If the request is for a static file and no user is authenticated,
22+
// lookup the document via filename and check if external is enabled.
23+
if (isReadingStaticFile && !user) {
24+
const filename = data?.filename;
25+
if (!filename) {
26+
payload.logger.error(
27+
'externalOrApiKeyAccess: Expected a filename value in data'
28+
);
29+
return false;
30+
}
31+
const { config } = payload.collections.media;
32+
33+
// File name can be the main name or one of the image sizes
34+
// e.g. filename: 'image.jpg', sizes: { small: { filename: 'image-small.jpg' } }
35+
const filenamesQuery: Array<Where> = [];
36+
37+
// Main filename
38+
filenamesQuery.push({
39+
filename: {
40+
equals: filename
41+
}
42+
});
43+
44+
// Image sizes filenames
45+
if (config.upload.imageSizes) {
46+
config.upload.imageSizes.forEach(({ name }) => {
47+
filenamesQuery.push({
48+
[`sizes.${name}.filename`]: {
49+
equals: filename
50+
}
51+
});
52+
});
53+
}
54+
55+
const doc = await payload.find({
56+
collection: 'media',
57+
limit: 1,
58+
depth: 0,
59+
where: { or: filenamesQuery },
60+
req
61+
});
62+
63+
if (!doc.totalDocs) {
64+
return false;
65+
}
66+
67+
// Allow access if the file is marked external
68+
return doc.docs[0].external === true;
69+
}
70+
71+
// Default: Resolve api key access for tenant enabled collection
72+
return verifyApiKeyAccess({ secret })(args);
73+
};

apps/cms/src/collections/media/media.collection.ts

Lines changed: 88 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,95 @@
11
import path from 'path';
22
import { fileURLToPath } from 'url';
33

4-
import type { CollectionConfig, GenerateImageName } from 'payload';
4+
import mimeTypes from 'mime-types';
5+
import type {
6+
CollectionBeforeValidateHook,
7+
CollectionConfig,
8+
Condition,
9+
GenerateImageName,
10+
TypeWithID
11+
} from 'payload';
512

6-
import { adminGroups } from '@codeware/app-cms/util/definitions';
13+
import { getEnv } from '@codeware/app-cms/feature/env-loader';
14+
import { tagsSelectField } from '@codeware/app-cms/ui/fields';
15+
import { adminGroups, getMimeTypes } from '@codeware/app-cms/util/definitions';
16+
import { Media } from '@codeware/shared/util/payload-types';
17+
18+
import { externalOrApiKeyAccess } from './access/external-or-api-key-access';
719

820
const filename = fileURLToPath(import.meta.url);
921
const dirname = path.dirname(filename);
22+
const env = getEnv();
1023

1124
/** Custom image name */
1225
const imageName: GenerateImageName = ({ extension, originalName, sizeName }) =>
1326
`${originalName}-${sizeName}.${extension}`;
1427

28+
const isImageOrVideo: Condition<TypeWithID, Media> = (_, siblingData) =>
29+
!!siblingData.mimeType && siblingData.mimeType.match(/image|video/) !== null;
30+
31+
// Extracting mime type during seed has a flaky bewhavior.
32+
// The mime type is not always available when the file is uploaded.
33+
const ensureMimeType: CollectionBeforeValidateHook<Media> = ({
34+
data,
35+
operation
36+
}) => {
37+
if (!data) {
38+
return data;
39+
}
40+
if (data.mimeType) {
41+
return data;
42+
}
43+
if (operation === 'create' || operation === 'update') {
44+
// Try to lookup the mime type from the filename
45+
data.mimeType = mimeTypes.lookup(data.filename ?? '') || undefined;
46+
}
47+
return data;
48+
};
49+
1550
/**
16-
* Media images collection.
51+
* Media collection for files with supported mime types.
52+
*
53+
* Uploaded images are converted to webp format.
1754
*
18-
* Upload limited to `image/*` mime types and images are converted to webp format.
55+
* **Mime types**
56+
*
57+
* `@codeware/app-cms/util/definitions`
1958
*/
2059
const media: CollectionConfig = {
2160
slug: 'media',
2261
admin: {
2362
group: adminGroups.fileArea,
24-
defaultColumns: ['filename', 'alt', 'tenant', 'updatedAt'],
25-
description: {
26-
en: 'Media images to use in posts and pages.',
27-
sv: 'Bilder som kan användas i inlägg och sidor.'
63+
defaultColumns: ['filename', 'mimeType', 'fileSize', 'tags', 'createdAt'],
64+
components: {
65+
beforeListTable: [
66+
{
67+
path: '@codeware/app-cms/ui/components/Callout',
68+
serverProps: {
69+
kind: 'tip',
70+
title: 'Using tags to organize media files',
71+
description: [
72+
'Use tags to organize your media files and easily select them in file areas.',
73+
'Tags can be created in the "Tags" collection.',
74+
'You can assign multiple tags to a media file.'
75+
]
76+
}
77+
}
78+
]
2879
}
2980
},
3081
access: {
31-
// Media files like images are not fetched, hence no api key to verify.
32-
// For admin access, the plugin appends proper permission filters.
33-
read: () => true
82+
read: externalOrApiKeyAccess(env.SIGNATURE_SECRET)
83+
},
84+
hooks: {
85+
beforeValidate: [ensureMimeType]
3486
},
3587
labels: {
3688
singular: { en: 'Media', sv: 'Media' },
3789
plural: { en: 'Media', sv: 'Media' }
3890
},
3991
upload: {
40-
mimeTypes: ['image/*'],
92+
mimeTypes: getMimeTypes(),
4193
// Uploaded image is converted to a backward compatible format known by all browsers.
4294
// This image should be used as the default image in a `<picture />` element.
4395
formatOptions: { format: 'jpeg' },
@@ -102,28 +154,35 @@ const media: CollectionConfig = {
102154
type: 'richText',
103155
localized: true,
104156
admin: {
157+
condition: isImageOrVideo,
105158
description: {
106-
en: 'The caption for the media.',
107-
sv: 'Bildtext för media.'
159+
en: 'Caption to display below an image or video.',
160+
sv: 'Text som visas under en bild eller video.'
108161
}
109162
}
110163
},
164+
tagsSelectField({
165+
buildIndex: true,
166+
overrides: {
167+
// TODO: Would like to use 'drawer' but it doesn't work with bulk upload media.
168+
// Better to be safe and wait for a fix.
169+
admin: { appearance: 'select' }
170+
}
171+
}),
111172
{
112-
type: 'tabs',
113-
tabs: [
114-
{
115-
label: { en: 'Posts', sv: 'Inlägg' },
116-
fields: [
117-
{
118-
name: 'relatedPosts',
119-
label: { en: 'Posts', sv: 'Inlägg' },
120-
type: 'join',
121-
collection: 'posts',
122-
on: 'heroImage'
123-
}
124-
]
125-
}
126-
]
173+
// Media files are not fetched, hence there's no api key to verify.
174+
// This property will be used as alternative access control for static file requests.
175+
name: 'external',
176+
type: 'checkbox',
177+
label: { en: 'External access', sv: 'Extern åtkomst' },
178+
admin: {
179+
description: {
180+
en: 'Allow external access to the file without authentication. For example, this is required for file areas and document images.',
181+
sv: 'Tillåt extern åtkomst till filen utan autentisering. Detta krävs till exempel för filytor och bilder i dokument.'
182+
},
183+
position: 'sidebar'
184+
},
185+
defaultValue: false
127186
}
128187
]
129188
};

apps/cms/src/collections/pages/pages.collection.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@ const env = getEnv();
1818
const blocks: Record<BlockSlug, boolean> = {
1919
content: true,
2020
card: true,
21+
'file-area': true,
2122
form: true,
23+
image: true,
2224
media: true,
2325
code: true,
2426
'reusable-content': true,
2527
'social-media': true,
26-
spacing: true
28+
spacing: true,
29+
// Unsupported blocks
30+
video: false
2731
};
2832

2933
/**
@@ -33,7 +37,7 @@ const pages: CollectionConfig<'pages'> = {
3337
slug: 'pages',
3438
admin: {
3539
group: adminGroups.content,
36-
defaultColumns: ['name', 'slug', 'tenant', 'updatedAt'],
40+
defaultColumns: ['name', 'slug', 'updatedAt'],
3741
useAsTitle: 'name',
3842
description: {
3943
en: 'Pages are the building blocks of the site and are used to create menus and navigation.',

apps/cms/src/collections/posts/posts.collection.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { BlocksFeature } from '@payloadcms/richtext-lexical';
33
import type { CollectionConfig } from 'payload';
44

55
import { getEnv } from '@codeware/app-cms/feature/env-loader';
6-
import { slugField } from '@codeware/app-cms/ui/fields';
6+
import { mediaUploadField, slugField } from '@codeware/app-cms/ui/fields';
77
import { multiTenantLinkFeature } from '@codeware/app-cms/ui/lexical';
88
import { seoTab } from '@codeware/app-cms/ui/tabs';
99
import { verifyApiKeyAccess } from '@codeware/app-cms/util/access';
@@ -24,12 +24,15 @@ const blocks: Record<BlockSlug, boolean> = {
2424
card: true,
2525
media: true,
2626
code: true,
27+
image: true,
2728
'social-media': true,
2829
spacing: true,
2930
// Unsupported blocks
31+
'file-area': false,
3032
form: false,
3133
content: false,
32-
'reusable-content': false
34+
'reusable-content': false,
35+
video: false
3336
};
3437

3538
/**
@@ -39,7 +42,7 @@ const posts: CollectionConfig<'posts'> = {
3942
slug: 'posts',
4043
admin: {
4144
group: adminGroups.content,
42-
defaultColumns: ['title', 'tenant', 'updatedAt'],
45+
defaultColumns: ['title', 'updatedAt'],
4346
useAsTitle: 'title',
4447
description: {
4548
en: 'Posts are standalone pages such as articles or blog posts and can be categorized.',
@@ -73,11 +76,10 @@ const posts: CollectionConfig<'posts'> = {
7376
{
7477
label: { en: 'Content', sv: 'Innehåll' },
7578
fields: [
76-
{
79+
mediaUploadField({
7780
name: 'heroImage',
78-
type: 'upload',
79-
relationTo: 'media'
80-
},
81+
mimeTypeSlugs: ['image']
82+
}),
8183
{
8284
name: 'content',
8385
type: 'richText',

0 commit comments

Comments
 (0)