├── .changeset └── config.json ├── .github └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── dev ├── .env.example ├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode │ ├── extensions.json │ ├── launch.json │ └── settings.json ├── CHANGELOG.md ├── Dockerfile ├── README.md ├── docker-compose.yml ├── eslint.config.mjs ├── next.config.mjs ├── package.json ├── src │ ├── app │ │ ├── (frontend) │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ └── (payload) │ │ │ ├── admin │ │ │ ├── [[...segments]] │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ │ └── importMap.js │ │ │ ├── api │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ ├── graphql-playground │ │ │ │ └── route.ts │ │ │ └── graphql │ │ │ │ └── route.ts │ │ │ ├── custom.scss │ │ │ └── layout.tsx │ ├── collections │ │ ├── Media.ts │ │ ├── Movies.ts │ │ ├── SensitiveData │ │ │ └── index.ts │ │ └── Users.ts │ ├── payload-types.ts │ └── payload.config.ts └── tsconfig.json ├── gifs └── mux-preview.gif ├── package.json ├── packages ├── blur-data-urls │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── extendCollectionConfig.ts │ │ ├── hooks │ │ │ └── beforeChange.ts │ │ ├── index.ts │ │ ├── plugin.ts │ │ ├── types.ts │ │ └── utilities │ │ │ ├── generateDataUrl.ts │ │ │ └── getIncomingFiles.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── encrypted-fields │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── consts.ts │ │ ├── fields │ │ │ └── encryptedField.ts │ │ ├── hooks │ │ │ ├── decryptField.ts │ │ │ └── encryptField.ts │ │ ├── index.ts │ │ └── utils │ │ │ ├── decrypt.ts │ │ │ ├── encrypt.ts │ │ │ └── toPascalCase.ts │ ├── tsconfig.json │ └── tsup.config.ts └── mux-video │ ├── .gitignore │ ├── .prettierrc │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── collections │ │ └── MuxVideo.ts │ ├── endpoints │ │ ├── upload.ts │ │ └── webhook.ts │ ├── fields │ │ ├── index.ts │ │ └── mux-uploader │ │ │ ├── index.ts │ │ │ ├── mux-uploader.scss │ │ │ ├── mux-uploader.tsx │ │ │ ├── mux-video-gif-cell.tsx │ │ │ └── mux-video-image-cell.tsx │ ├── hooks │ │ ├── afterDelete.ts │ │ └── beforeChange.ts │ ├── index.ts │ ├── lib │ │ ├── defaultAccessFunction.ts │ │ ├── delay.ts │ │ ├── getAssetMetadata.ts │ │ └── onInitExtension.ts │ ├── plugin.ts │ └── types.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── scripts └── blurDataUrlsMigrationScript.ts /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | concurrency: 12 | group: build 13 | cancel-in-progress: true # Cancel any in-progress builds if a new one starts 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: pnpm/action-setup@v2 18 | with: 19 | version: 9 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 20.x 23 | cache: 'pnpm' 24 | 25 | - run: pnpm install --frozen-lockfile 26 | - run: pnpm --filter ./packages/* run build # Only build packages in /packages 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_run: 5 | workflows: [CI] 6 | branches: [main] 7 | types: [completed] 8 | 9 | concurrency: ${{ github.workflow }}-${{ github.ref }} 10 | 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | 15 | jobs: 16 | publish: 17 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: pnpm/action-setup@v2 22 | with: 23 | version: 9 24 | - uses: actions/setup-node@v3 25 | with: 26 | node-version: 20.x 27 | cache: 'pnpm' 28 | 29 | - run: pnpm install --frozen-lockfile 30 | 31 | - run: pnpm run build 32 | 33 | - name: Create Release Pull Request or Publish 34 | id: changesets 35 | uses: changesets/action@v1 36 | with: 37 | publish: pnpm run release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pnpm-debug.log* 2 | node_modules/ 3 | dist 4 | .DS_Store -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | pnpm-lock.yaml -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [], 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100, 6 | "semi": false 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 OVERSIGHT STUDIO LIMITED 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oversight Payload Plugins 2 | 3 | A collection of Payload plugins made by [Oversight Studio](https://oversight.studio/). 4 | 5 | ## Packages list: 6 | 7 | #### 1. [Blur Data URLs](packages/blur-data-urls/) - Auto-assigns blur data URLs to images. 8 | #### 2. [Mux Video](packages/mux-video/) - Brings Mux Video to Payload. 9 | #### 3. [Encrypted Fields](packages/encrypted-fields/) - Database field encryption for Payload. -------------------------------------------------------------------------------- /dev/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URI=file:./main.db 2 | PAYLOAD_SECRET=YOUR_SECRET_HERE 3 | 4 | # Mux 5 | MUX_TOKEN_ID="" 6 | MUX_TOKEN_SECRET="" 7 | MUX_WEBHOOK_SIGNING_SECRET="" -------------------------------------------------------------------------------- /dev/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | /.idea/* 10 | !/.idea/runConfigurations 11 | 12 | # testing 13 | /coverage 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | 41 | .env 42 | 43 | /media 44 | 45 | *.db -------------------------------------------------------------------------------- /dev/.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true -------------------------------------------------------------------------------- /dev/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"], 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100, 6 | "semi": false 7 | } 8 | -------------------------------------------------------------------------------- /dev/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /dev/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Next.js: debug full stack", 9 | "type": "node", 10 | "request": "launch", 11 | "program": "${workspaceFolder}/node_modules/next/dist/bin/next", 12 | "runtimeArgs": ["--inspect"], 13 | "skipFiles": ["/**"], 14 | "serverReadyAction": { 15 | "action": "debugWithChrome", 16 | "killOnServerStop": true, 17 | "pattern": "- Local:.+(https?://.+)", 18 | "uriFormat": "%s", 19 | "webRoot": "${workspaceFolder}" 20 | }, 21 | "cwd": "${workspaceFolder}" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /dev/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "npm.packageManager": "pnpm", 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "[typescript]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit" 9 | } 10 | }, 11 | "[typescriptreact]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode", 13 | "editor.formatOnSave": true, 14 | "editor.codeActionsOnSave": { 15 | "source.fixAll.eslint": "explicit" 16 | } 17 | }, 18 | "[javascript]": { 19 | "editor.defaultFormatter": "esbenp.prettier-vscode", 20 | "editor.formatOnSave": true, 21 | "editor.codeActionsOnSave": { 22 | "source.fixAll.eslint": "explicit" 23 | } 24 | }, 25 | "[json]": { 26 | "editor.defaultFormatter": "esbenp.prettier-vscode", 27 | "editor.formatOnSave": true 28 | }, 29 | "[jsonc]": { 30 | "editor.defaultFormatter": "esbenp.prettier-vscode", 31 | "editor.formatOnSave": true 32 | }, 33 | "editor.formatOnSaveMode": "file", 34 | "typescript.tsdk": "node_modules/typescript/lib", 35 | "[javascript][typescript][typescriptreact]": { 36 | "editor.codeActionsOnSave": { 37 | "source.fixAll.eslint": "explicit" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /dev/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # dev 2 | 3 | ## 1.0.4 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [54863a2] 8 | - @oversightstudio/blur-data-urls@1.0.2 9 | 10 | ## 1.0.3 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [7d410a5] 15 | - @oversightstudio/blur-data-urls@1.0.1 16 | -------------------------------------------------------------------------------- /dev/Dockerfile: -------------------------------------------------------------------------------- 1 | # To use this Dockerfile, you have to set `output: 'standalone'` in your next.config.mjs file. 2 | # From https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile 3 | 4 | FROM node:22.12.0-alpine AS base 5 | 6 | # Install dependencies only when needed 7 | FROM base AS deps 8 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 9 | RUN apk add --no-cache libc6-compat 10 | WORKDIR /app 11 | 12 | # Install dependencies based on the preferred package manager 13 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ 14 | RUN \ 15 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 16 | elif [ -f package-lock.json ]; then npm ci; \ 17 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 18 | else echo "Lockfile not found." && exit 1; \ 19 | fi 20 | 21 | 22 | # Rebuild the source code only when needed 23 | FROM base AS builder 24 | WORKDIR /app 25 | COPY --from=deps /app/node_modules ./node_modules 26 | COPY . . 27 | 28 | # Next.js collects completely anonymous telemetry data about general usage. 29 | # Learn more here: https://nextjs.org/telemetry 30 | # Uncomment the following line in case you want to disable telemetry during the build. 31 | # ENV NEXT_TELEMETRY_DISABLED 1 32 | 33 | RUN \ 34 | if [ -f yarn.lock ]; then yarn run build; \ 35 | elif [ -f package-lock.json ]; then npm run build; \ 36 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ 37 | else echo "Lockfile not found." && exit 1; \ 38 | fi 39 | 40 | # Production image, copy all the files and run next 41 | FROM base AS runner 42 | WORKDIR /app 43 | 44 | ENV NODE_ENV production 45 | # Uncomment the following line in case you want to disable telemetry during runtime. 46 | # ENV NEXT_TELEMETRY_DISABLED 1 47 | 48 | RUN addgroup --system --gid 1001 nodejs 49 | RUN adduser --system --uid 1001 nextjs 50 | 51 | # Remove this line if you do not have this folder 52 | COPY --from=builder /app/public ./public 53 | 54 | # Set the correct permission for prerender cache 55 | RUN mkdir .next 56 | RUN chown nextjs:nodejs .next 57 | 58 | # Automatically leverage output traces to reduce image size 59 | # https://nextjs.org/docs/advanced-features/output-file-tracing 60 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 61 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 62 | 63 | USER nextjs 64 | 65 | EXPOSE 3000 66 | 67 | ENV PORT 3000 68 | 69 | # server.js is created by next build from the standalone output 70 | # https://nextjs.org/docs/pages/api-reference/next-config-js/output 71 | CMD HOSTNAME="0.0.0.0" node server.js 72 | -------------------------------------------------------------------------------- /dev/README.md: -------------------------------------------------------------------------------- 1 | # blank 2 | 3 | blank 4 | 5 | ## Attributes 6 | 7 | - **Database**: mongodb 8 | - **Storage Adapter**: localDisk 9 | -------------------------------------------------------------------------------- /dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | # payload: 5 | # image: node:18-alpine 6 | # ports: 7 | # - '3000:3000' 8 | # volumes: 9 | # - .:/home/node/app 10 | # - node_modules:/home/node/app/node_modules 11 | # working_dir: /home/node/app/ 12 | # command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev" 13 | # depends_on: 14 | # - mongo 15 | # # - postgres 16 | # env_file: 17 | # - .env 18 | 19 | # Ensure your DATABASE_URI uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name 20 | mongo: 21 | image: mongo:latest 22 | ports: 23 | - '27017:27017' 24 | command: 25 | - --storageEngine=wiredTiger 26 | volumes: 27 | - data:/data/db 28 | logging: 29 | driver: none 30 | # Uncomment the following to use postgres 31 | # postgres: 32 | # restart: always 33 | # image: postgres:latest 34 | # volumes: 35 | # - pgdata:/var/lib/postgresql/data 36 | # ports: 37 | # - "5432:5432" 38 | 39 | volumes: 40 | data: # pgdata: 41 | 42 | node_modules: 43 | -------------------------------------------------------------------------------- /dev/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path' 2 | import { fileURLToPath } from 'url' 3 | import { FlatCompat } from '@eslint/eslintrc' 4 | 5 | const __filename = fileURLToPath(import.meta.url) 6 | const __dirname = dirname(__filename) 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }) 11 | 12 | const eslintConfig = [ 13 | ...compat.extends('next/core-web-vitals', 'next/typescript'), 14 | { 15 | rules: { 16 | '@typescript-eslint/ban-ts-comment': 'warn', 17 | '@typescript-eslint/no-empty-object-type': 'warn', 18 | '@typescript-eslint/no-explicit-any': 'warn', 19 | '@typescript-eslint/no-unused-vars': [ 20 | 'warn', 21 | { 22 | vars: 'all', 23 | args: 'after-used', 24 | ignoreRestSiblings: false, 25 | argsIgnorePattern: '^_', 26 | varsIgnorePattern: '^_', 27 | destructuredArrayIgnorePattern: '^_', 28 | caughtErrorsIgnorePattern: '^(_|ignore)', 29 | }, 30 | ], 31 | }, 32 | }, 33 | ] 34 | 35 | export default eslintConfig 36 | -------------------------------------------------------------------------------- /dev/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withPayload } from '@payloadcms/next/withPayload' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | // Your Next.js config here 6 | } 7 | 8 | export default withPayload(nextConfig) 9 | -------------------------------------------------------------------------------- /dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dev", 3 | "version": "1.0.12", 4 | "description": "A blank template to get started with Payload 3.0", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "build": "cross-env NODE_OPTIONS=--no-deprecation next build", 9 | "dev": "cross-env NODE_OPTIONS=--no-deprecation next dev", 10 | "devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev", 11 | "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", 12 | "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", 13 | "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", 14 | "payload": "cross-env NODE_OPTIONS=--no-deprecation payload", 15 | "start": "cross-env NODE_OPTIONS=--no-deprecation next start" 16 | }, 17 | "dependencies": { 18 | "@oversightstudio/blur-data-urls": "workspace:*", 19 | "@oversightstudio/mux-video": "workspace:*", 20 | "@oversightstudio/encrypted-fields": "workspace:*", 21 | "@payloadcms/db-sqlite": "^3.15.1", 22 | "@payloadcms/next": "^3.15.1", 23 | "@payloadcms/richtext-lexical": "^3.15.1", 24 | "@payloadcms/ui": "^3.15.0", 25 | "cross-env": "^7.0.3", 26 | "graphql": "^16.8.1", 27 | "payload": "^3.15.1", 28 | "react": "19.0.0", 29 | "react-dom": "19.0.0", 30 | "sharp": "0.32.6" 31 | }, 32 | "devDependencies": { 33 | "@eslint/eslintrc": "^3.2.0", 34 | "@types/node": "^22.5.4", 35 | "@types/react": "19.0.1", 36 | "@types/react-dom": "19.0.1", 37 | "eslint": "^9.16.0", 38 | "eslint-config-next": "15.1.0", 39 | "next": "15.1.0", 40 | "typescript": "5.7.2" 41 | }, 42 | "engines": { 43 | "node": "^18.20.2 || >=20.9.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /dev/src/app/(frontend)/layout.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: 'Next.js', 3 | description: 'Generated by Next.js', 4 | } 5 | 6 | export default function RootLayout({ children }: { children: React.ReactNode }) { 7 | return ( 8 | 9 | {children} 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /dev/src/app/(frontend)/page.tsx: -------------------------------------------------------------------------------- 1 | import { getPayload } from 'payload' 2 | import config from '@/payload.config' 3 | import Image from 'next/image' 4 | 5 | async function Page() { 6 | const payload = await getPayload({ config }) 7 | 8 | const images = await payload.find({ 9 | collection: 'media', 10 | }) 11 | 12 | return ( 13 |
14 |

