Initial commit

This commit is contained in:
DrMint 2023-07-14 13:03:01 +02:00
commit 8b942f35e8
24 changed files with 10715 additions and 0 deletions

170
.gitignore vendored Normal file
View File

@ -0,0 +1,170 @@
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# Support for Project snippet scope
.vscode/*.code-snippets
# Ignore code-workspaces
*.code-workspace
# End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode
# Ignore Data
mongo/
uploads/

26
Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM node:18.8-alpine as base
FROM base as builder
WORKDIR /home/node/app
COPY package*.json ./
COPY . .
RUN npm install
RUN npm run build
FROM base as runtime
ENV NODE_ENV=production
ENV PAYLOAD_CONFIG_PATH=dist/payload.config.js
WORKDIR /home/node/app
COPY package*.json ./
RUN npm install --production
COPY --from=builder /home/node/app/dist ./dist
COPY --from=builder /home/node/app/build ./build
EXPOSE 3000
CMD ["node", "dist/server.js"]

19
README.md Normal file
View File

@ -0,0 +1,19 @@
# payload
This project was created using create-payload-app using the blank template.
## How to Use
`yarn dev` will start up your application and reload on any changes.
### Docker
If you have docker and docker-compose installed, you can run `docker-compose up`
To build the docker image, run `docker build -t my-tag .`
Ensure you are passing all needed environment variables when starting up your container via `--env-file` or setting them with your deployment.
The 3 typical env vars will be `MONGODB_URI`, `PAYLOAD_SECRET`, and `PAYLOAD_CONFIG_PATH`
`docker run --env-file .env -p 3000:3000 my-tag`

33
docker-compose.yml Normal file
View File

@ -0,0 +1,33 @@
version: "3"
services:
payload:
image: node:18-alpine
ports:
- "${PAYLOAD_PORT}:${PAYLOAD_PORT}"
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "npm install && npm run dev"
depends_on:
- mongo
environment:
MONGODB_URI: ${MONGODB_URI}
PAYLOAD_SECRET: ${PAYLOAD_SECRET}
PORT: ${PAYLOAD_PORT}
NODE_ENV: development
mongo:
image: mongo:latest
ports:
- "${MONGODB_PORT}:27017"
command:
- --storageEngine=wiredTiger
volumes:
- ./mongo:/data/db
logging:
driver: none
volumes:
node_modules:

4
nodemon.json Normal file
View File

@ -0,0 +1,4 @@
{
"ext": "ts",
"exec": "ts-node src/server.ts"
}

9927
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "payload",
"description": "Payload project created from blank template",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",
"scripts": {
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
"build:server": "tsc",
"build": "npm run copyfiles && npm run build:payload && npm run build:server",
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
"generate:graphQLSchema": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema"
},
"dependencies": {
"cross-env": "^7.0.3",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"iso-639-1": "^2.1.15",
"payload": "^1.11.1"
},
"devDependencies": {
"@types/express": "^4.17.9",
"copyfiles": "^2.4.1",
"nodemon": "^2.0.6",
"prettier": "^3.0.0",
"ts-node": "^9.1.1",
"typescript": "^4.8.4"
}
}

37
src/collections/Images.ts Normal file
View File

@ -0,0 +1,37 @@
import { CollectionConfig } from "payload/types";
const fields = {
filename: "filename",
mimeType: "mimeType",
filesize: "filesize",
alt: "alt",
} as const satisfies Record<string, string>;
const labels = {
singular: "Image",
plural: "Images",
} as const satisfies { singular: string; plural: string };
export const Images: CollectionConfig = {
slug: labels.plural,
labels,
typescript: { interface: labels.singular },
defaultSort: fields.filename,
admin: {
useAsTitle: fields.filename,
group: "Media",
},
upload: {
staticDir: `../uploads/${labels.plural}`,
mimeTypes: ["image/*"],
},
fields: [
{
name: fields.alt,
label: "Alt Text",
type: "text",
},
],
};

View File

@ -0,0 +1,43 @@
import { CollectionConfig } from "payload/types";
const fields = {
id: "id",
name: "name",
} as const satisfies Record<string, string>;
const labels = {
singular: "Language",
plural: "Languages",
} as const satisfies { singular: string; plural: string };
export const Languages: CollectionConfig = {
slug: labels.plural,
labels,
typescript: { interface: labels.singular },
defaultSort: fields.name,
admin: {
useAsTitle: fields.name,
defaultColumns: [fields.name, fields.id],
},
timestamps: false,
fields: [
{
name: fields.id,
type: "text",
unique: true,
required: true,
validate: (value) => {
if (/^[a-z]{2}(-[a-z]{2})?$/g.test(value)) {
return true;
}
return "The code must be a valid IETF language tag and lowercase (i.e: en, pt-pt, fr, zh-tw...)";
},
},
{
name: fields.name,
type: "text",
unique: true,
required: true,
},
],
};

View File

@ -0,0 +1,96 @@
import { CollectionConfig } from "payload/types";
import { localizedFields } from "../../utils/fields";
import { Languages } from "../Languages";
import { Images } from "../Images";
import { ImageCell } from "../../components/ImageCell";
import { BiographiesCell } from "./components/BiographiesCell";
import { beforeDuplicate } from "./hooks/beforeDuplicate";
import { BiographiesRowLabel } from "./components/BiographiesRowLabel";
const fields = {
username: "username",
anonymize: "anonymize",
languages: "languages",
biographies: "biographies",
biography: "biography",
avatar: "avatar",
} as const satisfies Record<string, string>;
const labels = {
singular: "Recorder",
plural: "Recorders",
} as const satisfies { singular: string; plural: string };
export const Recorders: CollectionConfig = {
slug: labels.plural,
labels,
typescript: { interface: labels.singular },
defaultSort: fields.username,
admin: {
useAsTitle: fields.username,
hooks: { beforeDuplicate },
description:
"Recorders are contributors of the Accord's Library project. Create a Recorder here to be able to credit them in other collections",
defaultColumns: [
fields.username,
fields.anonymize,
fields.biographies,
fields.languages,
],
},
timestamps: false,
fields: [
{
type: "row",
fields: [
{
name: fields.avatar,
type: "upload",
relationTo: Images.slug,
admin: {
components: {
Cell: ImageCell,
},
},
},
{
name: fields.username,
type: "text",
unique: true,
required: true,
admin: { description: "The username must be unique" },
},
{
name: fields.anonymize,
type: "checkbox",
required: true,
defaultValue: false,
admin: {
width: "50%",
description:
"If enabled, this recorder's username will not be made public. Instead they will be referred to as 'Recorder#0000' where '0000' is a random four digit number",
},
},
],
},
{
name: fields.languages,
type: "relationship",
relationTo: Languages.slug,
hasMany: true,
admin: {
allowCreate: false,
description: "List of language(s) that this recorder is familiar with",
},
},
localizedFields({
name: fields.biographies,
interfaceName: "RecorderBiographies",
admin: {
initCollapsed: true,
components: { RowLabel: BiographiesRowLabel, Cell: BiographiesCell },
},
fields: [{ name: fields.biography, type: "textarea" }],
}),
],
};

View File

@ -0,0 +1,45 @@
import { Props } from "payload/components/views/Cell";
import { useMemo } from "react";
import React from "react";
import { RecorderBiographies } from "../../../types/collections";
import { isDefined } from "../../../utils/asserts";
import { formatLanguageCode } from "../../../utils/string";
export const BiographiesCell: React.FC<Props> = ({ cellData }) => {
if (!Array.isArray(cellData)) return <>No biographies</>;
return (
<div style={{ display: "flex", gap: "6px" }}>
{cellData.map((biography: RecorderBiographies[number], index) => (
<BiographyCell key={biography.id} {...biography} index={index} />
))}
</div>
);
};
const BiographyCell: React.FC<
RecorderBiographies[number] & { index: number }
> = ({ language, biography, index }) => {
const label = useMemo(() => {
if (isDefined(language) && typeof language === "string") {
return formatLanguageCode(language);
}
return `Biography ${index}`;
}, []);
return (
<div
className="biography-cell"
style={{
backgroundColor: "var(--theme-elevation-100)",
color: "var(--theme-elevation-800)",
padding: "0.2em 0.5em",
borderRadius: 3,
}}
>
<abbr title={biography}>
<div style={{ position: "relative" }}>{label}</div>
</abbr>
</div>
);
};

View File

@ -0,0 +1,19 @@
import { RecorderBiographies } from "../../../types/collections";
import { AdminComponent } from "../../../utils/components";
import { isDefined } from "../../../utils/asserts";
import { formatLanguageCode, shortenEllipsis } from "../../../utils/string";
export const BiographiesRowLabel: AdminComponent<
RecorderBiographies[number]
> = ({ data: { language, biography }, index }) => {
const labelValues = [];
if (isDefined(language) && typeof language === "string") {
labelValues.push(formatLanguageCode(language));
}
if (isDefined(biography)) {
labelValues.push(shortenEllipsis(biography, 50));
}
const label = labelValues.join(" — ");
if (label === "") return `Biography ${index}`;
return label;
};

View File

@ -0,0 +1,9 @@
import { BeforeDuplicate } from "payload/types";
import { Recorder } from "../../../types/collections";
export const beforeDuplicate: BeforeDuplicate<Recorder> = ({ data }) => {
return {
...data,
id: `${data.id}-copy`,
};
};

25
src/collections/Users.ts Normal file
View File

@ -0,0 +1,25 @@
import { CollectionConfig } from "payload/types";
const fields = {
email: "email",
} as const satisfies Record<string, string>;
const labels = {
singular: "User",
plural: "Users",
} as const satisfies { singular: string; plural: string };
export const Users: CollectionConfig = {
slug: labels.plural,
auth: true,
labels,
typescript: { interface: labels.singular },
defaultSort: fields.email,
admin: {
useAsTitle: fields.email,
defaultColumns: [fields.email],
group: "Administration",
},
timestamps: false,
fields: [],
};

View File

@ -0,0 +1,27 @@
import { Props } from "payload/components/views/Cell";
import { useState, useEffect } from "react";
import React from "react";
export const ImageCell: React.FC<Props> = ({ cellData, field }) => {
const [imageURL, setImageURL] = useState<string>();
useEffect(() => {
const fetchUrl = async () => {
if (typeof cellData !== "string") return;
if (field.type !== "upload") return;
const result = await (
await fetch(`/api/${field.relationTo}/${cellData}`)
).json();
setImageURL(result.url);
};
fetchUrl();
}, [cellData]);
return imageURL ? (
<img
style={{ height: "3rem", borderRadius: "100%", aspectRatio: "1/1" }}
src={imageURL}
/>
) : (
"<No image>"
);
};

22
src/payload.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { buildConfig } from "payload/config";
import path from "path";
import { Users } from "./collections/Users";
import { Languages } from "./collections/Languages";
import { Recorders } from "./collections/Recorders/Recorders";
import { Images } from "./collections/Images";
export default buildConfig({
serverURL: "http://localhost:3000",
admin: {
user: Users.slug,
},
collections: [Users, Languages, Recorders, Images],
globals: [],
telemetry: false,
typescript: {
outputFile: path.resolve(__dirname, "types/collections.ts"),
},
graphQL: {
schemaOutputFile: path.resolve(__dirname, "generated-schema.graphql"),
},
});

28
src/server.ts Normal file
View File

@ -0,0 +1,28 @@
import express from "express";
import payload from "payload";
require("dotenv").config();
const app = express();
// Redirect root to Admin panel
app.get("/", (_, res) => {
res.redirect("/admin");
});
const start = async () => {
// Initialize Payload
await payload.init({
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
express: app,
onInit: async () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`);
},
});
// Add your own express routes here
app.listen(3000);
};
start();

57
src/types/collections.ts Normal file
View File

@ -0,0 +1,57 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export type RecorderBiographies = {
language: string | Language;
biography?: string;
id?: string;
}[];
export interface Config {
collections: {
Users: User;
Languages: Language;
Recorders: Recorder;
Images: Image;
};
globals: {};
}
export interface User {
id: string;
email: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
salt?: string;
hash?: string;
loginAttempts?: number;
lockUntil?: string;
password?: string;
}
export interface Language {
id: string;
name: string;
}
export interface Recorder {
id: string;
username: string;
anonymize: boolean;
languages?: string[] | Language[];
biographies?: RecorderBiographies;
}
export interface Image {
id: string;
alt?: string;
updatedAt: string;
createdAt: string;
url?: string;
filename?: string;
mimeType?: string;
filesize?: number;
width?: number;
height?: number;
}

9
src/utils/asserts.ts Normal file
View File

@ -0,0 +1,9 @@
export const isDefined = <T>(value: T | null | undefined): value is T =>
value !== null && value !== undefined;
export const isUndefined = <T>(
value: T | null | undefined
): value is null | undefined => !isDefined(value);
export const filterDefined = <T>(array: (T | null | undefined)[]): T[] =>
array.filter(isDefined);

4
src/utils/components.ts Normal file
View File

@ -0,0 +1,4 @@
export type AdminComponent<T extends Record<string, any>> = (props: {
data: Partial<T>;
index?: number;
}) => string;

40
src/utils/fields.ts Normal file
View File

@ -0,0 +1,40 @@
import { ArrayField } from "payload/types";
import { hasDuplicates } from "./validation";
import { isDefined, isUndefined } from "./asserts";
import { Languages } from "../collections/Languages";
const LANGUAGE_FIELD_NAME = "language";
type LocalizedFieldsProps = Omit<ArrayField, "type">;
type ArrayData = { [LANGUAGE_FIELD_NAME]?: string }[] | number | undefined;
export const localizedFields = ({
fields,
validate,
...otherProps
}: LocalizedFieldsProps): ArrayField => ({
...otherProps,
type: "array",
validate: (value, options) => {
const data = options.data[otherProps.name] as ArrayData;
if (isUndefined(data)) return true;
if (typeof data === "number") return true;
const languages = data.map((biography) => biography.language);
if (hasDuplicates(languages)) {
return `There cannot be multiple ${otherProps.name} with the same ${LANGUAGE_FIELD_NAME}`;
}
return isDefined(validate) ? validate(value, options) : true;
},
fields: [
{
name: LANGUAGE_FIELD_NAME,
type: "relationship",
relationTo: Languages.slug,
required: true,
admin: { allowCreate: false },
},
...fields,
],
});

7
src/utils/string.ts Normal file
View File

@ -0,0 +1,7 @@
import ISO6391 from "iso-639-1";
export const shortenEllipsis = (text: string, length: number): string =>
text.length - 3 > length ? `${text.substring(0, length)}...` : text;
export const formatLanguageCode = (code: string): string =>
ISO6391.validate(code) ? ISO6391.getName(code) : code;

2
src/utils/validation.ts Normal file
View File

@ -0,0 +1,2 @@
export const hasDuplicates = <T>(list: T[]): boolean =>
list.length !== new Set(list).size;

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react",
"paths": {
"payload/generated-types": [
"./src/payload-types.ts",
],
}
},
"include": [
"src"
],
"exclude": [
"node_modules",
"dist",
"build",
],
"ts-node": {
"transpileOnly": true,
"swc": true,
}
}