Skip to content

Commit a030553

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

105 files changed

Lines changed: 15609 additions & 1179 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/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: 73 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,79 @@
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.
1752
*
18-
* Upload limited to `image/*` mime types and images are converted to webp format.
53+
* Uploaded images are converted to webp format.
54+
*
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.'
28-
}
63+
defaultColumns: ['filename', 'mimeType', 'fileSize', 'tags', 'createdAt']
2964
},
3065
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
66+
read: externalOrApiKeyAccess(env.SIGNATURE_SECRET)
67+
},
68+
hooks: {
69+
beforeValidate: [ensureMimeType]
3470
},
3571
labels: {
3672
singular: { en: 'Media', sv: 'Media' },
3773
plural: { en: 'Media', sv: 'Media' }
3874
},
3975
upload: {
40-
mimeTypes: ['image/*'],
76+
mimeTypes: getMimeTypes(),
4177
// Uploaded image is converted to a backward compatible format known by all browsers.
4278
// This image should be used as the default image in a `<picture />` element.
4379
formatOptions: { format: 'jpeg' },
@@ -102,28 +138,35 @@ const media: CollectionConfig = {
102138
type: 'richText',
103139
localized: true,
104140
admin: {
141+
condition: isImageOrVideo,
105142
description: {
106-
en: 'The caption for the media.',
107-
sv: 'Bildtext för media.'
143+
en: 'Caption to display below an image or video.',
144+
sv: 'Text som visas under en bild eller video.'
108145
}
109146
}
110147
},
148+
tagsSelectField({
149+
buildIndex: true,
150+
overrides: {
151+
// TODO: Would like to use 'drawer' but it doesn't work with bulk upload media.
152+
// Better to be safe and wait for a fix.
153+
admin: { appearance: 'select' }
154+
}
155+
}),
111156
{
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-
]
157+
// Media files are not fetched, hence there's no api key to verify.
158+
// This property will be used as alternative access control for static file requests.
159+
name: 'external',
160+
type: 'checkbox',
161+
label: { en: 'External access', sv: 'Extern åtkomst' },
162+
admin: {
163+
description: {
164+
en: 'Allow external access to the file without authentication. For example, this is required for file areas and document images.',
165+
sv: 'Tillåt extern åtkomst till filen utan autentisering. Detta krävs till exempel för filytor och bilder i dokument.'
166+
},
167+
position: 'sidebar'
168+
},
169+
defaultValue: false
127170
}
128171
]
129172
};

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',

apps/cms/src/collections/reusable-content/reusable-content.collection.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@ const blocks: Record<BlockSlug, boolean> = {
1616
card: true,
1717
code: true,
1818
content: true,
19+
'file-area': true,
1920
form: true,
21+
image: true,
2022
media: true,
2123
'social-media': true,
2224
spacing: true,
23-
'reusable-content': false
25+
'reusable-content': false,
26+
video: false
2427
};
2528

2629
/**

0 commit comments

Comments
 (0)