Hello world

15 | {images.docs.map((image) => ( 16 |
17 | {image.alt} 31 |
32 | ))} 33 |
34 | ) 35 | } 36 | 37 | export default Page 38 | -------------------------------------------------------------------------------- /dev/src/app/(payload)/admin/[[...segments]]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views' 7 | import { importMap } from '../importMap' 8 | 9 | type Args = { 10 | params: Promise<{ 11 | segments: string[] 12 | }> 13 | searchParams: Promise<{ 14 | [key: string]: string | string[] 15 | }> 16 | } 17 | 18 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 19 | generatePageMetadata({ config, params, searchParams }) 20 | 21 | const NotFound = ({ params, searchParams }: Args) => 22 | NotFoundPage({ config, params, searchParams, importMap }) 23 | 24 | export default NotFound 25 | -------------------------------------------------------------------------------- /dev/src/app/(payload)/admin/[[...segments]]/page.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { RootPage, generatePageMetadata } from '@payloadcms/next/views' 7 | import { importMap } from '../importMap' 8 | 9 | type Args = { 10 | params: Promise<{ 11 | segments: string[] 12 | }> 13 | searchParams: Promise<{ 14 | [key: string]: string | string[] 15 | }> 16 | } 17 | 18 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 19 | generatePageMetadata({ config, params, searchParams }) 20 | 21 | const Page = ({ params, searchParams }: Args) => 22 | RootPage({ config, params, searchParams, importMap }) 23 | 24 | export default Page 25 | -------------------------------------------------------------------------------- /dev/src/app/(payload)/admin/importMap.js: -------------------------------------------------------------------------------- 1 | import { TextField as TextField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' 2 | import { NumberField as NumberField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' 3 | import { SelectField as SelectField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' 4 | import { CheckboxField as CheckboxField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' 5 | import { DateTimeField as DateTimeField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' 6 | import { JSONField as JSONField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' 7 | import { TextareaField as TextareaField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' 8 | import { CodeField as CodeField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' 9 | import { RadioGroupField as RadioGroupField_3817bf644402e67bfe6577f60ef982de } from '@payloadcms/ui' 10 | import { MuxUploaderField as MuxUploaderField_c369a797e256de625eba826a6acb8608 } from '@oversightstudio/mux-video/elements' 11 | import { MuxVideoGifCell as MuxVideoGifCell_c369a797e256de625eba826a6acb8608 } from '@oversightstudio/mux-video/elements' 12 | 13 | export const importMap = { 14 | "@payloadcms/ui#TextField": TextField_3817bf644402e67bfe6577f60ef982de, 15 | "@payloadcms/ui#NumberField": NumberField_3817bf644402e67bfe6577f60ef982de, 16 | "@payloadcms/ui#SelectField": SelectField_3817bf644402e67bfe6577f60ef982de, 17 | "@payloadcms/ui#CheckboxField": CheckboxField_3817bf644402e67bfe6577f60ef982de, 18 | "@payloadcms/ui#DateTimeField": DateTimeField_3817bf644402e67bfe6577f60ef982de, 19 | "@payloadcms/ui#JSONField": JSONField_3817bf644402e67bfe6577f60ef982de, 20 | "@payloadcms/ui#TextareaField": TextareaField_3817bf644402e67bfe6577f60ef982de, 21 | "@payloadcms/ui#CodeField": CodeField_3817bf644402e67bfe6577f60ef982de, 22 | "@payloadcms/ui#RadioGroupField": RadioGroupField_3817bf644402e67bfe6577f60ef982de, 23 | "@oversightstudio/mux-video/elements#MuxUploaderField": MuxUploaderField_c369a797e256de625eba826a6acb8608, 24 | "@oversightstudio/mux-video/elements#MuxVideoGifCell": MuxVideoGifCell_c369a797e256de625eba826a6acb8608 25 | } 26 | -------------------------------------------------------------------------------- /dev/src/app/(payload)/api/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { 6 | REST_DELETE, 7 | REST_GET, 8 | REST_OPTIONS, 9 | REST_PATCH, 10 | REST_POST, 11 | REST_PUT, 12 | } from '@payloadcms/next/routes' 13 | 14 | export const GET = REST_GET(config) 15 | export const POST = REST_POST(config) 16 | export const DELETE = REST_DELETE(config) 17 | export const PATCH = REST_PATCH(config) 18 | export const PUT = REST_PUT(config) 19 | export const OPTIONS = REST_OPTIONS(config) 20 | -------------------------------------------------------------------------------- /dev/src/app/(payload)/api/graphql-playground/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' 6 | 7 | export const GET = GRAPHQL_PLAYGROUND_GET(config) 8 | -------------------------------------------------------------------------------- /dev/src/app/(payload)/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes' 5 | 6 | export const POST = GRAPHQL_POST(config) 7 | 8 | export const OPTIONS = REST_OPTIONS(config) 9 | -------------------------------------------------------------------------------- /dev/src/app/(payload)/custom.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oversightstudio/payload-plugins/4b02352fc33f76d9c4cf36fef9879079d2039d59/dev/src/app/(payload)/custom.scss -------------------------------------------------------------------------------- /dev/src/app/(payload)/layout.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import type { ServerFunctionClient } from 'payload' 6 | import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' 7 | import React from 'react' 8 | 9 | import { importMap } from './admin/importMap.js' 10 | import './custom.scss' 11 | 12 | type Args = { 13 | children: React.ReactNode 14 | } 15 | 16 | const serverFunction: ServerFunctionClient = async function (args) { 17 | 'use server' 18 | return handleServerFunctions({ 19 | ...args, 20 | config, 21 | importMap, 22 | }) 23 | } 24 | 25 | const Layout = ({ children }: Args) => ( 26 | 27 | {children} 28 | 29 | ) 30 | 31 | export default Layout 32 | -------------------------------------------------------------------------------- /dev/src/collections/Media.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload' 2 | 3 | export const Media: CollectionConfig = { 4 | slug: 'media', 5 | access: { 6 | read: () => true, 7 | }, 8 | fields: [ 9 | { 10 | name: 'alt', 11 | type: 'text', 12 | required: true, 13 | }, 14 | ], 15 | upload: true, 16 | } 17 | -------------------------------------------------------------------------------- /dev/src/collections/Movies.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload' 2 | 3 | export const Movies: CollectionConfig = { 4 | slug: 'movies', 5 | admin: { 6 | useAsTitle: 'title', 7 | defaultColumns: ['title', 'movieUploader', 'duration'], 8 | }, 9 | fields: [ 10 | { 11 | name: 'title', 12 | type: 'text', 13 | required: true, 14 | }, 15 | { 16 | name: 'trailer', 17 | type: 'relationship', 18 | relationTo: 'movies', 19 | }, 20 | ], 21 | } 22 | -------------------------------------------------------------------------------- /dev/src/collections/SensitiveData/index.ts: -------------------------------------------------------------------------------- 1 | import { encryptedField } from '@oversightstudio/encrypted-fields' 2 | import { CollectionConfig } from 'payload' 3 | 4 | export const SensitiveData: CollectionConfig = { 5 | slug: 'sensitive-data', 6 | fields: [ 7 | encryptedField({ 8 | name: 'name', 9 | type: 'text', 10 | }), 11 | encryptedField({ 12 | name: 'age', 13 | type: 'number', 14 | }), 15 | encryptedField({ 16 | name: 'favoriteAges', 17 | type: 'number', 18 | hasMany: true, 19 | }), 20 | encryptedField({ 21 | name: 'gender', 22 | type: 'select', 23 | options: [ 24 | { label: 'Male', value: 'male' }, 25 | { label: 'Female', value: 'female' }, 26 | { label: 'Other', value: 'other' }, 27 | ], 28 | }), 29 | encryptedField({ 30 | name: 'traits', 31 | type: 'select', 32 | options: [ 33 | { label: 'Cool', value: 'cool' }, 34 | { label: 'Based', value: 'based' }, 35 | { label: 'Sexy', value: 'sexy' }, 36 | ], 37 | hasMany: true, 38 | }), 39 | encryptedField({ 40 | name: 'auraFarmer', 41 | type: 'checkbox', 42 | }), 43 | encryptedField({ 44 | name: 'birthDate', 45 | type: 'date', 46 | }), 47 | encryptedField({ 48 | name: 'jsonData', 49 | type: 'json', 50 | }), 51 | encryptedField({ 52 | name: 'description', 53 | type: 'textarea', 54 | }), 55 | encryptedField({ 56 | name: 'favoriteCode', 57 | type: 'code', 58 | }), 59 | encryptedField({ 60 | name: 'powerLevel', 61 | type: 'radio', 62 | options: [ 63 | { label: 'Low', value: 'low' }, 64 | { label: 'Medium', value: 'medium' }, 65 | { label: 'High', value: 'high' }, 66 | ], 67 | }), 68 | ], 69 | } 70 | -------------------------------------------------------------------------------- /dev/src/collections/Users.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload' 2 | 3 | export const Users: CollectionConfig = { 4 | slug: 'users', 5 | admin: { 6 | useAsTitle: 'email', 7 | }, 8 | auth: true, 9 | fields: [ 10 | // Email added by default 11 | // Add more fields as needed 12 | ], 13 | } 14 | -------------------------------------------------------------------------------- /dev/src/payload-types.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * This file was automatically generated by Payload. 5 | * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, 6 | * and re-run `payload generate:types` to regenerate this file. 7 | */ 8 | 9 | /** 10 | * Supported timezones in IANA format. 11 | * 12 | * This interface was referenced by `Config`'s JSON-Schema 13 | * via the `definition` "supportedTimezones". 14 | */ 15 | export type SupportedTimezones = 16 | | 'Pacific/Midway' 17 | | 'Pacific/Niue' 18 | | 'Pacific/Honolulu' 19 | | 'Pacific/Rarotonga' 20 | | 'America/Anchorage' 21 | | 'Pacific/Gambier' 22 | | 'America/Los_Angeles' 23 | | 'America/Tijuana' 24 | | 'America/Denver' 25 | | 'America/Phoenix' 26 | | 'America/Chicago' 27 | | 'America/Guatemala' 28 | | 'America/New_York' 29 | | 'America/Bogota' 30 | | 'America/Caracas' 31 | | 'America/Santiago' 32 | | 'America/Buenos_Aires' 33 | | 'America/Sao_Paulo' 34 | | 'Atlantic/South_Georgia' 35 | | 'Atlantic/Azores' 36 | | 'Atlantic/Cape_Verde' 37 | | 'Europe/London' 38 | | 'Europe/Berlin' 39 | | 'Africa/Lagos' 40 | | 'Europe/Athens' 41 | | 'Africa/Cairo' 42 | | 'Europe/Moscow' 43 | | 'Asia/Riyadh' 44 | | 'Asia/Dubai' 45 | | 'Asia/Baku' 46 | | 'Asia/Karachi' 47 | | 'Asia/Tashkent' 48 | | 'Asia/Calcutta' 49 | | 'Asia/Dhaka' 50 | | 'Asia/Almaty' 51 | | 'Asia/Jakarta' 52 | | 'Asia/Bangkok' 53 | | 'Asia/Shanghai' 54 | | 'Asia/Singapore' 55 | | 'Asia/Tokyo' 56 | | 'Asia/Seoul' 57 | | 'Australia/Sydney' 58 | | 'Pacific/Guam' 59 | | 'Pacific/Noumea' 60 | | 'Pacific/Auckland' 61 | | 'Pacific/Fiji'; 62 | 63 | export interface Config { 64 | auth: { 65 | users: UserAuthOperations; 66 | }; 67 | blocks: {}; 68 | collections: { 69 | users: User; 70 | media: Media; 71 | movies: Movie; 72 | 'sensitive-data': SensitiveDatum; 73 | 'mux-video': MuxVideo; 74 | 'payload-locked-documents': PayloadLockedDocument; 75 | 'payload-preferences': PayloadPreference; 76 | 'payload-migrations': PayloadMigration; 77 | }; 78 | collectionsJoins: {}; 79 | collectionsSelect: { 80 | users: UsersSelect | UsersSelect; 81 | media: MediaSelect | MediaSelect; 82 | movies: MoviesSelect | MoviesSelect; 83 | 'sensitive-data': SensitiveDataSelect | SensitiveDataSelect; 84 | 'mux-video': MuxVideoSelect | MuxVideoSelect; 85 | 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 86 | 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 87 | 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; 88 | }; 89 | db: { 90 | defaultIDType: number; 91 | }; 92 | globals: {}; 93 | globalsSelect: {}; 94 | locale: null; 95 | user: User & { 96 | collection: 'users'; 97 | }; 98 | jobs: { 99 | tasks: unknown; 100 | workflows: unknown; 101 | }; 102 | } 103 | export interface UserAuthOperations { 104 | forgotPassword: { 105 | email: string; 106 | password: string; 107 | }; 108 | login: { 109 | email: string; 110 | password: string; 111 | }; 112 | registerFirstUser: { 113 | email: string; 114 | password: string; 115 | }; 116 | unlock: { 117 | email: string; 118 | password: string; 119 | }; 120 | } 121 | /** 122 | * This interface was referenced by `Config`'s JSON-Schema 123 | * via the `definition` "users". 124 | */ 125 | export interface User { 126 | id: number; 127 | updatedAt: string; 128 | createdAt: string; 129 | email: string; 130 | resetPasswordToken?: string | null; 131 | resetPasswordExpiration?: string | null; 132 | salt?: string | null; 133 | hash?: string | null; 134 | loginAttempts?: number | null; 135 | lockUntil?: string | null; 136 | password?: string | null; 137 | } 138 | /** 139 | * This interface was referenced by `Config`'s JSON-Schema 140 | * via the `definition` "media". 141 | */ 142 | export interface Media { 143 | id: number; 144 | alt: string; 145 | blurDataUrl?: string | null; 146 | updatedAt: string; 147 | createdAt: string; 148 | url?: string | null; 149 | thumbnailURL?: string | null; 150 | filename?: string | null; 151 | mimeType?: string | null; 152 | filesize?: number | null; 153 | width?: number | null; 154 | height?: number | null; 155 | focalX?: number | null; 156 | focalY?: number | null; 157 | } 158 | /** 159 | * This interface was referenced by `Config`'s JSON-Schema 160 | * via the `definition` "movies". 161 | */ 162 | export interface Movie { 163 | id: number; 164 | title: string; 165 | trailer?: (number | null) | Movie; 166 | updatedAt: string; 167 | createdAt: string; 168 | } 169 | /** 170 | * This interface was referenced by `Config`'s JSON-Schema 171 | * via the `definition` "sensitive-data". 172 | */ 173 | export interface SensitiveDatum { 174 | id: number; 175 | name?: string | null; 176 | age?: string | null; 177 | favoriteAges?: string[] | null; 178 | gender?: string | null; 179 | traits?: string[] | null; 180 | auraFarmer?: string | null; 181 | birthDate?: string | null; 182 | jsonData?: string | null; 183 | description?: string | null; 184 | favoriteCode?: string | null; 185 | powerLevel?: string | null; 186 | updatedAt: string; 187 | createdAt: string; 188 | } 189 | /** 190 | * This interface was referenced by `Config`'s JSON-Schema 191 | * via the `definition` "mux-video". 192 | */ 193 | export interface MuxVideo { 194 | id: number; 195 | /** 196 | * A unique title for this video that will help you identify it later. 197 | */ 198 | title: string; 199 | assetId?: string | null; 200 | duration?: number | null; 201 | /** 202 | * Pick a timestamp (in seconds) from the video to be used as the poster image. When unset, defaults to the middle of the video. 203 | */ 204 | posterTimestamp?: number | null; 205 | aspectRatio?: string | null; 206 | maxWidth?: number | null; 207 | maxHeight?: number | null; 208 | playbackOptions?: 209 | | { 210 | playbackId?: string | null; 211 | playbackPolicy?: ('signed' | 'public') | null; 212 | playbackUrl?: string | null; 213 | posterUrl?: string | null; 214 | gifUrl?: string | null; 215 | id?: string | null; 216 | }[] 217 | | null; 218 | updatedAt: string; 219 | createdAt: string; 220 | } 221 | /** 222 | * This interface was referenced by `Config`'s JSON-Schema 223 | * via the `definition` "payload-locked-documents". 224 | */ 225 | export interface PayloadLockedDocument { 226 | id: number; 227 | document?: 228 | | ({ 229 | relationTo: 'users'; 230 | value: number | User; 231 | } | null) 232 | | ({ 233 | relationTo: 'media'; 234 | value: number | Media; 235 | } | null) 236 | | ({ 237 | relationTo: 'movies'; 238 | value: number | Movie; 239 | } | null) 240 | | ({ 241 | relationTo: 'sensitive-data'; 242 | value: number | SensitiveDatum; 243 | } | null) 244 | | ({ 245 | relationTo: 'mux-video'; 246 | value: number | MuxVideo; 247 | } | null); 248 | globalSlug?: string | null; 249 | user: { 250 | relationTo: 'users'; 251 | value: number | User; 252 | }; 253 | updatedAt: string; 254 | createdAt: string; 255 | } 256 | /** 257 | * This interface was referenced by `Config`'s JSON-Schema 258 | * via the `definition` "payload-preferences". 259 | */ 260 | export interface PayloadPreference { 261 | id: number; 262 | user: { 263 | relationTo: 'users'; 264 | value: number | User; 265 | }; 266 | key?: string | null; 267 | value?: 268 | | { 269 | [k: string]: unknown; 270 | } 271 | | unknown[] 272 | | string 273 | | number 274 | | boolean 275 | | null; 276 | updatedAt: string; 277 | createdAt: string; 278 | } 279 | /** 280 | * This interface was referenced by `Config`'s JSON-Schema 281 | * via the `definition` "payload-migrations". 282 | */ 283 | export interface PayloadMigration { 284 | id: number; 285 | name?: string | null; 286 | batch?: number | null; 287 | updatedAt: string; 288 | createdAt: string; 289 | } 290 | /** 291 | * This interface was referenced by `Config`'s JSON-Schema 292 | * via the `definition` "users_select". 293 | */ 294 | export interface UsersSelect { 295 | updatedAt?: T; 296 | createdAt?: T; 297 | email?: T; 298 | resetPasswordToken?: T; 299 | resetPasswordExpiration?: T; 300 | salt?: T; 301 | hash?: T; 302 | loginAttempts?: T; 303 | lockUntil?: T; 304 | } 305 | /** 306 | * This interface was referenced by `Config`'s JSON-Schema 307 | * via the `definition` "media_select". 308 | */ 309 | export interface MediaSelect { 310 | alt?: T; 311 | blurDataUrl?: T; 312 | updatedAt?: T; 313 | createdAt?: T; 314 | url?: T; 315 | thumbnailURL?: T; 316 | filename?: T; 317 | mimeType?: T; 318 | filesize?: T; 319 | width?: T; 320 | height?: T; 321 | focalX?: T; 322 | focalY?: T; 323 | } 324 | /** 325 | * This interface was referenced by `Config`'s JSON-Schema 326 | * via the `definition` "movies_select". 327 | */ 328 | export interface MoviesSelect { 329 | title?: T; 330 | trailer?: T; 331 | updatedAt?: T; 332 | createdAt?: T; 333 | } 334 | /** 335 | * This interface was referenced by `Config`'s JSON-Schema 336 | * via the `definition` "sensitive-data_select". 337 | */ 338 | export interface SensitiveDataSelect { 339 | name?: T; 340 | age?: T; 341 | favoriteAges?: T; 342 | gender?: T; 343 | traits?: T; 344 | auraFarmer?: T; 345 | birthDate?: T; 346 | jsonData?: T; 347 | description?: T; 348 | favoriteCode?: T; 349 | powerLevel?: T; 350 | updatedAt?: T; 351 | createdAt?: T; 352 | } 353 | /** 354 | * This interface was referenced by `Config`'s JSON-Schema 355 | * via the `definition` "mux-video_select". 356 | */ 357 | export interface MuxVideoSelect { 358 | title?: T; 359 | assetId?: T; 360 | duration?: T; 361 | posterTimestamp?: T; 362 | aspectRatio?: T; 363 | maxWidth?: T; 364 | maxHeight?: T; 365 | playbackOptions?: 366 | | T 367 | | { 368 | playbackId?: T; 369 | playbackPolicy?: T; 370 | playbackUrl?: T; 371 | posterUrl?: T; 372 | gifUrl?: T; 373 | id?: T; 374 | }; 375 | updatedAt?: T; 376 | createdAt?: T; 377 | } 378 | /** 379 | * This interface was referenced by `Config`'s JSON-Schema 380 | * via the `definition` "payload-locked-documents_select". 381 | */ 382 | export interface PayloadLockedDocumentsSelect { 383 | document?: T; 384 | globalSlug?: T; 385 | user?: T; 386 | updatedAt?: T; 387 | createdAt?: T; 388 | } 389 | /** 390 | * This interface was referenced by `Config`'s JSON-Schema 391 | * via the `definition` "payload-preferences_select". 392 | */ 393 | export interface PayloadPreferencesSelect { 394 | user?: T; 395 | key?: T; 396 | value?: T; 397 | updatedAt?: T; 398 | createdAt?: T; 399 | } 400 | /** 401 | * This interface was referenced by `Config`'s JSON-Schema 402 | * via the `definition` "payload-migrations_select". 403 | */ 404 | export interface PayloadMigrationsSelect { 405 | name?: T; 406 | batch?: T; 407 | updatedAt?: T; 408 | createdAt?: T; 409 | } 410 | /** 411 | * This interface was referenced by `Config`'s JSON-Schema 412 | * via the `definition` "auth". 413 | */ 414 | export interface Auth { 415 | [k: string]: unknown; 416 | } 417 | 418 | 419 | declare module 'payload' { 420 | export interface GeneratedTypes extends Config {} 421 | } -------------------------------------------------------------------------------- /dev/src/payload.config.ts: -------------------------------------------------------------------------------- 1 | // storage-adapter-import-placeholder 2 | import { sqliteAdapter } from '@payloadcms/db-sqlite' 3 | import { lexicalEditor } from '@payloadcms/richtext-lexical' 4 | import path from 'path' 5 | import { buildConfig } from 'payload' 6 | import { fileURLToPath } from 'url' 7 | import sharp from 'sharp' 8 | import { blurDataUrlsPlugin } from '@oversightstudio/blur-data-urls' 9 | import { muxVideoPlugin } from '@oversightstudio/mux-video' 10 | 11 | import { Users } from './collections/Users' 12 | import { Media } from './collections/Media' 13 | import { Movies } from './collections/Movies' 14 | import { SensitiveData } from './collections/SensitiveData' 15 | 16 | const filename = fileURLToPath(import.meta.url) 17 | const dirname = path.dirname(filename) 18 | 19 | export default buildConfig({ 20 | admin: { 21 | user: Users.slug, 22 | importMap: { 23 | baseDir: path.resolve(dirname), 24 | }, 25 | autoLogin: { 26 | email: "dev@email.com", 27 | password: "123", 28 | } 29 | }, 30 | collections: [Users, Media, Movies, SensitiveData], 31 | editor: lexicalEditor(), 32 | secret: process.env.PAYLOAD_SECRET || '', 33 | typescript: { 34 | outputFile: path.resolve(dirname, 'payload-types.ts'), 35 | }, 36 | db: sqliteAdapter({ 37 | client: { 38 | url: process.env.DATABASE_URI || '', 39 | }, 40 | }), 41 | sharp, 42 | plugins: [ 43 | blurDataUrlsPlugin({ 44 | enabled: true, 45 | collections: [Media], 46 | blurOptions: { 47 | blur: 18, 48 | width: 32, 49 | height: 'auto', 50 | }, 51 | }), 52 | muxVideoPlugin({ 53 | enabled: true, 54 | initSettings: { 55 | tokenId: process.env.MUX_TOKEN_ID || '', 56 | tokenSecret: process.env.MUX_TOKEN_SECRET || '', 57 | webhookSecret: process.env.MUX_WEBHOOK_SIGNING_SECRET || '', 58 | jwtSigningKey: process.env.MUX_JWT_KEY_ID || '', 59 | jwtPrivateKey: process.env.MUX_JWT_KEY || '', 60 | }, 61 | uploadSettings: { 62 | cors_origin: 'http://localhost:3000', 63 | new_asset_settings: { 64 | playback_policy: ['signed'], 65 | }, 66 | }, 67 | }), 68 | ], 69 | }) 70 | -------------------------------------------------------------------------------- /dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"], 23 | "@payload-config": ["./src/payload.config.ts"] 24 | }, 25 | "target": "ES2022" 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /gifs/mux-preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oversightstudio/payload-plugins/4b02352fc33f76d9c4cf36fef9879079d2039d59/gifs/mux-preview.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oversightstudio-plugins", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "cd dev && pnpm dev", 8 | "build": "pnpm --filter=./packages/* run build", 9 | "release": "pnpm --filter=./packages/* publish --access public" 10 | }, 11 | "keywords": [], 12 | "author": "oversightstudio", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@changesets/cli": "^2.27.12", 16 | "prettier": "^3.5.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/blur-data-urls/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @oversightstudio/blur-data-urls 2 | 3 | ## 1.0.4 4 | 5 | ### Patch Changes 6 | 7 | - 22ad09c: Remove csm support 8 | 9 | ## 1.0.3 10 | 11 | ### Patch Changes 12 | 13 | - f8956e9: Update description and keywords. 14 | 15 | ## 1.0.2 16 | 17 | ### Patch Changes 18 | 19 | - 54863a2: Update exports 20 | 21 | ## 1.0.1 22 | 23 | ### Patch Changes 24 | 25 | - 7d410a5: First release 26 | -------------------------------------------------------------------------------- /packages/blur-data-urls/README.md: -------------------------------------------------------------------------------- 1 | # Blur Data URLs Payload Plugin 2 | 3 | ## Install 4 | 5 | `pnpm add @oversightstudio/blur-data-urls sharp` 6 | 7 | ## About 8 | This plugin automatically assigns URLs for blur data to media collections in Payload. It will automatically add the ``blurDataUrl`` field to the collections you provide and automatically generate and assign a blurDataUrl to the field whenever a media item is uploaded. 9 | 10 | ## Payload Setup 11 | ```tsx 12 | import { buildConfig } from 'payload' 13 | import { blurDataUrlsPlugin } from '@oversightstudio/blur-data-urls' 14 | import { Media } from './collections/Media' 15 | 16 | export default buildConfig({ 17 | plugins: [ 18 | blurDataUrlsPlugin({ 19 | enabled: true, 20 | collections: [Media], 21 | // Blur data URLs Settings (Optional) 22 | blurOptions: { 23 | blur: 18, 24 | width: 32, 25 | height: "auto", 26 | } 27 | }), 28 | ], 29 | }) 30 | ``` 31 | 32 | ## Options 33 | 34 | | Option | Type | Default | Description | 35 | |------------------|--------------------------------|----------|-------------| 36 | | `enabled` | `boolean` | `true` | Whether the plugin is enabled. | 37 | | `collections` | `PluginCollectionConfig[]` | **Required** | A list of collections where `blurDataUrl` should be implemented. Should typically be the main media collection. | 38 | | `blurOptions` | `object` | *Optional* | Additional settings for generating blurDataUrls. | 39 | 40 | ### `blurOptions` Settings 41 | 42 | | Option | Type | Default | Description | 43 | |---------|-----------------|---------|-------------| 44 | | `width` | `number` | `32` | Width of the blurDataUrl. | 45 | | `height` | `number` \| `'auto'` | `'auto'` | Height of the blurDataUrl. If `'auto'`, it maintains the image’s aspect ratio. | 46 | | `blur` | `number` | `18` | The amount of blur applied to the generated blurDataUrl. | 47 | 48 | ## Usage with next/image 49 | ```tsx 50 | import Image from 'next/image' 51 | 52 | 57 | ``` 58 | 59 | ## Generating BlurData URLs for Existing Images 60 | If you already have images in your media collection, you might want to generate blurDataUrls for them. 61 | 62 | To do so: 63 | 1. Place [this script](/scripts/blurDataUrlsMigrationScript.ts) on your `./src/scripts/ directory`. 64 | 2. Make sure you have `tsx` installed either globally or on your project. You can uninstall it after running the script. 65 | 3. Modify the plugin configuration **on the script**. You'll be able to set the blur options + which collections to migrate. 66 | 4. Run the script: ``tsx ./src/scripts/blurDataUrlsMigrationScript.ts`` 67 | 5. Let it cook. 68 | 69 | ## Credits 70 | * Shoutout to [Paul](https://github.com/paulpopus) for being a real one. 71 | -------------------------------------------------------------------------------- /packages/blur-data-urls/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@oversightstudio/blur-data-urls", 3 | "type": "module", 4 | "version": "1.0.4", 5 | "description": "Automatically assign URLs for blur data to media collections in Payload.", 6 | "private": false, 7 | "bugs": "https://github.com/oversightstudio/payload-plugins/issues", 8 | "repository": "https://github.com/oversightstudio/payload-plugins/tree/main/packages/blur-data-urls", 9 | "license": "MIT", 10 | "types": "./src/index.ts", 11 | "main": "./src/index.ts", 12 | "author": "oversightstudio", 13 | "files": [ 14 | "dist" 15 | ], 16 | "keywords": [ 17 | "payload", 18 | "cms", 19 | "plugin", 20 | "blur", 21 | "data", 22 | "urls" 23 | ], 24 | "scripts": { 25 | "build": "tsup" 26 | }, 27 | "exports": { 28 | ".": { 29 | "import": "./src/index.ts", 30 | "types": "./src/index.ts" 31 | } 32 | }, 33 | "devDependencies": { 34 | "payload": "^3.15.0", 35 | "sharp": "0.32.6", 36 | "tsup": "^8.3.6", 37 | "typescript": "5.7.2" 38 | }, 39 | "peerDependencies": { 40 | "payload": "^3.15.0", 41 | "sharp": "^0.32.6" 42 | }, 43 | "publishConfig": { 44 | "exports": { 45 | ".": { 46 | "import": "./dist/index.js", 47 | "require": "./dist/index.js", 48 | "types": "./dist/index.d.ts" 49 | } 50 | }, 51 | "main": "./dist/index.js", 52 | "registry": "https://registry.npmjs.org/", 53 | "types": "./dist/index.d.ts" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/blur-data-urls/src/extendCollectionConfig.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig, CollectionBeforeChangeHook } from 'payload' 2 | import { createBeforeChangeHook } from './hooks/beforeChange' 3 | import { BlurDataUrlsPluginOptions } from './types' 4 | 5 | const extendCollectionConfig = ( 6 | collection: CollectionConfig, 7 | hook: CollectionBeforeChangeHook, 8 | ): CollectionConfig => { 9 | return { 10 | ...collection, 11 | fields: [ 12 | ...collection.fields, 13 | { 14 | name: 'blurDataUrl', 15 | type: 'text', 16 | admin: { 17 | readOnly: true, 18 | }, 19 | }, 20 | ], 21 | hooks: { 22 | ...collection.hooks, 23 | beforeChange: [...(collection.hooks?.beforeChange ?? []), hook], 24 | }, 25 | } 26 | } 27 | 28 | export const extendCollectionsConfig = ( 29 | incomingCollections: CollectionConfig[], 30 | options: BlurDataUrlsPluginOptions, 31 | ) => { 32 | return incomingCollections.map((collection) => { 33 | const foundInConfig = options.collections.some(({ slug }) => slug === collection.slug) 34 | 35 | if (!foundInConfig) return collection 36 | 37 | return extendCollectionConfig(collection, createBeforeChangeHook(options.blurOptions)) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /packages/blur-data-urls/src/hooks/beforeChange.ts: -------------------------------------------------------------------------------- 1 | import { CollectionBeforeChangeHook } from 'payload' 2 | import { getIncomingFiles } from '../utilities/getIncomingFiles' 3 | import { generateDataUrl } from '../utilities/generateDataUrl' 4 | import { BlurDataUrlsPluginOptions } from '../types' 5 | 6 | export const createBeforeChangeHook = ( 7 | options: BlurDataUrlsPluginOptions['blurOptions'], 8 | ): CollectionBeforeChangeHook => { 9 | return async ({ req, data }) => { 10 | const files = getIncomingFiles({ data, req }) 11 | 12 | for (const file of files) { 13 | if (!file.mimeType.startsWith('image/')) { 14 | continue 15 | } 16 | 17 | data.blurDataUrl = await generateDataUrl(file, options) 18 | } 19 | 20 | return data 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/blur-data-urls/src/index.ts: -------------------------------------------------------------------------------- 1 | export { blurDataUrlsPlugin } from './plugin' 2 | export type { BlurDataUrlsPluginOptions } from './types' 3 | -------------------------------------------------------------------------------- /packages/blur-data-urls/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'payload' 2 | 3 | import { BlurDataUrlsPluginOptions } from './types' 4 | import { extendCollectionsConfig } from './extendCollectionConfig' 5 | 6 | export const blurDataUrlsPlugin = 7 | (pluginOptions: BlurDataUrlsPluginOptions) => 8 | (incomingConfig: Config): Config => { 9 | const config = { ...incomingConfig } 10 | 11 | config.admin = { 12 | ...(config.admin || {}), 13 | components: { 14 | ...(config.admin?.components || {}), 15 | }, 16 | } 17 | 18 | if (pluginOptions.enabled === false) { 19 | return config 20 | } 21 | 22 | if (config.collections) { 23 | config.collections = extendCollectionsConfig(config.collections, pluginOptions) 24 | } 25 | 26 | return config 27 | } 28 | -------------------------------------------------------------------------------- /packages/blur-data-urls/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration options for the Blur Data URLs plugin in Payload CMS v3. 3 | */ 4 | export type PluginCollectionConfig = { 5 | /** 6 | * The slug of the collection where the blurDataUrl should be implemented. 7 | */ 8 | slug: string 9 | } 10 | 11 | export type BlurDataUrlsPluginOptions = { 12 | /** 13 | * Determines whether the Blur Data URLs plugin is enabled. 14 | */ 15 | enabled: boolean 16 | 17 | /** 18 | * A list of collections where the blurDataUrl field should be added. 19 | * This should typically be used for Payload's main media collection. 20 | */ 21 | collections: PluginCollectionConfig[] 22 | 23 | /** 24 | * Additional settings for generating blurDataUrls. 25 | */ 26 | blurOptions?: { 27 | /** 28 | * The width of the generated blurDataUrl. 29 | * 30 | * @default 32 31 | */ 32 | width?: number 33 | 34 | /** 35 | * The height of the generated blurDataUrl. 36 | * If set to `'auto'`, it will maintain the image's aspect ratio 37 | * and adjust accordingly at generation time. 38 | * 39 | * @default 'auto' 40 | */ 41 | height?: number | 'auto' 42 | 43 | /** 44 | * The amount of blur applied to the generated blurDataUrl. 45 | * 46 | * @default 18 47 | */ 48 | blur?: number 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/blur-data-urls/src/utilities/generateDataUrl.ts: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp' 2 | import { File } from './getIncomingFiles' 3 | import { BlurDataUrlsPluginOptions } from '../types' 4 | 5 | export const generateDataUrl = async ( 6 | file: File, 7 | options?: BlurDataUrlsPluginOptions['blurOptions'], 8 | ): Promise => { 9 | const { buffer } = file 10 | 11 | const width = options?.width ?? 32 12 | const height: number | 'auto' = options?.height ?? 'auto' 13 | const blur = options?.blur ?? 18 14 | 15 | try { 16 | const sharpImage = sharp(buffer) 17 | 18 | const metadata = await sharpImage.metadata() 19 | 20 | let resizedHeight: number 21 | 22 | if (height === 'auto' && metadata.width && metadata.height) { 23 | resizedHeight = Math.round((width / metadata.width) * metadata.height) 24 | } else if (typeof height === 'number') { 25 | resizedHeight = height 26 | } else { 27 | resizedHeight = 32 28 | } 29 | 30 | const blurDataBuffer = await sharpImage.resize(width, resizedHeight).blur(blur).toBuffer() 31 | 32 | const blurDataURL = `data:image/png;base64,${blurDataBuffer.toString('base64')}` 33 | 34 | return blurDataURL 35 | } catch (error) { 36 | console.error('Error generating blurDataURL:', error) 37 | throw error 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/blur-data-urls/src/utilities/getIncomingFiles.ts: -------------------------------------------------------------------------------- 1 | import type { FileData, PayloadRequest } from 'payload' 2 | 3 | export interface File { 4 | buffer: Buffer 5 | filename: string 6 | filesize: number 7 | mimeType: string 8 | tempFilePath?: string 9 | } 10 | 11 | export function getIncomingFiles({ 12 | data, 13 | req, 14 | }: { 15 | data: Partial 16 | req: PayloadRequest 17 | }): File[] { 18 | const file = req.file 19 | 20 | let files: File[] = [] 21 | 22 | if (file && data.filename && data.mimeType) { 23 | const mainFile: File = { 24 | buffer: file.data, 25 | filename: data.filename, 26 | filesize: file.size, 27 | mimeType: data.mimeType, 28 | tempFilePath: file.tempFilePath, 29 | } 30 | 31 | files = [mainFile] 32 | 33 | if (data?.sizes) { 34 | Object.entries(data.sizes).forEach(([key, resizedFileData]) => { 35 | if (req.payloadUploadSizes?.[key] && data.mimeType) { 36 | files = files.concat([ 37 | { 38 | buffer: req.payloadUploadSizes[key], 39 | filename: `${resizedFileData.filename}`, 40 | filesize: req.payloadUploadSizes[key].length, 41 | mimeType: data.mimeType, 42 | }, 43 | ]) 44 | } 45 | }) 46 | } 47 | } 48 | 49 | return files 50 | } 51 | -------------------------------------------------------------------------------- /packages/blur-data-urls/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "dist", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "strict": true 9 | }, 10 | "include": ["src/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/blur-data-urls/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | external: ['tsup', 'typescript'], 5 | entry: ['src/index.ts'], 6 | format: ['esm'], 7 | dts: true, 8 | outDir: 'dist', 9 | splitting: false, 10 | clean: true, 11 | esbuildOptions(options) { 12 | options.loader = { 13 | '.ts': 'ts', 14 | } 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /packages/encrypted-fields/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @oversightstudio/encrypted-fields 2 | 3 | ## 1.0.0 4 | 5 | ### Major Changes 6 | 7 | - ca01e1c: Release encrypted fields plugin. 8 | -------------------------------------------------------------------------------- /packages/encrypted-fields/README.md: -------------------------------------------------------------------------------- 1 | # Payload Encrypted Fields 2 | 3 | ## Install 4 | 5 | `pnpm add @oversightstudio/encrypted-fields @payloadcms/ui` 6 | 7 | ## About 8 | This package brings encrypted fields to Payload. It provides an `encryptedField` function that allows you to easily add encrypted fields to your collections. 9 | 10 | It supports most field field types, including ones like `select` or `number` with `hasMany` enabled. Additionally, it fully integrates with Payload’s field customization options and lifecycle events, making it behave just like any other field—just encrypted. 11 | 12 | The package automatically encrypts and decrypts field values using hooks, meaning you don’t need any extra setup to use encrypted fields in the admin panel or through the API. 13 | 14 | ### Encryption 15 | This package uses **AES-256-CTR** encryption, combined with your `PAYLOAD_SECRET` and a random IV. The encrypted value is stored as a string in the database. 16 | 17 | For a deeper dive into how this encryption works, check out [this blog post](https://payloadcms.com/posts/blog/the-power-of-encryption-and-decryption-safeguarding-data-privacy-with-payloads-hooks) by Payload. It explains the same encryption approach but for a simple text field. This package expands on that to support more field types and make integration seamless. 18 | 19 | Since encryption fundamentally converts data into an unreadable format, this package JSON stringifies your data before encrypting it and automatically parses it back after decryption to retain it's original data type. 20 | 21 | ### Tradeofs 22 | Using field encryption comes with some tradeoffs: 23 | - **Larger data size** – Encrypted values take up more space than raw values. 24 | - **No sorting or filtering** – Since the data is encrypted at rest, the database **cannot** sort or filter it. 25 | - **Performance overhead** – Encrypting and decrypting data adds some processing time. 26 | 27 | These tradeoffs are standard when working with encrypted data, but they’re well worth it if protecting sensitive information is your goal. 28 | 29 | ## Usage 30 | 31 | **Important:** Make sure the `PAYLOAD_SECRET` environment variable is set before using this package. 32 | 33 | ```tsx 34 | import { CollectionConfig } from 'payload' 35 | import { encryptedField } from '@oversightstudio/encrypted-fields' 36 | 37 | export const SensitiveDataCollection: CollectionConfig = { 38 | slug: 'sensitive-data', 39 | fields: [ 40 | encryptedField({ 41 | name: 'name', 42 | type: 'text', 43 | }), 44 | encryptedField({ 45 | name: 'age', 46 | type: 'number', 47 | }), 48 | encryptedField({ 49 | name: 'birthDate', 50 | type: 'date', 51 | }), 52 | encryptedField({ 53 | name: 'traits', 54 | type: 'select', 55 | options: [ 56 | { label: 'Awesome', value: 'awesome' }, 57 | { label: 'Based', value: 'based' }, 58 | { label: 'Goat', value: 'goat' }, 59 | ], 60 | hasMany: true, 61 | }), 62 | ], 63 | } 64 | ``` 65 | 66 | ## Supported Field Types 67 | | Field Type | Supported | 68 | |-------------|-----------| 69 | | `text` | ✅ Yes | 70 | | `number` | ✅ Yes | 71 | | `select` | ✅ Yes | 72 | | `checkbox` | ✅ Yes | 73 | | `email` | ✅ Yes | 74 | | `date` | ✅ Yes | 75 | | `json` | ✅ Yes | 76 | | `textarea` | ✅ Yes | 77 | | `code` | ✅ Yes | 78 | | `radio` | ✅ Yes | 79 | | `point` | ❌ Not yet supported | 80 | | `richText` | ❌ Not yet supported | 81 | 82 | All other missing field types (like as `ui`, `tabs`, etc.) are of course **not supported**, as they do not store user data that needs encryption. 83 | 84 | ## Credits 85 | * Shoutout to [Paul](https://github.com/paulpopus) for being a real one. -------------------------------------------------------------------------------- /packages/encrypted-fields/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@oversightstudio/encrypted-fields", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "description": "Database field encryption for Payload.", 6 | "private": false, 7 | "bugs": "https://github.com/oversightstudio/payload-plugins/issues", 8 | "repository": "https://github.com/oversightstudio/payload-plugins/tree/main/packages/encrypted-fields", 9 | "license": "MIT", 10 | "types": "./src/index.ts", 11 | "main": "./src/index.ts", 12 | "author": "oversightstudio", 13 | "files": [ 14 | "dist" 15 | ], 16 | "keywords": [ 17 | "payload", 18 | "cms", 19 | "plugin", 20 | "encrypted", 21 | "fields", 22 | "payloadcms" 23 | ], 24 | "scripts": { 25 | "build": "tsup" 26 | }, 27 | "exports": { 28 | ".": { 29 | "import": "./src/index.ts", 30 | "types": "./src/index.ts" 31 | } 32 | }, 33 | "devDependencies": { 34 | "@payloadcms/ui": "^3.15.0", 35 | "payload": "^3.15.0", 36 | "tsup": "^8.3.6", 37 | "typescript": "5.7.2" 38 | }, 39 | "peerDependencies": { 40 | "@payloadcms/ui": "^3.15.0", 41 | "payload": "^3.15.1" 42 | }, 43 | "publishConfig": { 44 | "exports": { 45 | ".": { 46 | "import": "./dist/index.js", 47 | "require": "./dist/index.js", 48 | "types": "./dist/index.d.ts" 49 | } 50 | }, 51 | "main": "./dist/index.js", 52 | "registry": "https://registry.npmjs.org/", 53 | "types": "./dist/index.d.ts" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/encrypted-fields/src/consts.ts: -------------------------------------------------------------------------------- 1 | export const algorithm = 'aes-256-ctr' 2 | -------------------------------------------------------------------------------- /packages/encrypted-fields/src/fields/encryptedField.ts: -------------------------------------------------------------------------------- 1 | import { Field } from 'payload' 2 | import * as validationFunctions from 'payload/shared' 3 | 4 | import { toPascalCase } from '../utils/toPascalCase' 5 | import { decryptField } from '../hooks/decryptField' 6 | import { encryptField } from '../hooks/encryptField' 7 | 8 | export type EncryptedFieldOptions = Field & { 9 | type: 10 | | 'text' 11 | | 'number' 12 | | 'select' 13 | | 'checkbox' 14 | | 'email' 15 | | 'date' 16 | | 'json' 17 | | 'textarea' 18 | | 'code' 19 | | 'radio' 20 | } 21 | 22 | export const encryptedField = (data: EncryptedFieldOptions): Field => { 23 | let fieldComponentName = toPascalCase(data.type) 24 | 25 | switch (data.type) { 26 | case 'date': 27 | fieldComponentName = 'DateTime' 28 | break 29 | case 'json': 30 | fieldComponentName = 'JSON' 31 | break 32 | case 'radio': 33 | fieldComponentName = 'RadioGroup' 34 | default: 35 | break 36 | } 37 | 38 | return { 39 | ...data, 40 | type: 'text', 41 | validate: (encryptedValue: any, args: any) => { 42 | const value: any = decryptField(encryptedValue) 43 | 44 | return (validationFunctions as any)[data.type](value, args) 45 | }, 46 | hooks: { 47 | ...data.hooks, 48 | beforeChange: [ 49 | ...(data.hooks?.beforeChange ?? []), 50 | ({ value }) => { 51 | if ('hasMany' in data && data.hasMany && Array.isArray(value)) { 52 | return value.map((item: any) => encryptField(item)) 53 | } 54 | 55 | return encryptField(value) 56 | }, 57 | ], 58 | afterRead: [ 59 | ...(data.hooks?.afterRead ?? []), 60 | ({ value }) => { 61 | if (Array.isArray(value)) { 62 | return value.map((item: any) => decryptField(item)) 63 | } 64 | 65 | return decryptField(value) 66 | }, 67 | ], 68 | }, 69 | admin: { 70 | ...data.admin, 71 | components: { 72 | ...((data.admin?.components as any) ?? {}), 73 | Field: { 74 | path: `@payloadcms/ui#${fieldComponentName}Field`, 75 | ...((data.admin?.components?.Field as any) ?? {}), 76 | }, 77 | }, 78 | }, 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/encrypted-fields/src/hooks/decryptField.ts: -------------------------------------------------------------------------------- 1 | import { decrypt } from '../utils/decrypt' 2 | 3 | export const decryptField = (value: any) => { 4 | if (value === undefined || value === null) return undefined 5 | 6 | try { 7 | return JSON.parse(decrypt(value)) 8 | } catch (e) { 9 | return undefined 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/encrypted-fields/src/hooks/encryptField.ts: -------------------------------------------------------------------------------- 1 | import { encrypt } from '../utils/encrypt' 2 | 3 | export const encryptField = (value: any) => { 4 | if (value === undefined || value === null) return undefined 5 | 6 | return encrypt(JSON.stringify(value)) 7 | } 8 | -------------------------------------------------------------------------------- /packages/encrypted-fields/src/index.ts: -------------------------------------------------------------------------------- 1 | export { encryptedField } from './fields/encryptedField' 2 | -------------------------------------------------------------------------------- /packages/encrypted-fields/src/utils/decrypt.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import { createKeyFromSecret } from './encrypt' 3 | import { algorithm } from '../consts' 4 | 5 | export const decrypt = (hash: string): string => { 6 | const iv = hash.slice(0, 32) 7 | const content = hash.slice(32) 8 | 9 | const decipher = crypto.createDecipheriv( 10 | algorithm, 11 | createKeyFromSecret(process.env.PAYLOAD_SECRET!), 12 | Buffer.from(iv, 'hex'), 13 | ) 14 | 15 | const decrypted = Buffer.concat([decipher.update(Buffer.from(content, 'hex')), decipher.final()]) 16 | 17 | return decrypted.toString() 18 | } 19 | -------------------------------------------------------------------------------- /packages/encrypted-fields/src/utils/encrypt.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import { algorithm } from '../consts' 3 | 4 | export const createKeyFromSecret = (secretKey: string): string => 5 | crypto.createHash('sha256').update(secretKey).digest('hex').slice(0, 32) 6 | 7 | export const encrypt = (text: string): string => { 8 | const iv = crypto.randomBytes(16) 9 | const cipher = crypto.createCipheriv( 10 | algorithm, 11 | createKeyFromSecret(process.env.PAYLOAD_SECRET!), 12 | iv, 13 | ) 14 | 15 | const encrypted = Buffer.concat([cipher.update(text), cipher.final()]) 16 | 17 | return `${iv.toString('hex')}${encrypted.toString('hex')}` 18 | } 19 | -------------------------------------------------------------------------------- /packages/encrypted-fields/src/utils/toPascalCase.ts: -------------------------------------------------------------------------------- 1 | export function toPascalCase(name: string): string { 2 | return name.charAt(0).toUpperCase() + name.slice(1) 3 | } 4 | -------------------------------------------------------------------------------- /packages/encrypted-fields/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "moduleResolution": "bundler", 5 | "module": "esnext", 6 | "outDir": "dist", 7 | "declaration": true, 8 | "sourceMap": true, 9 | "strict": false, 10 | "skipLibCheck": true 11 | }, 12 | "include": ["src/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/encrypted-fields/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | external: ['tsup', 'typescript'], 5 | entry: ['src/index.ts'], 6 | format: ['esm'], 7 | dts: true, 8 | outDir: 'dist', 9 | splitting: false, 10 | clean: true, 11 | esbuildOptions(options) { 12 | options.loader = { 13 | '.ts': 'ts', 14 | } 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /packages/mux-video/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /packages/mux-video/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [], 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100, 6 | "semi": false 7 | } 8 | -------------------------------------------------------------------------------- /packages/mux-video/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @oversightstudio/mux-video 2 | 3 | ## 1.1.1 4 | 5 | ### Patch Changes 6 | 7 | - 55e7721: Implement better read-access permissions on the MuxVideo collection 8 | 9 | ## 1.1.0 10 | 11 | ### Minor Changes 12 | 13 | - 5f369a1: ### Added 14 | 15 | - Added `gifUrl` option to playback options. 16 | - Introduced `adminThumbnail` option, which can be `'gif'`, `'image'`, or `'none'` (default: `'gif'`). 17 | 18 | ### Breaking Change 19 | 20 | - Removed `gifPreviews` option in favor of `adminThumbnail`. 21 | 22 | ## 1.0.4 23 | 24 | ### Patch Changes 25 | 26 | - 9ee51ba: Update docs. 27 | 28 | ## 1.0.3 29 | 30 | ### Patch Changes 31 | 32 | - 22ad09c: Remove csm support 33 | 34 | ## 1.0.2 35 | 36 | ### Patch Changes 37 | 38 | - 7f8ee60: Change build settings. 39 | 40 | ## 1.0.1 41 | 42 | ### Patch Changes 43 | 44 | - d64b710: Include use client directive where needed. 45 | 46 | ## 1.0.0 47 | 48 | ### Major Changes 49 | 50 | - f8956e9: Release the Payload Mux Video plugin. 51 | -------------------------------------------------------------------------------- /packages/mux-video/README.md: -------------------------------------------------------------------------------- 1 | # Mux Video Payload Plugin 2 | 3 | ## Install 4 | `pnpm add @oversightstudio/mux-video @mux/mux-player-react` 5 | 6 | ## About 7 | This plugin brings Mux Video to Payload! It creates a “Videos” collection within the admin panel, making it simple to upload videos directly to Mux and manage them. 8 | 9 | Features include: 10 | - Support for both public and signed playback policies. 11 | - Ensures that videos deleted in the admin panel are automatically deleted from Mux, and vice versa. 12 | - Video gif previews on the videos collection list view, which can be disabled if required. 13 | 14 | ![muxVideoPreview](/gifs/mux-preview.gif) 15 | 16 | ## Payload Setup 17 | There are two possible setups for this plugin: The public setup, and the signed URLs setup. The main difference between the two is that the signed URLs setup requires setting up a little extra configuration, but that's about it. 18 | 19 | To get started, you’ll need to generate your MUX tokens and secrets from the MUX Dashboard. When configuring the webhook, set the URL to the automatically generated API endpoint provided by this plugin at `/api/mux/webhook`. 20 | 21 | ### Public Setup 22 | ```tsx 23 | import { buildConfig } from 'payload' 24 | import { muxVideoPlugin } from '@oversightstudio/mux-video' 25 | 26 | export default buildConfig({ 27 | plugins: [ 28 | muxVideoPlugin({ 29 | enabled: true, 30 | initSettings: { 31 | tokenId: process.env.MUX_TOKEN_ID || '', 32 | tokenSecret: process.env.MUX_TOKEN_SECRET || '', 33 | webhookSecret: process.env.MUX_WEBHOOK_SIGNING_SECRET || '', 34 | }, 35 | uploadSettings: { 36 | cors_origin: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000', 37 | }, 38 | }), 39 | ], 40 | }) 41 | ``` 42 | 43 | 44 | 45 | ### Signed URLs Setup 46 | ```tsx 47 | import { buildConfig } from 'payload' 48 | import { muxVideoPlugin } from '@oversightstudio/mux-video' 49 | 50 | export default buildConfig({ 51 | plugins: [ 52 | muxVideoPlugin({ 53 | enabled: true, 54 | initSettings: { 55 | tokenId: process.env.MUX_TOKEN_ID || '', 56 | tokenSecret: process.env.MUX_TOKEN_SECRET || '', 57 | webhookSecret: process.env.MUX_WEBHOOK_SIGNING_SECRET || '', 58 | jwtSigningKey: process.env.MUX_JWT_KEY_ID || '', 59 | jwtPrivateKey: process.env.MUX_JWT_KEY || '', 60 | }, 61 | uploadSettings: { 62 | cors_origin: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000', 63 | new_asset_settings: { 64 | playback_policy: ['signed'], 65 | }, 66 | }, 67 | }), 68 | ], 69 | }) 70 | ``` 71 | 72 | ## Options 73 | 74 | | Option | Type | Default | Description | 75 | |---------------------------|--------------------------------------------------|----------|---------------------------------------------------------------------------------------------------------| 76 | | `enabled` | `boolean` | **Required** | Whether the plugin is enabled. | 77 | | `initSettings` | `MuxVideoInitSettings` | **Required** | Initialization settings for the Mux implementation. | 78 | | `uploadSettings` | `MuxVideoUploadSettings` | **Required** | Upload settings for Mux video assets. | 79 | | `access` | `(request: PayloadRequest) => Promise \| boolean` | *Optional* | An optional function to determine who can upload files. Should return a boolean or a Promise resolving to a boolean. | 80 | | `signedUrlOptions` | `MuxVideoSignedUrlOptions` | *Optional* | Options for signed URL generation. | 81 | | `adminThumbnail` | `'gif' \| 'image' \| 'none'` | `"gif"` | Specifies the type of thumbnail to display for videos in the collection list view. | 82 | 83 | ### `initSettings` Options 84 | 85 | | Option | Type | Default | Description | 86 | |-----------------------|-------------|----------|-----------------------------------------------------------| 87 | | `tokenId` | `string` | **Required** | The Mux token ID. | 88 | | `tokenSecret` | `string` | **Required** | The Mux token secret. | 89 | | `webhookSecret` | `string` | **Required** | The secret used to validate Mux webhooks. | 90 | | `jwtSigningKey` | `string` | *Optional* | Optional JWT signing key, required for signed URL setup. | 91 | | `jwtPrivateKey` | `string` | *Optional* | Optional JWT private key, required for signed URL setup. | 92 | 93 | 94 | ### `uploadSettings` Options 95 | 96 | | Option | Type | Default | Description | 97 | |---------------------------|-----------------------|----------|--------------------------------------------------------| 98 | | `cors_origin` | `string` | **Required** | The required CORS origin for Mux. | 99 | | `new_asset_settings` | `MuxVideoNewAssetSettings` | *Optional* | Additional settings for creating assets in Mux. | 100 | 101 | ### `new_asset_settings` Options 102 | 103 | | Option | Type | Default | Description | 104 | |-------------------------------|-----------------------------|----------|-------------------------------------------------------------| 105 | | `playback_policy` | `Array<'public' | 'signed'>` | `public` | Controls the playback policy for uploaded videos. Default is `public`. | 106 | 107 | ### `signedUrlOptions` Options 108 | 109 | | Option | Type | Default | Description | 110 | |-------------------------------|-------------|----------|-------------------------------------------------------------| 111 | | `expiration` | `string` | `"1d"` | Expiration time for signed URLs. Default is `"1d"`. | 112 | 113 | ## Videos Collection 114 | This is the collection generated by the plugin with the `mux-video` slug. 115 | 116 | | Field | Type | Read-Only | Description | 117 | |-------------------|-----------|-----------|-------------| 118 | | `title` | `text` | No | A unique title for this video that will help you identify it later. | 119 | | `assetId` | `text` | Yes | | 120 | | `duration` | `number` | Yes | | 121 | | `posterTimestamp`| `number` | No | A timestamp (in seconds) from the video to be used as the poster image. When unset, defaults to the middle of the video. | 122 | | `aspectRatio` | `text` | Yes | | 123 | | `maxWidth` | `number` | Yes | | 124 | | `maxHeight` | `number` | Yes | | 125 | | `playbackOptions`| `array` | Yes | | 126 | 127 | ### `playbackOptions` Fields 128 | 129 | | Field | Type | Read-Only | Description | 130 | |---------------|---------|-----------|-------------| 131 | | `playbackId` | `text` | Yes | | 132 | | `playbackPolicy` | `select` | Yes | Options: `signed`, `public` | 133 | | `playbackUrl` | `text (virtual)` | Yes | | 134 | | `posterUrl` | `text (virtual)` | Yes | | 135 | 136 | ## Payload Usage Example 137 | ```tsx 138 | import { CollectionConfig } from 'payload' 139 | 140 | export const ExampleCollection: CollectionConfig = { 141 | slug: 'example', 142 | fields: [ 143 | // To link videos to other collection, use the `relationship` field type 144 | { 145 | name: 'video', 146 | label: 'Preview Video', 147 | type: 'relationship', 148 | relationTo: 'mux-video', 149 | }, 150 | ], 151 | } 152 | ``` 153 | 154 | ## Frontend Usage Example 155 | ```tsx 156 | import config from '@/payload.config' 157 | import { getPayload } from 'payload' 158 | import MuxPlayer from '@mux/mux-player-react' 159 | 160 | async function Page() { 161 | const payload = await getPayload({ config }) 162 | 163 | const video = await payload.findByID({ 164 | collection: 'mux-video', 165 | id: 'example', 166 | }) 167 | 168 | return ( 169 | 177 | ) 178 | } 179 | 180 | export default Page 181 | ``` 182 | 183 | ## Credits 184 | * Huge shoutout to [jamesvclements](https://github.com/jamesvclements) for building the initial version of this plugin! 185 | * Shoutout to [Paul](https://github.com/paulpopus) for being a real one. 186 | -------------------------------------------------------------------------------- /packages/mux-video/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@oversightstudio/mux-video", 3 | "type": "module", 4 | "version": "1.1.1", 5 | "description": "Brings Mux Video to PayloadCMS.", 6 | "private": false, 7 | "bugs": "https://github.com/oversightstudio/payload-plugins/issues", 8 | "repository": "https://github.com/oversightstudio/payload-plugins/tree/main/packages/mux-video", 9 | "license": "MIT", 10 | "types": "./src/index.ts", 11 | "main": "./src/index.ts", 12 | "author": "oversightstudio", 13 | "files": [ 14 | "dist" 15 | ], 16 | "keywords": [ 17 | "payload", 18 | "cms", 19 | "plugin", 20 | "mux", 21 | "video", 22 | "upload" 23 | ], 24 | "scripts": { 25 | "build": "tsup" 26 | }, 27 | "exports": { 28 | ".": { 29 | "import": "./src/index.ts", 30 | "default": "./src/index.ts" 31 | }, 32 | "./elements": { 33 | "import": "./src/fields/index.ts", 34 | "default": "./src/fields/index.ts" 35 | } 36 | }, 37 | "devDependencies": { 38 | "@mux/mux-player-react": "^3.2.4", 39 | "@payloadcms/ui": "^3.15.0", 40 | "@types/react": "^19.0.1", 41 | "esbuild-plugin-preserve-directives": "^0.0.11", 42 | "payload": "^3.15.1", 43 | "react": "^19.0.0", 44 | "react-dom": "^19.0.0", 45 | "tsup": "^8.3.6", 46 | "typescript": "^5.7.2" 47 | }, 48 | "peerDependencies": { 49 | "@mux/mux-player-react": "^3.2.4", 50 | "@payloadcms/ui": "^3.15.0", 51 | "payload": "^3.15.1" 52 | }, 53 | "dependencies": { 54 | "@mux/mux-node": "^9.0.1", 55 | "@mux/mux-uploader-react": "^1.1.1" 56 | }, 57 | "publishConfig": { 58 | "exports": { 59 | ".": { 60 | "import": "./dist/index.js", 61 | "require": "./dist/index.js", 62 | "types": "./dist/index.d.ts" 63 | }, 64 | "./elements": { 65 | "import": "./dist/fields/index.js", 66 | "require": "./dist/fields/index.js", 67 | "types": "./dist/fields/index.d.ts" 68 | } 69 | }, 70 | "main": "./dist/index.js", 71 | "registry": "https://registry.npmjs.org/", 72 | "types": "./dist/index.d.ts" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/mux-video/src/collections/MuxVideo.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload' 2 | import getAfterDeleteMuxVideoHook from '../hooks/afterDelete' 3 | import getBeforeChangeMuxVideoHook from '../hooks/beforeChange' 4 | import Mux from '@mux/mux-node' 5 | import { MuxVideoPluginOptions } from '../types' 6 | import { defaultAccessFunction } from '../lib/defaultAccessFunction' 7 | 8 | export const MuxVideo = (mux: Mux, pluginOptions: MuxVideoPluginOptions): CollectionConfig => ({ 9 | slug: 'mux-video', 10 | labels: { 11 | singular: 'Video', 12 | plural: 'Videos', 13 | }, 14 | access: { 15 | read: ({ req }) => pluginOptions.access?.(req) ?? defaultAccessFunction(req), 16 | }, 17 | admin: { 18 | useAsTitle: 'title', 19 | defaultColumns: ['title', 'muxUploader', 'duration'], 20 | }, 21 | hooks: { 22 | afterDelete: [getAfterDeleteMuxVideoHook(mux)], 23 | beforeChange: [getBeforeChangeMuxVideoHook(mux)], 24 | }, 25 | fields: [ 26 | { 27 | name: 'muxUploader', 28 | label: 'Video Preview', 29 | type: 'ui', 30 | admin: { 31 | components: { 32 | Field: '@oversightstudio/mux-video/elements#MuxUploaderField', 33 | Cell: 34 | pluginOptions.adminThumbnail === 'gif' 35 | ? '@oversightstudio/mux-video/elements#MuxVideoGifCell' 36 | : pluginOptions.adminThumbnail === 'image' 37 | ? '@oversightstudio/mux-video/elements#MuxVideoImageCell' 38 | : undefined, 39 | }, 40 | }, 41 | }, 42 | { 43 | name: 'title', 44 | type: 'text', 45 | label: 'Title', 46 | required: true, 47 | unique: true, 48 | admin: { 49 | description: `A unique title for this video that will help you identify it later.`, 50 | }, 51 | }, 52 | { 53 | name: 'assetId', 54 | type: 'text', 55 | required: true, 56 | admin: { 57 | readOnly: true, 58 | condition: (data) => data.assetId, 59 | }, 60 | }, 61 | { 62 | name: 'duration', 63 | label: 'Duration', 64 | type: 'number', 65 | admin: { 66 | readOnly: true, 67 | condition: (data) => data.duration, 68 | }, 69 | }, 70 | { 71 | name: 'posterTimestamp', 72 | type: 'number', 73 | label: 'Poster Timestamp', 74 | min: 0, 75 | admin: { 76 | description: 77 | 'Pick a timestamp (in seconds) from the video to be used as the poster image. When unset, defaults to the middle of the video.', 78 | // Only show it when playbackId has been set, so users can pick the poster image using the player 79 | condition: (data) => data.duration, 80 | step: 0.25, 81 | }, 82 | validate: (value: any, { siblingData }: any) => { 83 | if (!siblingData.duration || !value) { 84 | return true 85 | } 86 | 87 | return ( 88 | Boolean(siblingData.duration >= value) || 89 | 'Poster timestamp must be less than the video duration.' 90 | ) 91 | }, 92 | }, 93 | { 94 | name: 'aspectRatio', 95 | label: 'Aspect Ratio', 96 | type: 'text', 97 | admin: { 98 | readOnly: true, 99 | condition: (data) => data.aspectRatio, 100 | }, 101 | }, 102 | { 103 | name: 'maxWidth', 104 | type: 'number', 105 | admin: { 106 | readOnly: true, 107 | condition: (data) => data.maxWidth, 108 | }, 109 | }, 110 | { 111 | name: 'maxHeight', 112 | type: 'number', 113 | admin: { 114 | readOnly: true, 115 | condition: (data) => data.maxHeight, 116 | }, 117 | }, 118 | { 119 | name: 'playbackOptions', 120 | type: 'array', 121 | admin: { 122 | readOnly: true, 123 | condition: (data) => !!data.playbackOptions, 124 | }, 125 | fields: [ 126 | { 127 | name: 'playbackId', 128 | label: 'Playback ID', 129 | type: 'text', 130 | admin: { 131 | readOnly: true, 132 | }, 133 | }, 134 | { 135 | name: 'playbackPolicy', 136 | label: 'Playback Policy', 137 | type: 'select', 138 | options: [ 139 | { 140 | label: 'signed', 141 | value: 'signed', 142 | }, 143 | { 144 | label: 'public', 145 | value: 'public', 146 | }, 147 | ], 148 | admin: { 149 | readOnly: true, 150 | }, 151 | }, 152 | { 153 | name: 'playbackUrl', 154 | label: 'Playback URL', 155 | type: 'text', 156 | virtual: true, 157 | admin: { 158 | hidden: true, 159 | }, 160 | hooks: { 161 | afterRead: [ 162 | async ({ siblingData }) => { 163 | const playbackId = siblingData?.['playbackId'] 164 | 165 | if (!playbackId) { 166 | return null 167 | } 168 | 169 | const url = new URL(`https://stream.mux.com/${playbackId}.m3u8`) 170 | 171 | if (siblingData.playbackPolicy === 'signed') { 172 | const token = await mux.jwt.signPlaybackId(playbackId, { 173 | expiration: pluginOptions.signedUrlOptions?.expiration ?? '1d', 174 | type: 'video', 175 | }) 176 | 177 | url.searchParams.set('token', token) 178 | } 179 | 180 | return url.toString() 181 | }, 182 | ], 183 | }, 184 | }, 185 | { 186 | name: 'posterUrl', 187 | label: 'Poster URL', 188 | type: 'text', 189 | virtual: true, 190 | admin: { 191 | hidden: true, 192 | }, 193 | hooks: { 194 | afterRead: [ 195 | async ({ data, siblingData }) => { 196 | const playbackId = siblingData?.['playbackId'] 197 | const posterTimestamp = data?.['posterTimestamp'] 198 | 199 | if (!playbackId) { 200 | return null 201 | } 202 | 203 | const url = new URL(`https://image.mux.com/${playbackId}/thumbnail.png`) 204 | 205 | if (typeof posterTimestamp === 'number') { 206 | url.searchParams.set('time', posterTimestamp.toString()) 207 | } 208 | 209 | if (siblingData.playbackPolicy === 'signed') { 210 | const token = await mux.jwt.signPlaybackId(playbackId, { 211 | expiration: pluginOptions.signedUrlOptions?.expiration ?? '1d', 212 | type: 'thumbnail', 213 | }) 214 | 215 | url.searchParams.set('token', token) 216 | } 217 | 218 | return url.toString() 219 | }, 220 | ], 221 | }, 222 | }, 223 | { 224 | name: 'gifUrl', 225 | label: 'Gif URL', 226 | type: 'text', 227 | virtual: true, 228 | admin: { 229 | hidden: true, 230 | }, 231 | hooks: { 232 | afterRead: [ 233 | async ({ data, siblingData }) => { 234 | const playbackId = siblingData?.['playbackId'] 235 | const posterTimestamp = data?.['posterTimestamp'] 236 | 237 | if (!playbackId) { 238 | return null 239 | } 240 | 241 | const url = new URL(`https://image.mux.com/${playbackId}/animated.gif`) 242 | 243 | if (typeof posterTimestamp === 'number') { 244 | url.searchParams.set('time', posterTimestamp.toString()) 245 | } 246 | 247 | if (siblingData.playbackPolicy === 'signed') { 248 | const token = await mux.jwt.signPlaybackId(playbackId, { 249 | expiration: pluginOptions.signedUrlOptions?.expiration ?? '1d', 250 | type: 'gif', 251 | }) 252 | 253 | url.searchParams.set('token', token) 254 | } 255 | 256 | return url.toString() 257 | }, 258 | ], 259 | }, 260 | }, 261 | ], 262 | }, 263 | ], 264 | }) 265 | -------------------------------------------------------------------------------- /packages/mux-video/src/endpoints/upload.ts: -------------------------------------------------------------------------------- 1 | import Mux from '@mux/mux-node' 2 | import { PayloadHandler, PayloadRequest } from 'payload' 3 | import { MuxVideoPluginOptions } from '../types' 4 | import { defaultAccessFunction } from '../lib/defaultAccessFunction' 5 | 6 | export const createMuxUploadHandler = ( 7 | mux: Mux, 8 | pluginOptions: MuxVideoPluginOptions, 9 | ): PayloadHandler => { 10 | return async (request) => { 11 | const userHasAccess = (await pluginOptions.access?.(request)) ?? defaultAccessFunction(request) 12 | 13 | if (!userHasAccess) { 14 | return Response.error() 15 | } 16 | 17 | const uploadSettings = pluginOptions.uploadSettings 18 | 19 | const upload = await mux.video.uploads.create({ 20 | cors_origin: uploadSettings.cors_origin, 21 | new_asset_settings: { 22 | playback_policy: ['public'], 23 | ...uploadSettings.new_asset_settings, 24 | }, 25 | }) 26 | 27 | return Response.json(upload) 28 | } 29 | } 30 | 31 | export const getMuxUploadHandler = ( 32 | mux: Mux, 33 | pluginOptions: MuxVideoPluginOptions, 34 | ): PayloadHandler => { 35 | return async (request) => { 36 | const userHasAccess = pluginOptions.access?.(request) ?? defaultAccessFunction(request) 37 | 38 | if (!userHasAccess) { 39 | return Response.error() 40 | } 41 | 42 | try { 43 | const id = request.query.id as string 44 | 45 | const upload = await mux.video.uploads.retrieve(id) 46 | 47 | return Response.json(upload) 48 | } catch (err) { 49 | return Response.json(err) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/mux-video/src/endpoints/webhook.ts: -------------------------------------------------------------------------------- 1 | import { PayloadHandler } from 'payload' 2 | import { getAssetMetadata } from '../lib/getAssetMetadata' 3 | import Mux from '@mux/mux-node' 4 | 5 | export const muxWebhooksHandler = 6 | (mux: Mux): PayloadHandler => 7 | async (req) => { 8 | if (!req.json) { 9 | return Response.error() 10 | } 11 | 12 | const body = await req.json() 13 | 14 | if (!body) { 15 | return Response.error() 16 | } 17 | 18 | mux.webhooks.verifySignature(JSON.stringify(body), req.headers) 19 | 20 | const event = body 21 | 22 | if (event.type === 'video.asset.ready' || event.type === 'video.asset.deleted') { 23 | try { 24 | const assetId = event.object.id 25 | const videos = await req.payload.find({ 26 | collection: 'mux-video', 27 | where: { 28 | assetId: { 29 | equals: assetId, 30 | }, 31 | }, 32 | limit: 1, 33 | pagination: false, 34 | }) 35 | 36 | if (videos.totalDocs === 0) { 37 | return new Response('Success!', { 38 | status: 200, 39 | }) 40 | } 41 | 42 | const video = videos.docs[0] 43 | 44 | if (event.type === 'video.asset.ready') { 45 | /* Update the video with the playbackId, aspectRatio, and duration */ 46 | const update = await req.payload.update({ 47 | collection: 'mux-video', 48 | id: video.id, 49 | data: { 50 | ...getAssetMetadata(event.data), 51 | }, 52 | }) 53 | } else if (event.type === 'video.asset.deleted') { 54 | await req.payload.delete({ 55 | collection: 'mux-video', 56 | id: video.id, 57 | }) 58 | } 59 | } catch (err: any) { 60 | return new Response('Error', { 61 | status: 204, 62 | }) 63 | // return res.status(err.status).json(err) 64 | } 65 | } else if (event.type === 'video.asset.errored') { 66 | if (event.data?.errors) { 67 | console.error(`Error with assetId: ${event.object.id}, logging error and returning 204...`) 68 | console.error(JSON.stringify(event.data.errors, null, 2)) 69 | } 70 | } else { 71 | } 72 | 73 | return new Response('Success!', { 74 | status: 200, 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /packages/mux-video/src/fields/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mux-uploader' 2 | -------------------------------------------------------------------------------- /packages/mux-video/src/fields/mux-uploader/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mux-uploader' 2 | export * from './mux-video-image-cell' 3 | export * from './mux-video-gif-cell' 4 | -------------------------------------------------------------------------------- /packages/mux-video/src/fields/mux-uploader/mux-uploader.scss: -------------------------------------------------------------------------------- 1 | .mux-uploader { 2 | margin-bottom: var(--base); 3 | } 4 | 5 | .mux-uploader__processing::after { 6 | overflow: hidden; 7 | display: inline-block; 8 | vertical-align: bottom; 9 | animation: ellipsis steps(5, end) 1.5s infinite; 10 | content: '\2026'; 11 | /* ascii code for the ellipsis character */ 12 | width: 0px; 13 | } 14 | 15 | @keyframes ellipsis { 16 | to { 17 | width: 2ch; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/mux-video/src/fields/mux-uploader/mux-uploader.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useCallback, useEffect, useMemo, useState } from 'react' 4 | import MuxPlayer from '@mux/mux-player-react' 5 | import MuxUploader from '@mux/mux-uploader-react' 6 | import { useForm, useFormFields } from '@payloadcms/ui' 7 | import path from 'path' 8 | import './mux-uploader.scss' 9 | 10 | export const MuxUploaderField = () => { 11 | const [uploadId, setUploadId] = useState('') 12 | const { assetId, setAssetId, title, setTitle, setFile, playbackUrl } = useFormFields( 13 | ([fields, dispatch]) => ({ 14 | assetId: fields.assetId, 15 | setAssetId: (assetId: string) => 16 | dispatch({ type: 'UPDATE', path: 'assetId', value: assetId }), 17 | title: fields.title, 18 | setTitle: (title: string) => dispatch({ type: 'UPDATE', path: 'title', value: title }), 19 | // file: fields.file, 20 | setFile: (file: File) => dispatch({ type: 'UPDATE', path: 'file', value: file }), 21 | playbackUrl: fields['playbackOptions.0.playbackUrl']?.value, 22 | }), 23 | ) 24 | 25 | const { submit, setProcessing } = useForm() 26 | 27 | const getSignedUrl = useCallback(async () => { 28 | console.log('get signed url') 29 | 30 | // Fetch the signed URL from the API 31 | const response = await fetch(`/api/mux/upload`, { 32 | method: 'POST', 33 | }) 34 | 35 | // Parse the JSON response 36 | const { id, url } = (await response.json()) as { id: string; url: string } 37 | 38 | // Update the upload ID 39 | setUploadId(id) 40 | 41 | // Return the URL 42 | return url 43 | }, []) 44 | 45 | const onUploadStart = (args: any) => { 46 | console.log('onUploadStart') 47 | console.log(args) 48 | 49 | const { 50 | detail: { file }, 51 | } = args 52 | 53 | // Remove the file extension from the name if it comes from the file input 54 | const resolvedTitle = (title.value as string) || path.parse(file.name).name 55 | 56 | if (!title.value) { 57 | setTitle(resolvedTitle) 58 | } 59 | 60 | setFile( 61 | new File([], resolvedTitle, { 62 | type: file.type, 63 | lastModified: file.lastModified, 64 | }), 65 | ) 66 | } 67 | 68 | const onSuccess = async (args: any) => { 69 | /* Show "Creating..." overlay */ 70 | setProcessing(true) 71 | 72 | /* Args don't contain asset ID, so we need to fetch it from the server */ 73 | console.log(`Args in onSuccess`) 74 | console.log(args) 75 | 76 | /* When the upload succeeded, get the Asset ID from the server */ 77 | let upload = await ( 78 | await fetch(`/api/mux/upload?id=${uploadId}`, { 79 | method: 'get', 80 | }) 81 | ).json() 82 | 83 | console.log(`First attempt fetching upload in onSuccess`) 84 | console.log(upload) 85 | 86 | /* Sometimes the upload doesn't have the asset_id yet, poll every second until it does (this should only take a moment) */ 87 | while (!upload.asset_id) { 88 | console.log(`Polling for asset_id...`) 89 | await new Promise((resolve) => setTimeout(resolve, 1000)) 90 | upload = await ( 91 | await fetch(`/api/mux/upload?id=${uploadId}`, { 92 | method: 'get', 93 | }) 94 | ).json() 95 | console.log(upload) 96 | } 97 | 98 | const { asset_id } = upload 99 | 100 | /* Dispatch the ID field */ 101 | setAssetId(asset_id) 102 | 103 | /* Submit the form, use timeout to ensure setAssetId has been handled */ 104 | setTimeout(async () => { 105 | await submit({ 106 | overrides: { 107 | assetId: asset_id, 108 | }, 109 | }) 110 | }, 0) 111 | } 112 | 113 | /* When this is rendered, ensure the default .file-field is hidden */ 114 | /* We can't do this in CSS otherwise it hides .file-field(s) in other collections */ 115 | useEffect(() => { 116 | if (window) { 117 | const fileField = document.querySelector('.file-field') as HTMLElement 118 | if (fileField) { 119 | fileField.style.display = 'none' 120 | } 121 | } 122 | }, []) 123 | 124 | // There are three states: before upload, when we show the uploader. When the asset exists, we show the player. And when the asset is preparing, we show a message. 125 | return ( 126 |
127 | {!assetId?.value && ( 128 | 133 | )} 134 | 135 | {(assetId as any)?.value && !playbackUrl && ( 136 |
137 | Video is being encoded. This typically takes less than 90 seconds, please refresh the page 138 | in a moment 139 |
140 | )} 141 | {playbackUrl && ( 142 | 143 | )} 144 |
145 | ) 146 | } 147 | 148 | export default MuxUploaderField 149 | -------------------------------------------------------------------------------- /packages/mux-video/src/fields/mux-uploader/mux-video-gif-cell.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultCellComponentProps } from 'payload' 2 | 3 | export function MuxVideoGifCell(props: DefaultCellComponentProps) { 4 | const row = props.rowData 5 | 6 | const playbackOption = row?.playbackOptions?.[0] 7 | 8 | if (!playbackOption) { 9 | return <>Preview not available. 10 | } 11 | 12 | return ( 13 | {row?.title} 19 | ) 20 | } 21 | 22 | export default MuxVideoGifCell 23 | -------------------------------------------------------------------------------- /packages/mux-video/src/fields/mux-uploader/mux-video-image-cell.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultCellComponentProps } from 'payload' 2 | 3 | export function MuxVideoImageCell(props: DefaultCellComponentProps) { 4 | const row = props.rowData 5 | 6 | const playbackOption = row?.playbackOptions?.[0] 7 | 8 | if (!playbackOption) { 9 | return <>Preview not available. 10 | } 11 | 12 | return ( 13 | {row?.title} 19 | ) 20 | } 21 | 22 | export default MuxVideoImageCell 23 | -------------------------------------------------------------------------------- /packages/mux-video/src/hooks/afterDelete.ts: -------------------------------------------------------------------------------- 1 | import { CollectionAfterDeleteHook } from 'payload' 2 | import Mux from '@mux/mux-node' 3 | 4 | const getAfterDeleteMuxVideoHook = (mux: Mux): CollectionAfterDeleteHook => { 5 | return async ({ id, doc }: any) => { 6 | const { assetId } = doc 7 | try { 8 | // Check if the asset still exists in Mux. If it was deleted there first, we don't need to do anything 9 | console.log(`[payload-mux] Checking if asset ${assetId} exists in Mux...`) 10 | const video = await mux.video.assets.retrieve(assetId) 11 | 12 | if (video) { 13 | console.log(`[payload-mux] Asset ${id} exists in Mux, deleting...`) 14 | const response = await mux.video.assets.delete(assetId) 15 | } 16 | } catch (err: any) { 17 | if (err.type === 'not_found') { 18 | console.log(`[payload-mux] Asset ${id} not found in Mux, continuing...`) 19 | } else { 20 | console.error(`[payload-mux] Error deleting asset ${id} from Mux...`) 21 | console.error(err) 22 | throw err 23 | } 24 | } 25 | } 26 | } 27 | 28 | export default getAfterDeleteMuxVideoHook 29 | -------------------------------------------------------------------------------- /packages/mux-video/src/hooks/beforeChange.ts: -------------------------------------------------------------------------------- 1 | import { CollectionBeforeChangeHook } from 'payload' 2 | import delay from '../lib/delay' 3 | import { getAssetMetadata } from '../lib/getAssetMetadata' 4 | import Mux from '@mux/mux-node' 5 | 6 | const getBeforeChangeMuxVideoHook = (mux: Mux): CollectionBeforeChangeHook => { 7 | return async ({ req, data: incomingData, operation, originalDoc }) => { 8 | let data = { ...incomingData } 9 | console.log(`beforeChangeHook: ${operation}`) 10 | console.log('data') 11 | try { 12 | if (!originalDoc?.assetId || originalDoc.assetId !== data.assetId) { 13 | console.log( 14 | `[payload-mux] Asset ID created for the first time or changed. Creating or updating...`, 15 | ) 16 | 17 | /* If this is an update, delete the old video first */ 18 | if (operation === 'update' && originalDoc.assetId !== data.assetId) { 19 | console.log(`[payload-mux] Deleting original asset: ${originalDoc.assetId}...`) 20 | const response = await mux.video.assets.delete(originalDoc.assetId) 21 | console.log(response) 22 | } 23 | 24 | /* Now, get the asset and append its' information to the doc */ 25 | let asset = await mux.video.assets.retrieve(data.assetId) 26 | /* Poll for up to 6 seconds, then the webhook will handle setting the metadata */ 27 | let delayDuration = 1500 28 | const pollingLimit = 6 29 | const timeout = Date.now() + pollingLimit * 1000 30 | while (asset.status === 'preparing') { 31 | if (Date.now() > timeout) { 32 | console.log( 33 | `[payload-mux] Asset is still preparing after ${pollingLimit} seconds, giving up and letting the webhook handle it...`, 34 | ) 35 | break 36 | } 37 | console.log(`[payload-mux] Asset is preparing, trying again in ${delayDuration}ms`) 38 | await delay(delayDuration) 39 | asset = await mux.video.assets.retrieve(data.assetId) 40 | } 41 | 42 | if (asset.status === 'errored') { 43 | /* If the asset errored, delete it and throw an error */ 44 | console.log('Error while preparing asset. Deleting it and throwing error...') 45 | const response = await mux.video.assets.delete(data.assetId) 46 | console.log(response) 47 | throw new Error( 48 | `Unable to prepare asset: ${asset.status}. It's been deleted, please try again.`, 49 | ) 50 | } 51 | 52 | /* If the asset is ready, we can get the metadata now */ 53 | if (asset.status === 'ready') { 54 | console.log(`[payload-mux] Asset is ready, getting metadata...`) 55 | data = { 56 | ...data, 57 | ...getAssetMetadata(asset), 58 | } 59 | } else { 60 | console.log(`[payload-mux] Asset is not ready, letting the webhook handle it...`) 61 | } 62 | 63 | /* Override some of the built-in file data */ 64 | data.url = '' 65 | 66 | /* Ensure the title is unique, since we're setting the filename equal to the title and the filename must be unique */ 67 | const existingVideo = await req.payload.find({ 68 | collection: 'mux-video', 69 | where: { 70 | title: { 71 | contains: data.title, 72 | }, 73 | }, 74 | }) 75 | 76 | const uniqueTitle = `${data.title}${existingVideo.totalDocs > 0 ? ` (${existingVideo.totalDocs})` : ''}` 77 | data.title = uniqueTitle 78 | data.filename = uniqueTitle 79 | } 80 | } catch (err: unknown) { 81 | req.payload.logger.error( 82 | `[payload-mux] There was an error while uploading files corresponding to the collection with filename ${data.filename}:`, 83 | ) 84 | req.payload.logger.error(err) 85 | throw err 86 | } 87 | return { 88 | ...data, 89 | } 90 | } 91 | } 92 | 93 | export default getBeforeChangeMuxVideoHook 94 | -------------------------------------------------------------------------------- /packages/mux-video/src/index.ts: -------------------------------------------------------------------------------- 1 | export { muxVideoPlugin } from './plugin' 2 | export type { MuxVideoPluginOptions } from './types' 3 | -------------------------------------------------------------------------------- /packages/mux-video/src/lib/defaultAccessFunction.ts: -------------------------------------------------------------------------------- 1 | import { PayloadRequest } from 'payload' 2 | 3 | export const defaultAccessFunction = (request: PayloadRequest) => { 4 | if (!request.user || request.user?.collection !== request.payload.config.admin.user) { 5 | return false 6 | } 7 | 8 | return true 9 | } 10 | -------------------------------------------------------------------------------- /packages/mux-video/src/lib/delay.ts: -------------------------------------------------------------------------------- 1 | export default async function delay(ms: number): Promise { 2 | return new Promise((resolve) => setTimeout(resolve, ms)) 3 | } 4 | -------------------------------------------------------------------------------- /packages/mux-video/src/lib/getAssetMetadata.ts: -------------------------------------------------------------------------------- 1 | import { Asset } from '@mux/mux-node/resources/video/assets.mjs' 2 | 3 | export const getAssetMetadata = (asset: Asset) => { 4 | const videoTrack = asset.tracks?.find((track: any) => track.type === 'video')! 5 | 6 | return { 7 | playbackOptions: asset.playback_ids?.map((value) => ({ 8 | playbackId: value.id, 9 | playbackPolicy: value.policy, 10 | })), 11 | /* Reformat Mux's aspect ratio (e.g. 16:9) to be CSS-friendly (e.g. 16/9) */ 12 | aspectRatio: asset.aspect_ratio!.replace(':', '/'), 13 | duration: asset.duration, 14 | ...(videoTrack 15 | ? { 16 | maxWidth: videoTrack.max_width, 17 | maxHeight: videoTrack.max_height, 18 | } 19 | : {}), 20 | } as any 21 | } 22 | -------------------------------------------------------------------------------- /packages/mux-video/src/lib/onInitExtension.ts: -------------------------------------------------------------------------------- 1 | import type { Payload } from 'payload' 2 | import type { MuxVideoPluginOptions } from '../types' 3 | 4 | export const onInitExtension = (pluginOptions: MuxVideoPluginOptions, payload: Payload): void => { 5 | try { 6 | } catch (err: unknown) { 7 | payload.logger.error({ err, msg: 'Error in onInitExtension' }) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/mux-video/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'payload' 2 | import { MuxVideo } from './collections/MuxVideo' 3 | import { createMuxUploadHandler, getMuxUploadHandler } from './endpoints/upload' 4 | import { muxWebhooksHandler } from './endpoints/webhook' 5 | import { onInitExtension } from './lib/onInitExtension' 6 | import type { MuxVideoPluginOptions } from './types' 7 | import Mux from '@mux/mux-node' 8 | 9 | export const muxVideoPlugin = 10 | (pluginOptions: MuxVideoPluginOptions) => 11 | (incomingConfig: Config): Config => { 12 | const config = { ...incomingConfig } 13 | 14 | config.admin = { 15 | ...(config.admin || {}), 16 | components: { 17 | ...(config.admin?.components || {}), 18 | }, 19 | } 20 | 21 | if (pluginOptions.enabled === false) { 22 | return config 23 | } 24 | 25 | if (!pluginOptions.adminThumbnail) { 26 | pluginOptions.adminThumbnail = 'gif' 27 | } 28 | 29 | const mux = new Mux(pluginOptions.initSettings) 30 | 31 | config.collections = [...(config.collections || []), MuxVideo(mux, pluginOptions)] 32 | 33 | config.endpoints = [ 34 | ...(config.endpoints || []), 35 | { 36 | method: 'post', 37 | path: '/mux/upload', 38 | handler: createMuxUploadHandler(mux, pluginOptions), 39 | }, 40 | { 41 | method: 'get', 42 | path: '/mux/upload', 43 | handler: getMuxUploadHandler(mux, pluginOptions), 44 | }, 45 | { 46 | path: '/mux/webhook', 47 | method: 'post', 48 | handler: muxWebhooksHandler(mux), 49 | }, 50 | ] 51 | 52 | config.globals = [...(config.globals || [])] 53 | 54 | config.hooks = { 55 | ...(config.hooks || {}), 56 | } 57 | 58 | config.onInit = async (payload) => { 59 | if (incomingConfig.onInit) { 60 | await incomingConfig.onInit(payload) 61 | } 62 | 63 | onInitExtension(pluginOptions, payload) 64 | } 65 | 66 | return config 67 | } 68 | -------------------------------------------------------------------------------- /packages/mux-video/src/types.ts: -------------------------------------------------------------------------------- 1 | import { AssetOptions } from '@mux/mux-node/resources/video/assets.mjs' 2 | import { PayloadRequest } from 'payload' 3 | 4 | /** 5 | * Initialization settings for the Mux implementation. 6 | */ 7 | type MuxVideoInitSettings = { 8 | /** 9 | * The Mux token ID. 10 | */ 11 | tokenId: string 12 | 13 | /** 14 | * The Mux token secret. 15 | */ 16 | tokenSecret: string 17 | 18 | /** 19 | * The secret used to validate Mux webhooks. 20 | */ 21 | webhookSecret: string 22 | 23 | /** 24 | * Optional JWT signing key. 25 | * Only required for signed URL setup. 26 | */ 27 | jwtSigningKey?: string 28 | 29 | /** 30 | * Optional JWT private key. 31 | * Only required for signed URL setup. 32 | */ 33 | jwtPrivateKey?: string 34 | } 35 | 36 | /** 37 | * Settings for creating a new asset on Mux. 38 | * This extends the base AssetOptions with additional properties. 39 | */ 40 | type MuxVideoNewAssetSettings = AssetOptions & { 41 | /** 42 | * Playback policy for the uploaded video. 43 | * Accepted values are `'public'` or `'signed'`. 44 | * Although this accepts an array, the recommended default is to use a single value of `'public'`. 45 | */ 46 | playback_policy?: Array<'public' | 'signed'> 47 | } 48 | 49 | /** 50 | * Settings for uploading videos to Mux. 51 | */ 52 | type MuxVideoUploadSettings = { 53 | /** 54 | * The required CORS origin for Mux. 55 | */ 56 | cors_origin: string 57 | 58 | /** 59 | * Additional settings passed to Mux when creating a new asset. 60 | */ 61 | new_asset_settings?: MuxVideoNewAssetSettings 62 | } 63 | 64 | /** 65 | * Options for generating signed URLs for video playback. 66 | */ 67 | type MuxVideoSignedUrlOptions = { 68 | /** 69 | * The expiration time for signed URLs. 70 | * 71 | * @default "1d" 72 | */ 73 | expiration?: string 74 | } 75 | 76 | /** 77 | * Configuration options for the Mux Video Plugin. 78 | */ 79 | export type MuxVideoPluginOptions = { 80 | /** 81 | * Determines whether the plugin is enabled. 82 | */ 83 | enabled: boolean 84 | 85 | /** 86 | * Specifies the type of thumbnail to display for videos in the collection list view. 87 | * - `"gif"`: Displays an animated GIF preview. 88 | * - `"image"`: Displays a static image preview. 89 | * - `"none"`: No thumbnail is displayed. 90 | * 91 | * @default "gif" 92 | */ 93 | adminThumbnail?: 'gif' | 'image' | 'none' 94 | 95 | /** 96 | * Initialization settings for the Mux implementation. 97 | */ 98 | initSettings: MuxVideoInitSettings 99 | 100 | /** 101 | * Upload settings for creating video assets on Mux. 102 | */ 103 | uploadSettings: MuxVideoUploadSettings 104 | 105 | /** 106 | * An optional function to determine whether the current request is allowed to upload files. 107 | * Should return a boolean or a Promise resolving to a boolean. 108 | */ 109 | access?: (request: PayloadRequest) => Promise | boolean 110 | 111 | /** 112 | * Options for generating signed URLs for video playback. 113 | */ 114 | signedUrlOptions?: MuxVideoSignedUrlOptions 115 | } 116 | -------------------------------------------------------------------------------- /packages/mux-video/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "dist", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "strict": false, 9 | "jsx": "react-jsx", 10 | "esModuleInterop": true 11 | }, 12 | "include": ["src/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/mux-video/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | import { preserveDirectivesPlugin } from 'esbuild-plugin-preserve-directives' 3 | 4 | export default defineConfig({ 5 | external: [ 6 | '@types/react', 7 | 'typescript', 8 | 'tsup', 9 | 'react', 10 | 'react-dom', 11 | 'esbuild-plugin-preserve-directives', 12 | ], 13 | entry: ['src/index.ts', 'src/fields/index.ts'], 14 | format: ['esm'], 15 | dts: true, 16 | outDir: 'dist', 17 | splitting: false, 18 | clean: true, 19 | esbuildOptions(options) { 20 | options.loader = { 21 | '.tsx': 'tsx', 22 | '.ts': 'ts', 23 | '.scss': 'copy', 24 | } 25 | }, 26 | esbuildPlugins: [ 27 | preserveDirectivesPlugin({ 28 | directives: ['use client', 'use strict'], 29 | include: /\.(js|ts|jsx|tsx)$/, 30 | exclude: /node_modules/, 31 | }), 32 | ], 33 | }) 34 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | - "dev" 4 | -------------------------------------------------------------------------------- /scripts/blurDataUrlsMigrationScript.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Place this script on your ./src/scripts/ directory. 3 | */ 4 | 5 | import { getPayload, TypedUploadCollection } from 'payload' 6 | import { loadEnv } from 'payload/node' 7 | import type { GeneratedTypes } from 'payload' 8 | import sharp from 'sharp' 9 | 10 | loadEnv() 11 | 12 | /** 13 | * !PLUGIN CONFIGURATION! 14 | */ 15 | 16 | const mediaCollections: (keyof GeneratedTypes['collections'])[] = ['media'] 17 | 18 | const blurOptions = { 19 | width: 20, 20 | height: 'auto', 21 | blur: 18, 22 | } 23 | 24 | /** 25 | * !END OF PLUGIN CONFIGURATION! 26 | */ 27 | 28 | export const generateDataUrl = async (buffer: ArrayBuffer): Promise => { 29 | try { 30 | const { width, height, blur } = blurOptions 31 | 32 | const metadata = await sharp(buffer).metadata() 33 | 34 | let resizedHeight: number 35 | 36 | if (height === 'auto' && metadata.width && metadata.height) { 37 | resizedHeight = Math.round((width / metadata.width) * metadata.height) 38 | } else if (typeof height === 'number') { 39 | resizedHeight = height 40 | } else { 41 | resizedHeight = 32 42 | } 43 | 44 | const blurDataBuffer = await sharp(buffer).resize(width, resizedHeight).blur(blur).toBuffer() 45 | 46 | const blurDataURL = `data:image/png;base64,${blurDataBuffer.toString('base64')}` 47 | 48 | return blurDataURL 49 | } catch (error) { 50 | console.error('Error generating blurDataURL:', error) 51 | throw error 52 | } 53 | } 54 | 55 | async function migrateBlurDataUrls() { 56 | const payload = await getPayload({ 57 | config: await import('../payload.config').then((m) => m.default), 58 | }) 59 | 60 | payload.logger.info('Starting...') 61 | 62 | for (const collection of mediaCollections) { 63 | payload.logger.info(`Going over collection: ${collection}`) 64 | 65 | const media = await payload.find({ 66 | collection, 67 | where: { 68 | blurDataUrl: { 69 | exists: false, 70 | }, 71 | mimeType: { 72 | contains: 'image/', 73 | }, 74 | }, 75 | limit: 999999999, 76 | }) 77 | 78 | const docs = media.docs as unknown as TypedUploadCollection['media'][] 79 | 80 | for (const mediaItem of docs) { 81 | payload.logger.info(`Processing media item: ${mediaItem.id}`) 82 | const response = await fetch(`${process.env.PAYLOAD_PUBLIC_SERVER_URL}/${mediaItem.url}`) 83 | 84 | if (response.status !== 200) { 85 | payload.logger.error(`Error fetching ${mediaItem.url}: ${response.statusText}`) 86 | continue 87 | } 88 | 89 | const blurDataURL = await generateDataUrl(await response.arrayBuffer()) 90 | 91 | await payload.update({ 92 | collection, 93 | id: mediaItem.id, 94 | data: { 95 | blurDataUrl: blurDataURL, 96 | }, 97 | }) 98 | } 99 | } 100 | } 101 | 102 | await migrateBlurDataUrls() 103 | process.exit(0) 104 | --------------------------------------------------------------------------------