Clone starter

This commit is contained in:
toqvist 2024-03-07 15:31:41 +01:00
commit 1b7ee20d05
87 changed files with 17015 additions and 0 deletions

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
MONGODB_URI=mongodb://username:password@localhost:27017
DB_NAME=turbopress
ASTRO_PORT=3001
PAYLOAD_PORT=3000
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
PAYLOAD_SECRET=37aecd563edc5550a44bbf1e
PAYLOAD_CONFIG_PATH=src/payload.config.ts
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=
S3_BUCKET=
S3_ENDPOINT=

5
.eslintrc.cjs Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
root: true,
extends: ["custom"],
rules: {},
};

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# next.js
.next/
out/
build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
.turbo
# vercel
.vercel

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm test && pnpm exec lint-staged

1
.npmrc Normal file
View File

@ -0,0 +1 @@
auto-install-peers = true

12
.prettierignore Normal file
View File

@ -0,0 +1,12 @@
.DS_Store
node_modules
/build
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

13
.prettierrc Normal file
View File

@ -0,0 +1,13 @@
{
"trailingComma": "all",
"plugins": ["prettier-plugin-astro", "prettier-plugin-svelte"],
"overrides": [
{
"files": "*.astro",
"options": {
"parser": "astro"
}
},
{ "files": "*.svelte", "options": { "parser": "svelte" } }
]
}

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"prettier.documentSelectors": ["**/*.astro"],
"[astro]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

128
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

382
LICENSE.md Normal file
View File

@ -0,0 +1,382 @@
# Mozilla Public License Version 2.0
1. Definitions
---
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
---
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
---
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
---
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
---
- *
- 6. Disclaimer of Warranty \*
- ------------------------- \*
- *
- Covered Software is provided under this License on an "as is" \*
- basis, without warranty of any kind, either expressed, implied, or \*
- statutory, including, without limitation, warranties that the \*
- Covered Software is free of defects, merchantable, fit for a \*
- particular purpose or non-infringing. The entire risk as to the \*
- quality and performance of the Covered Software is with You. \*
- Should any Covered Software prove defective in any respect, You \*
- (not any Contributor) assume the cost of any necessary servicing, \*
- repair, or correction. This disclaimer of warranty constitutes an \*
- essential part of this License. No use of any Covered Software is \*
- authorized under this License except under this disclaimer. \*
- *
---
---
- *
- 7. Limitation of Liability \*
- -------------------------- \*
- *
- Under no circumstances and under no legal theory, whether tort \*
- (including negligence), contract, or otherwise, shall any \*
- Contributor, or anyone who distributes Covered Software as \*
- permitted above, be liable to You for any direct, indirect, \*
- special, incidental, or consequential damages of any character \*
- including, without limitation, damages for lost profits, loss of \*
- goodwill, work stoppage, computer failure or malfunction, or any \*
- and all other commercial damages or losses, even if such party \*
- shall have been informed of the possibility of such damages. This \*
- limitation of liability shall not apply to liability for death or \*
- personal injury resulting from such party's negligence to the \*
- extent applicable law prohibits such limitation. Some \*
- jurisdictions do not allow the exclusion or limitation of \*
- incidental or consequential damages, so this exclusion and \*
- limitation may not apply to You. \*
- *
---
8. Litigation
---
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
---
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
## Exhibit A - Source Code Form License Notice
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
## Exhibit B - "Incompatible With Secondary Licenses" Notice
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

68
README.md Normal file
View File

@ -0,0 +1,68 @@
# TurboPress starter
This is a starter Astro + Payload CMS project using Turborepo.
## Why Astro?
Astro allow you to use your favorite UI components and libraries. Mix and match React, Preact, Svelte, Vue, SolidJS, AlpineJS, and Lit to build your own website.
## Why PayloadCMS?
I need a headless CMS that is easy to use with TypeScript support. PayloadCMS work really well in this use case.
## What's inside?
This Turborepo includes the following packages/apps:
### Apps and Packages
- `api`: a [Payload](https://payloadcms.com/) app
- `web`: an [Astro](https://astro.build/) app
- `eslint-config-custom`: `eslint` configurations (includes `eslint-config-prettier` and `eslint-configg-turbo`)
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
### Utilities
This Turborepo has some additional tools already setup for you:
- [TypeScript](https://www.typescriptlang.org/) for static type checking
- [TailwindCSS](https://tailwindcss.com/) for CSS utility
- [ESLint](https://eslint.org/) for code linting
- [Prettier](https://prettier.io) for code formatting
## Prerequisites
Install `nodejs`, `pnpm` and `turborepo` on your local machine
## Develop
Create a `.env` file in the root folder, you can use the `.env.example` file as an example
I use `pnpm` for this project.
Run the following command:
```sh
pnpm installl
pnpm dev
```
By default, the payloadCMS will run on port 3000, and Astro will be served on port 3001.
## Build & Serve (NodeJs)
```sh
pnpm build
pnpm serve
```
## TypeScript support
To fully utilize the type safe features, manually generate the types for PayloadCMS by runng `pnpm generate:types`
Then, you can import the types easily as simple as
```ts
import type { User } from "@turbopress/api/types";
```

1
apps/api/.dockerignore Normal file
View File

@ -0,0 +1 @@
**/node_modules

5
apps/api/.eslintrc.cjs Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
root: true,
extends: ["custom"],
rules: {},
};

169
apps/api/.gitignore vendored Normal file
View File

@ -0,0 +1,169 @@
### 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
# Local media files
media

39
apps/api/Dockerfile Normal file
View File

@ -0,0 +1,39 @@
FROM node:18-alpine as base
RUN npm i -g pnpm turbo
FROM base AS pruner
WORKDIR /app
COPY . .
RUN turbo prune --scope=@turbopress/api --docker
# remove all empty node_modules folder structure
RUN rm -rf /app/out/full/*/*/node_modules
FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app
# Add lockfile and package.json's of isolated subworkspace
COPY .gitignore .gitignore
COPY --from=pruner /app/out/json/ .
RUN pnpm install
# Build the project and its dependencies
COPY --from=pruner /app/out/full/ .
ENV PAYLOAD_CONFIG_PATH=src/payload.config.ts
RUN pnpm build:api
# Run App
FROM base as runner
WORKDIR /app
ENV NODE_ENV=production
ENV PAYLOAD_CONFIG_PATH=dist/payload.config.js
COPY --from=builder /app/apps/api/package.json .
RUN pnpm install --prod
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/apps/api/build ./build
EXPOSE 3000
CMD ["node", "dist/server.js"]

19
apps/api/README.md Normal file
View File

@ -0,0 +1,19 @@
# api
This project was created using create-payload-app using the blog 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 -f Dockerfile ../..`
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`

5
apps/api/nodemon.json Normal file
View File

@ -0,0 +1,5 @@
{
"watch": ["src"],
"ext": "ts",
"exec": "ts-node src/server.ts"
}

36
apps/api/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "@turbopress/api",
"description": "Headless CMS based on Payload",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",
"scripts": {
"dev": "nodemon",
"build:payload": "payload build",
"build:server": "tsc",
"build": "pnpm copyfiles && pnpm build:payload && pnpm build:server",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
"generate:types": "payload generate:types",
"lint": "eslint \"./src/**/*.{js,ts}\" --fix",
"serve": "PAYLOAD_CONFIG_PATH=dist/payload.config.js node dist/server.js"
},
"dependencies": {
"dotenv": "^8.2.0",
"express": "^4.17.1",
"payload": "latest",
"@payloadcms/plugin-seo": "latest",
"@payloadcms/plugin-cloud-storage": "latest",
"@aws-sdk/client-s3": "latest",
"@aws-sdk/lib-storage": "latest"
},
"devDependencies": {
"eslint": "latest",
"@types/express": "^4.17.9",
"copyfiles": "^2.4.1",
"nodemon": "^2.0.6",
"ts-node": "^9.1.1",
"typescript": "^4.8.4",
"webpack-hot-middleware": "^2.25.4",
"eslint-config-custom": "*"
}
}

128
apps/api/src/blocks/Menu.ts Normal file
View File

@ -0,0 +1,128 @@
import payload from "payload";
import { Block } from "payload/types";
import Pages from "../collections/Pages";
import linkField from "../fields/linkField";
export const Menu: Block = {
slug: "menu",
interfaceName: "Menu",
fields: [
{
name: "type",
type: "select",
options: ["default"],
required: true,
defaultValue: "default",
},
{
name: "menus",
type: "array",
fields: [
{
name: "mainMenu",
type: "group",
interfaceName: "MainMenu",
fields: [
{
type: "row",
fields: [
{
name: "type",
type: "radio",
options: [
{
label: "Internal link",
value: "reference",
},
{
label: "Custom URL",
value: "custom",
},
{
label: "None",
value: "none",
},
],
defaultValue: "reference",
admin: {
layout: "horizontal",
width: "50%",
},
},
{
name: "newTab",
label: "Open in new tab",
type: "checkbox",
admin: {
condition: (_, siblingData) => siblingData?.type != "none",
width: "50%",
style: {
alignSelf: "flex-end",
},
},
},
{
name: "reference",
label: "Document to link to",
type: "relationship",
relationTo: [Pages.slug],
required: true,
maxDepth: 0,
admin: {
condition: (_, siblingData) =>
siblingData?.type === "reference",
width: "50%",
},
hooks: {
afterRead: [
async ({ value, siblingData }) => {
if (value && siblingData.type === "reference") {
const id = value.value;
const pages = await payload.find({
collection: "pages",
where: {
id: { equals: id },
},
depth: 0,
});
if (pages.docs[0]?.slug)
siblingData.url = pages.docs[0].slug;
}
},
],
},
},
{
name: "url",
label: "Custom URL",
type: "text",
required: true,
admin: {
condition: (_, siblingData) =>
siblingData?.type === "custom",
width: "50%",
},
},
{
name: "label",
label: "Label",
type: "text",
required: true,
admin: {
width: "50%",
},
},
],
},
{
name: "subMenu",
type: "array",
fields: [linkField()],
},
],
},
],
},
],
};

View File

@ -0,0 +1,17 @@
import { Block } from "payload/types";
export const PageContent: Block = {
slug: "pageContent",
interfaceName: "PageContent",
fields: [
{
name: "description",
type: "textarea",
defaultValue:
"This block will display the content of the page (if any). Please edit the original page change the value.",
admin: {
readOnly: true,
},
},
],
};

View File

@ -0,0 +1,52 @@
import { Block } from "payload/types";
import Categories from "../collections/Categories";
import { PagesField } from "../collections/Pages";
import Tags from "../collections/Tags";
const PageListField = {
numberOfItems: "numberOfItems",
filterByCategories: "filterByCategories",
filterByTags: "filterByTags",
sortBy: "sortBy",
pages: "pages",
};
type PageListField = (typeof PageListField)[keyof typeof PageListField];
export const PageList: Block = {
slug: "pageList",
interfaceName: "PageList",
fields: [
{
name: PageListField.numberOfItems,
type: "number",
defaultValue: 5,
},
{
name: PageListField.filterByCategories,
type: "relationship",
relationTo: [Categories.slug],
maxDepth: 0,
hasMany: true,
},
{
name: PageListField.filterByTags,
type: "relationship",
relationTo: [Tags.slug],
hasMany: true,
maxDepth: 0,
},
{
name: PageListField.sortBy,
type: "select",
options: [
PagesField.title,
PagesField.createdAt,
PagesField.updatedAt,
`-${PagesField.title}`,
`-${PagesField.createdAt}`,
`-${PagesField.updatedAt}`,
],
},
],
};

View File

@ -0,0 +1,15 @@
import { Block } from "payload/types";
import Contents from "../collections/Contents";
export const ReusableContent: Block = {
slug: "reusableContent",
interfaceName: "ReusableContent",
fields: [
{
name: "reference",
type: "relationship",
// maxDepth: 0,
relationTo: [Contents.slug],
},
],
};

View File

@ -0,0 +1,9 @@
import { Block } from "payload/types";
export const SiteTitle: Block = {
slug: "siteTitle",
interfaceName: "SiteTitle",
fields: [
{ name: "siteName", type: "text", required: true, admin: { width: "50%" } },
],
};

View File

@ -0,0 +1,37 @@
import type { CollectionConfig } from "payload/types";
const CategoriesField = {
name: "name",
slug: "slug",
};
type CategoriesField = (typeof CategoriesField)[keyof typeof CategoriesField];
const Categories: CollectionConfig = {
slug: "categories",
admin: {
useAsTitle: CategoriesField.name,
},
access: {
read: () => true,
},
fields: [
{
name: CategoriesField.name,
type: "text",
required: true,
},
{
name: CategoriesField.slug,
type: "text",
required: true,
unique: true,
admin: {
position: "sidebar",
},
},
],
timestamps: false,
};
export default Categories;

View File

@ -0,0 +1,48 @@
import { CollectionConfig } from "payload/types";
import { Menu } from "../blocks/Menu";
import { PageContent } from "../blocks/PageContent";
import { PageList } from "../blocks/PageList";
import { SiteTitle } from "../blocks/SiteTitle";
const ContentsField = {
name: "name",
slug: "slug",
description: "description",
};
type ContentsField = (typeof ContentsField)[keyof typeof ContentsField];
const Contents: CollectionConfig = {
slug: "contents",
access: {
read: () => true,
},
admin: {
useAsTitle: ContentsField.name,
},
fields: [
{
name: ContentsField.name,
type: "text",
required: true,
},
{
name: ContentsField.slug,
type: "text",
unique: true,
admin: {
position: "sidebar",
},
},
{
name: ContentsField.description,
type: "text",
},
{
name: "blocks",
type: "blocks",
blocks: [Menu, PageContent, PageList, SiteTitle],
},
],
};
export default Contents;

View File

@ -0,0 +1,79 @@
import { CollectionConfig } from "payload/types";
import { Menu } from "../blocks/Menu";
import { PageContent } from "../blocks/PageContent";
import { PageList } from "../blocks/PageList";
import { ReusableContent } from "../blocks/ReusableContent";
import { SiteTitle } from "../blocks/SiteTitle";
const LayoutsField = {
name: "name",
slug: "slug",
description: "description",
};
type LayoutsField = (typeof LayoutsField)[keyof typeof LayoutsField];
const blocks = [ReusableContent];
const Layouts: CollectionConfig = {
slug: "layouts",
access: {
read: () => true,
},
admin: {
useAsTitle: LayoutsField.name,
},
fields: [
{
name: LayoutsField.name,
type: "text",
required: true,
},
{
name: LayoutsField.slug,
type: "text",
unique: true,
admin: {
position: "sidebar",
},
},
{
name: LayoutsField.description,
type: "text",
},
{
name: "header",
type: "group",
fields: [
{
name: "blocks",
type: "blocks",
blocks: [...blocks, Menu, SiteTitle],
},
],
},
{
name: "body",
type: "group",
fields: [
{
name: "blocks",
type: "blocks",
blocks: [...blocks, PageContent, PageList],
},
],
},
{
name: "footer",
type: "group",
fields: [
{
name: "blocks",
type: "blocks",
blocks: blocks,
},
],
},
],
};
export default Layouts;

View File

@ -0,0 +1,29 @@
import type { CollectionConfig } from "payload/types";
const Media: CollectionConfig = {
slug: "media",
access: {
read: () => true,
},
upload: {
disableLocalStorage: true,
adminThumbnail: "thumbnail",
imageSizes: [
{
height: 400,
width: 400,
crop: "center",
name: "thumbnail",
},
{
width: 900,
height: 450,
crop: "center",
name: "sixteenByNineMedium",
},
],
},
fields: [],
};
export default Media;

View File

@ -0,0 +1,113 @@
import type { CollectionConfig } from "payload/types";
export const PagesField = {
title: "title",
slug: "slug",
author: "author",
publishedDate: "publishedDate",
categories: "categories",
tags: "tags",
content: "content",
status: "status",
layout: "layout",
createdAt: "createdAt",
updatedAt: "updatedAt",
};
type PagesField = (typeof PagesField)[keyof typeof PagesField];
const PagesFieldStatus = {
Draft: "Draft",
Published: "Published",
};
type PagesFieldStatus =
(typeof PagesFieldStatus)[keyof typeof PagesFieldStatus];
const Pages: CollectionConfig = {
slug: "pages",
admin: {
defaultColumns: [
PagesField.title,
PagesField.slug,
PagesField.author,
PagesField.categories,
PagesField.tags,
PagesField.status,
],
useAsTitle: PagesField.title,
},
access: {
read: () => true,
},
fields: [
{
name: PagesField.title,
type: "text",
required: true,
},
{
name: PagesField.slug,
type: "text",
required: true,
admin: {
position: "sidebar",
},
},
{
name: PagesField.author,
type: "relationship",
relationTo: "users",
admin: {
position: "sidebar",
},
},
{
name: PagesField.publishedDate,
type: "date",
admin: {
position: "sidebar",
},
},
{
name: PagesField.categories,
type: "relationship",
relationTo: "categories",
hasMany: true,
admin: {
position: "sidebar",
},
},
{
name: PagesField.tags,
type: "relationship",
relationTo: "tags",
hasMany: true,
admin: {
position: "sidebar",
},
},
{
name: PagesField.status,
type: "select",
options: Object.entries(PagesFieldStatus).map((e) => {
return { label: e[0], value: e[1] };
}),
defaultValue: PagesFieldStatus.Draft,
admin: {
position: "sidebar",
},
},
{
name: PagesField.layout,
type: "relationship",
relationTo: "layouts",
},
{
name: PagesField.content,
type: "richText",
},
],
};
export default Pages;

View File

@ -0,0 +1,37 @@
import type { CollectionConfig } from "payload/types";
const TagsField = {
name: "name",
slug: "slug",
};
type TagsField = (typeof TagsField)[keyof typeof TagsField];
const Tags: CollectionConfig = {
slug: "tags",
admin: {
useAsTitle: TagsField.name,
},
access: {
read: () => true,
},
fields: [
{
name: TagsField.name,
type: "text",
required: true,
},
{
name: TagsField.slug,
type: "text",
required: true,
unique: true,
admin: {
position: "sidebar",
},
},
],
timestamps: false,
};
export default Tags;

View File

@ -0,0 +1,18 @@
import type { CollectionConfig } from "payload/types";
const Users: CollectionConfig = {
slug: "users",
auth: true,
admin: {
useAsTitle: "email",
},
fields: [
// Email added by default
{
name: "name",
type: "text",
},
],
};
export default Users;

View File

@ -0,0 +1,15 @@
import { SelectField } from "payload/types";
function enableField(fieldOverrides?: Partial<SelectField>): SelectField {
return {
label: "Enable",
name: "enable",
type: "select",
options: ["Yes", "No"],
defaultValue: "Yes",
required: true,
...fieldOverrides,
};
}
export default enableField;

View File

@ -0,0 +1,100 @@
import payload from "payload";
import { GroupField } from "payload/types";
function linkField(fieldOverrides?: Partial<GroupField>): GroupField {
return {
name: "link",
type: "group",
interfaceName: "Link",
fields: [
{
type: "row",
fields: [
{
name: "type",
type: "radio",
options: [
{
label: "Internal link",
value: "reference",
},
{
label: "Custom URL",
value: "custom",
},
],
defaultValue: "reference",
admin: {
layout: "horizontal",
width: "50%",
},
},
{
name: "newTab",
label: "Open in new tab",
type: "checkbox",
admin: {
width: "50%",
style: {
alignSelf: "flex-end",
},
},
},
{
name: "reference",
label: "Document to link to",
type: "relationship",
relationTo: ["pages"],
required: true,
maxDepth: 0,
admin: {
condition: (_, siblingData) => siblingData?.type === "reference",
width: "50%",
},
hooks: {
afterRead: [
async ({ value, siblingData }) => {
if (value && siblingData.type === "reference") {
const id = value.value;
const pages = await payload.find({
collection: "pages",
where: {
id: { equals: id },
},
depth: 0,
});
if (pages.docs[0]?.slug)
if (pages.docs[0]) siblingData.url = pages.docs[0].slug;
}
},
],
},
},
{
name: "url",
label: "Custom URL",
type: "text",
required: true,
admin: {
condition: (_, siblingData) => siblingData?.type === "custom",
width: "50%",
},
},
{
name: "label",
label: "Label",
type: "text",
required: true,
admin: {
width: "50%",
},
},
],
},
],
...fieldOverrides,
};
}
export default linkField;

View File

@ -0,0 +1,59 @@
import { cloudStorage } from "@payloadcms/plugin-cloud-storage";
import { s3Adapter } from "@payloadcms/plugin-cloud-storage/s3";
import seo from "@payloadcms/plugin-seo";
import { GenerateTitle } from "@payloadcms/plugin-seo/dist/types";
import path from "path";
import { buildConfig } from "payload/config";
import Categories from "./collections/Categories";
import Contents from "./collections/Contents";
import Layouts from "./collections/Layouts";
import Media from "./collections/Media";
import Pages from "./collections/Pages";
import Tags from "./collections/Tags";
import Users from "./collections/Users";
const generateTitle: GenerateTitle = ({ slug, doc }) => {
let title = "TurboPress";
if (slug == "pages") {
const page = doc as any;
return (title = `TurboPress - ${page?.title?.value}`);
}
return title;
};
const adapter = s3Adapter({
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
region: process.env.S3_REGION,
endpoint: process.env.S3_ENDPOINT,
},
bucket: process.env.S3_BUCKET,
});
export default buildConfig({
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL ?? "http://localhost:3000",
admin: {
user: Users.slug,
},
collections: [Categories, Contents, Layouts, Media, Pages, Tags, Users],
typescript: {
outputFile: path.join(__dirname, "../types", "payload.ts"),
},
plugins: [
seo({
collections: ["pages"],
uploadsCollection: "media",
generateTitle: generateTitle,
}),
cloudStorage({
collections: {
media: {
adapter: adapter,
},
},
}),
],
cors: "*",
});

30
apps/api/src/server.ts Normal file
View File

@ -0,0 +1,30 @@
import express from "express";
import payload from "payload";
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()}`);
},
mongoOptions: {
dbName: process.env.DB_NAME,
},
});
// Add your own express routes here
app.listen(process.env.PAYLOAD_PORT ?? 3000);
};
start();

22
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"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": ["./types/payload.ts"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist", "build"],
"ts-node": {
"transpileOnly": true,
"swc": true
}
}

2
apps/api/types/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./payload";
export * from "./rich-text-export";

200
apps/api/types/payload.ts Normal file
View File

@ -0,0 +1,200 @@
/* 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 interface Config {
collections: {
categories: Category;
contents: Content;
layouts: Layout;
media: Media;
pages: Page;
tags: Tag;
users: User;
};
globals: {};
}
export interface Category {
id: string;
name: string;
slug: string;
}
export interface Content {
id: string;
name: string;
slug?: string;
description?: string;
blocks?: (Menu | PageContent | PageList | SiteTitle)[];
updatedAt: string;
createdAt: string;
}
export interface Menu {
type: 'default';
menus?: {
mainMenu: MainMenu;
id?: string;
}[];
id?: string;
blockName?: string;
blockType: 'menu';
}
export interface MainMenu {
type?: 'reference' | 'custom' | 'none';
newTab?: boolean;
reference: {
value: string | Page;
relationTo: 'pages';
};
url: string;
label: string;
subMenu?: {
link: Link;
id?: string;
}[];
}
export interface Page {
id: string;
title: string;
slug: string;
author?: string | User;
publishedDate?: string;
categories?: string[] | Category[];
tags?: string[] | Tag[];
status?: 'Draft' | 'Published';
layout?: string | Layout;
content?: {
[k: string]: unknown;
}[];
meta?: {
title?: string;
description?: string;
image?: string | Media;
};
updatedAt: string;
createdAt: string;
}
export interface User {
id: string;
name?: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
salt?: string;
hash?: string;
loginAttempts?: number;
lockUntil?: string;
password?: string;
}
export interface Tag {
id: string;
name: string;
slug: string;
}
export interface Layout {
id: string;
name: string;
slug?: string;
description?: string;
header?: {
blocks?: (ReusableContent | Menu | SiteTitle)[];
};
body?: {
blocks?: (ReusableContent | PageContent | PageList)[];
};
footer?: {
blocks?: ReusableContent[];
};
updatedAt: string;
createdAt: string;
}
export interface ReusableContent {
reference?: {
value: string | Content;
relationTo: 'contents';
};
id?: string;
blockName?: string;
blockType: 'reusableContent';
}
export interface SiteTitle {
siteName: string;
id?: string;
blockName?: string;
blockType: 'siteTitle';
}
export interface PageContent {
description?: string;
id?: string;
blockName?: string;
blockType: 'pageContent';
}
export interface PageList {
numberOfItems?: number;
filterByCategories?:
| {
value: string;
relationTo: 'categories';
}[]
| {
value: Category;
relationTo: 'categories';
}[];
filterByTags?:
| {
value: string;
relationTo: 'tags';
}[]
| {
value: Tag;
relationTo: 'tags';
}[];
sortBy?: 'title' | 'createdAt' | 'updatedAt' | '-title' | '-createdAt' | '-updatedAt';
id?: string;
blockName?: string;
blockType: 'pageList';
}
export interface Media {
id: string;
updatedAt: string;
createdAt: string;
url?: string;
filename?: string;
mimeType?: string;
filesize?: number;
width?: number;
height?: number;
sizes?: {
thumbnail?: {
url?: string;
width?: number;
height?: number;
mimeType?: string;
filesize?: number;
filename?: string;
};
sixteenByNineMedium?: {
url?: string;
width?: number;
height?: number;
mimeType?: string;
filesize?: number;
filename?: string;
};
};
}
export interface Link {
type?: 'reference' | 'custom';
newTab?: boolean;
reference: {
value: string | Page;
relationTo: 'pages';
};
url: string;
label: string;
}

View File

@ -0,0 +1,22 @@
import type {
RichTextElement,
RichTextLeaf,
} from "payload/dist/fields/config/types";
import type { RichTextCustomElement, RichTextCustomLeaf } from "payload/types";
type DefaultRichTextLeaf = Exclude<RichTextLeaf, RichTextCustomLeaf>;
export type FormattedText = {
[key in DefaultRichTextLeaf]?: boolean;
} & {
text: string;
};
type DefaultRichTextElement =
| Exclude<RichTextElement, RichTextCustomElement>
| "li"
| "quote";
export type FormattedElement = {
type: DefaultRichTextElement;
url?: string;
children: FormattedText[];
};

17
apps/web/.eslintrc.cjs Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
root: true,
extends: ["custom", "plugin:astro/recommended"],
overrides: [
{
files: ["*.astro"],
parser: "astro-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
extraFileExtensions: [".astro"],
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
],
};

21
apps/web/.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

13
apps/web/README.md Normal file
View File

@ -0,0 +1,13 @@
# Astro with Tailwind
```
npm create astro@latest -- --template with-tailwindcss
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/with-tailwindcss)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/with-tailwindcss)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/with-tailwindcss/devcontainer.json)
Astro comes with [Tailwind](https://tailwindcss.com) support out of the box. This example showcases how to style your Astro project with Tailwind.
For complete setup instructions, please see our [Tailwind Integration Guide](https://docs.astro.build/en/guides/integrations-guide/tailwind).

24
apps/web/astro.config.mjs Normal file
View File

@ -0,0 +1,24 @@
import mdx from "@astrojs/mdx";
import node from "@astrojs/node";
import svelte from "@astrojs/svelte";
import tailwind from "@astrojs/tailwind";
import { defineConfig } from "astro/config";
export default defineConfig({
integrations: [mdx(), tailwind(), svelte()],
server: {
port: parseInt(process.env.ASTRO_PORT ?? "3000"),
},
output: "server",
adapter: node({
mode: "standalone",
}),
vite: {
define: {
"import.meta.env.PAYLOAD_PUBLIC_SERVER_URL": JSON.stringify(
process.env.PAYLOAD_PUBLIC_SERVER_URL,
),
},
},
});

43
apps/web/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "@turbopress/web",
"description": "Front end website based on Astro",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"lint": "eslint \"./src/**/*.{js,ts,tsx,astro}\" --fix",
"serve": "node dist/server/entry.mjs"
},
"dependencies": {
"@astrojs/mdx": "latest",
"@astrojs/node": "latest",
"@astrojs/svelte": "latest",
"@astrojs/tailwind": "latest",
"@turbopress/api": "*",
"astro": "latest",
"autoprefixer": "latest",
"@iconify/svelte": "latest",
"postcss": "latest",
"qs": "^6.11.2",
"slate": "latest",
"tailwindcss": "latest",
"astro-icon": "latest",
"nanostores": "latest",
"svelte": "latest",
"svelte-inview": "4.0.1 "
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@types/escape-html": "^1.0.2",
"@types/qs": "^6.9.7",
"escape-html": "^1.0.3",
"eslint": "latest",
"eslint-config-custom": "*",
"eslint-plugin-astro": "latest",
"svelte-breakpoints": "latest"
}
}

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@ -0,0 +1,6 @@
<div class="grid place-items-center h-full w-full content-center">
<div class="text-xl font-bold">No homepage has been setup.</div>
<div class="text-lg">
Create a new page with slug 'home' in the admin panel.
</div>
</div>

View File

@ -0,0 +1,18 @@
---
import type {
FormattedElement,
FormattedText,
Layout,
} from "@turbopress/api/types";
import RenderBody from "./body/RenderBody.astro";
import RenderHeader from "./header/RenderHeader.astro";
interface Props {
layout: Layout;
content?: (FormattedElement | FormattedText)[];
}
const { layout, content } = Astro.props;
---
<RenderHeader blocks={layout.header?.blocks} />
<RenderBody blocks={layout.body?.blocks} {content} />

View File

@ -0,0 +1,30 @@
---
import type { Content } from "@turbopress/api/types";
import { getContentSingle } from "../services/api/content.service";
import RenderSiteTitle from "./header/RenderSiteTitle.astro";
import RenderMenu from "./menu/RenderMenu.astro";
interface Props {
content: string | Content;
}
const content: Content | undefined =
typeof Astro.props.content == "string"
? await getContentSingle(Astro.props.content)
: Astro.props.content;
if (!content?.blocks) return;
const blocks = content.blocks;
---
{
blocks.map((block) => {
if (!block.id) return;
if (block.blockType == "siteTitle")
return <RenderSiteTitle siteTitle={block} />;
if (block.blockType == "menu" && block.menus)
return <RenderMenu menu={block} />;
return <div>block = {block.id}</div>;
})
}

View File

@ -0,0 +1,36 @@
---
import type {
FormattedElement,
FormattedText,
PageContent,
PageList,
ReusableContent,
} from "@turbopress/api/types";
import RenderContent from "../RenderReusableContent.astro";
import RenderPageContent from "./RenderPageContent.astro";
// import RenderPageList from "./page-list/RenderPageList.astro";
import RenderPageList from "./page-list/RenderPageList.svelte";
interface Props {
blocks?: (PageList | ReusableContent | PageContent)[];
content?: (FormattedElement | FormattedText)[];
}
const { blocks = [], content } = Astro.props;
if (blocks.length === 0) return;
---
<main class="p-6 flex flex-wrap">
{
blocks.map((block) => {
if (!block.id) return;
if (block.blockType == "reusableContent" && block.reference?.value)
return <RenderContent content={block.reference.value} />;
if (block.blockType == "pageContent")
return <RenderPageContent {content} />;
if (block.blockType == "pageList")
return <RenderPageList {block} client:only />;
return <div>{block.id}</div>;
})
}
</main>

View File

@ -0,0 +1,17 @@
---
import type { FormattedElement, FormattedText } from "@turbopress/api/types";
import RichText from "../rich-text/RichText.astro";
interface Props {
content?: (FormattedElement | FormattedText)[];
title?: string;
}
const { content } = Astro.props;
---
{
content && (
<article class="w-full justify-center prose max-w-none prose-headings:m-0 prose-headings:my-3 prose-p:m-0 prose-p:my-2">
<RichText richText={content} />
</article>
)
}

View File

@ -0,0 +1,18 @@
<script lang="ts">
import type { Page } from "@turbopress/api/types";
import RichText from "../../rich-text/RichText.svelte";
export let page: Page;
</script>
{#if page.content}
<section class="">
<header>
<h2>{page.title}</h2>
</header>
<article>
<RichText richText={page.content}></RichText>
</article>
<hr />
</section>
{/if}

View File

@ -0,0 +1,65 @@
<script lang="ts">
import type { Page, PageList } from "@turbopress/api/types";
import { inview } from "svelte-inview";
import { writable, type Writable } from "svelte/store";
import { getPageCollection } from "../../../services/api";
import type { PayloadCollection } from "../../../types";
import PageListItem from "./PageListItem.svelte";
export let block: PageList;
const query = {
where: {
or: [
{
categories: {
in: block.filterByCategories?.map((f) => f.value),
},
},
{
tags: {
in: block.filterByTags?.map((f) => f.value),
},
},
],
},
limit: block.numberOfItems ?? 5,
page: 1,
sort: block.sortBy,
};
const queryState = writable(query);
const collection: Writable<PayloadCollection<Page> | undefined> = writable();
async function getPages() {
const pages = await getPageCollection($queryState);
collection.set(pages);
}
queryState.subscribe((s) => {
getPages();
});
$: pages = $collection?.docs;
function handleChange({ detail }: CustomEvent<ObserverEventDetails>) {
isInView = detail.inView;
if (detail.inView && $collection?.hasNextPage) {
queryState.set({ ...$queryState, limit: $queryState.limit + 1 });
}
}
let isInView: boolean;
</script>
<div
class="w-full prose max-w-none prose-headings:m-0 prose-headings:my-3 prose-p:m-0 prose-p:my-2"
>
{#if pages}
{#each pages as page}
<PageListItem {page}></PageListItem>
{/each}
{/if}
<div use:inview on:inview_change={handleChange}></div>
</div>

View File

@ -0,0 +1,29 @@
---
import type { Menu, ReusableContent, SiteTitle } from "@turbopress/api/types";
import RenderContent from "../RenderReusableContent.astro";
import RenderMenu from "../menu/RenderMenu.astro";
import RenderSiteTitle from "./RenderSiteTitle.astro";
// import SiteTitle from "./SiteTitle.astro";
interface Props {
blocks?: (Menu | ReusableContent | SiteTitle)[];
}
const { blocks = [] } = Astro.props;
if (blocks.length === 0) return;
---
<header class="shadow p-6 flex flex-wrap">
{
blocks.map((block) => {
if (!block.id) return;
if (block.blockType == "siteTitle")
return <RenderSiteTitle siteTitle={block} />;
if (block.blockType == "reusableContent" && block.reference?.value)
return <RenderContent content={block.reference.value} />;
if (block.blockType == "menu" && block.menus)
return <RenderMenu menu={block} />;
return <div>{block.id}</div>;
})
}
</header>

View File

@ -0,0 +1,16 @@
---
import type { SiteTitle } from "@turbopress/api/types";
import Link from "../link/Link.astro";
interface Props {
siteTitle: SiteTitle;
}
const { siteTitle } = Astro.props;
---
<div class="flex-grow">
<Link link="/">
<div class="font-bold text-lg">{siteTitle.siteName}</div>
</Link>
<div class="w-full"></div>
</div>

View File

@ -0,0 +1,22 @@
---
interface Props {
link?: string | undefined;
target?: "_self" | "_blank" | "_top" | "_parent";
class?: string;
}
const { link, target, class: className } = Astro.props;
---
{
link && (
<a
href={link}
{target}
class={"cursor-pointer hover:text-indigo-600 " + className}
>
<slot />
</a>
)
}
{!link && <slot />}

View File

@ -0,0 +1,22 @@
<script lang="ts">
export let link: string | undefined = undefined;
export let target:
| "_self"
| "_blank"
| "_top"
| "_parent"
| undefined
| null = undefined;
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if link}
<a
href={link}
{target}
class="cursor-pointer hover:text-indigo-600 {$$props.class ?? ''}"
><slot /></a
>
{:else}
<slot />
{/if}

View File

@ -0,0 +1,18 @@
---
import type { MainMenu, Menu } from "@turbopress/api/types";
import DefaultMenu from "./default/DefaultMenu.svelte";
interface Props {
menu: Menu;
}
const { menu } = Astro.props;
const menus = menu.menus ?? [];
const mainMenus: MainMenu[] = menus.map((menu) => menu.mainMenu);
---
{
() => {
return <DefaultMenu menus={mainMenus} client:only="svelte" />;
}
}

View File

@ -0,0 +1,18 @@
<script lang="ts">
import type { MainMenu } from "@turbopress/api/types";
import { useMediaQuery } from "svelte-breakpoints";
import DefaultDesktopMenu from "./desktop/DefaultDesktopMenu.svelte";
import DefaultMobileMenu from "./mobile/DefaultMobileMenu.svelte";
export let menus: MainMenu[];
const isDesktop = useMediaQuery("(min-width: 1024px)");
</script>
{#if !$isDesktop}
<DefaultMobileMenu {menus}></DefaultMobileMenu>
{/if}
{#if $isDesktop}
<DefaultDesktopMenu {menus}></DefaultDesktopMenu>
{/if}

View File

@ -0,0 +1,11 @@
import { map } from "nanostores";
interface MobileMenuState {
isOpen: boolean;
activeIndex?: number;
}
export const mobileMenuState = map<MobileMenuState>({
isOpen: false,
activeIndex: undefined,
});

View File

@ -0,0 +1,32 @@
<script lang="ts">
import type { MainMenu } from "@turbopress/api/types";
import { mobileMenuState } from "../defaultMenu";
import MainMenuSvelte from "./_MainMenu.svelte";
export let menus: MainMenu[];
function handleClick() {
mobileMenuState.setKey("isOpen", !isOpen);
}
$: isOpen = $mobileMenuState.isOpen;
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex items-center cursor-pointer text-sm"
on:click={handleClick}
on:keypress={handleClick}
>
{#each menus as menu, i}
<MainMenuSvelte {menu} />
{/each}
</div>
<!-- {#if isOpen}
<div class="w-full cursor-pointer lg:hidden">
{#each menus as menu, i}
<MainMenuSvelte {menu} index={i} />
{/each}
</div>
{/if} -->

View File

@ -0,0 +1,39 @@
<script lang="ts">
import type { MainMenu } from "@turbopress/api/types";
import Link from "../../../link/Link.svelte";
export let menu: MainMenu;
const subMenus = menu.subMenu ?? [];
</script>
<div class="group relative inline-block text-left group">
<div id="menu-button" aria-expanded="false" aria-haspopup="true">
<Link
link={menu.url}
class="hover:text-black w-full group-hover:bg-slate-100 p-2 "
>
{menu.label}
</Link>
</div>
<div
class="mt-2 absolute right-0 z-10 w-56 origin-top-right rounded-sm bg-white ring-1 ring-slate-200 focus:outline-none hidden group-hover:block"
role="menu"
aria-orientation="vertical"
aria-labelledby="menu-button"
tabindex="-1"
>
<div class="" role="none">
{#each subMenus as subMenu}
<Link link={subMenu.link.url}>
<div
class="px-3 py-1.5 block hover:bg-slate-100 ring-1 ring-inset ring-gray-200 ring-opacity-30"
>
{subMenu.link.label}
</div>
</Link>
{/each}
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import type { Link } from "@turbopress/api/types";
import LinkSvelte from "../../../link/Link.svelte";
export let subMenu: {
link: Link;
id?: string;
};
</script>
<LinkSvelte
link={subMenu.link.url}
class="h-7 hover:text-black hover:bg-slate-100 flex items-center"
>
<div class="w-full px-3">
{subMenu.link.label}
</div>
</LinkSvelte>

View File

@ -0,0 +1,36 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { MainMenu } from "@turbopress/api/types";
import { mobileMenuState } from "../defaultMenu";
import MainMenuSvelte from "./_MainMenu.svelte";
export let menus: MainMenu[];
function handleClick() {
mobileMenuState.setKey("isOpen", !isOpen);
}
$: isOpen = $mobileMenuState.isOpen;
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex items-center cursor-pointer font-semibold hover:text-indigo-600"
on:click={handleClick}
on:keypress={handleClick}
>
{#if isOpen}
<Icon icon="ic:round-close" class="h-5 w-5 mr-2" />
{:else}
<Icon icon="ic:baseline-menu" class="h-5 w-5 mr-2" />
{/if}
<div>Menu</div>
</div>
{#if isOpen}
<div class="w-full cursor-pointer lg:hidden">
{#each menus as menu, i}
<MainMenuSvelte {menu} index={i} />
{/each}
</div>
{/if}

View File

@ -0,0 +1,44 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { MainMenu } from "@turbopress/api/types";
import Link from "../../../link/Link.svelte";
import { mobileMenuState } from "../defaultMenu";
import SubMenu from "./_SubMenu.svelte";
export let menu: MainMenu;
export let index: number;
$: isOpen = $mobileMenuState.activeIndex == index;
function handleClick() {
if (isOpen) mobileMenuState.setKey("activeIndex", undefined);
else mobileMenuState.setKey("activeIndex", index);
}
</script>
<div class="flex items-center h-7">
<Link link={menu.url} class="hover:text-black w-full hover:bg-slate-100 p-1 ">
<div class="w-full">{menu.label}</div>
</Link>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="hover:bg-slate-100 h-8 w-10 {isOpen ? 'bg-indigo-50' : ''}"
on:click={handleClick}
on:keydown={handleClick}
>
<Icon
icon="ic:sharp-keyboard-arrow-down"
class="text-2xl mx-auto mt-1 transition-transform duration-200 {isOpen
? 'rotate-180 text-indigo-800 '
: ''}"
></Icon>
</div>
</div>
{#if isOpen}
{#if menu.subMenu}
{#each menu.subMenu as subMenu}
<SubMenu {subMenu} />
{/each}
{/if}
{/if}

View File

@ -0,0 +1,17 @@
<script lang="ts">
import type { Link } from "@turbopress/api/types";
import LinkSvelte from "../../../link/Link.svelte";
export let subMenu: {
link: Link;
id?: string;
};
</script>
<LinkSvelte
link={subMenu.link.url}
class="h-7 hover:text-black hover:bg-slate-100 flex items-center"
>
<div class="w-full px-3">
{subMenu.link.label}
</div>
</LinkSvelte>

View File

@ -0,0 +1,65 @@
---
import type { FormattedElement, FormattedText } from "@turbopress/api/types";
import escapeHTML from "escape-html";
import { Text } from "slate";
interface Props {
richText: (FormattedElement | FormattedText)[];
}
const { richText } = Astro.props;
---
{
richText.map((node) => {
return Text.isText(node) ? (
<Fragment>
{node.bold && <strong>{node.text}</strong>}
{node.code && <code>{node.text}</code>}
{node.italic && <em>{node.text}</em>}
{!node.bold && !node.code && !node.italic && (
<Fragment>{node.text}</Fragment>
)}
</Fragment>
) : (
<Fragment>
{node.type === "h1" && (
<h1>{<Astro.self richText={node.children} />}</h1>
)}
{node.type === "h2" && (
<h2>{<Astro.self richText={node.children} />}</h2>
)}
{node.type === "h3" && (
<h3>{<Astro.self richText={node.children} />}</h3>
)}
{node.type === "h4" && (
<h4>{<Astro.self richText={node.children} />}</h4>
)}
{node.type === "h5" && (
<h5>{<Astro.self richText={node.children} />}</h5>
)}
{node.type === "h6" && (
<h6>{<Astro.self richText={node.children} />}</h6>
)}
{node.type === "quote" && (
<p>{<Astro.self richText={node.children} />}</p>
)}
{node.type === "ul" && (
<ul>{<Astro.self richText={node.children} />}</ul>
)}
{node.type === "ol" && (
<ol>{<Astro.self richText={node.children} />}</ol>
)}
{node.type === "li" && (
<li>{<Astro.self richText={node.children} />}</li>
)}
{node.type === "link" && (
<a href={escapeHTML(node.url)}>
{<Astro.self richText={node.children} />}
</a>
)}
{!node.type && <p>{<Astro.self richText={node.children} />}</p>}
</Fragment>
);
})
}

View File

@ -0,0 +1,45 @@
<script lang="ts">
import type { FormattedElement, FormattedText } from "@turbopress/api/types";
import { Text } from "slate";
export let richText: (FormattedElement | FormattedText | any)[];
</script>
{#each richText as node}
{#if Text.isText(node)}
{#if node.bold}
<strong>{node.text}</strong>
{/if}
{#if node.code}
<strong>{node.text}</strong>
{/if}
{#if node.italic}
<strong>{node.text}</strong>
{/if}
{#if !node.bold && !node.code && !node.italic}
{node.text}
{/if}
{:else}
{#if node.type === "h1"}
<h1><svelte:self richText={node.children}></svelte:self></h1>
{/if}
{#if node.type === "h2"}
<h2><svelte:self richText={node.children}></svelte:self></h2>
{/if}
{#if node.type === "h3"}
<h3><svelte:self richText={node.children}></svelte:self></h3>
{/if}
{#if node.type === "h4"}
<h4><svelte:self richText={node.children}></svelte:self></h4>
{/if}
{#if node.type === "h5"}
<h5><svelte:self richText={node.children}></svelte:self></h5>
{/if}
{#if node.type === "h6"}
<h6><svelte:self richText={node.children}></svelte:self></h6>
{/if}
{#if !node.type}
<p><svelte:self richText={node.children}></svelte:self></p>
{/if}
{/if}
{/each}

5
apps/web/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly ASTRO_PORT: string;
}

View File

@ -0,0 +1,48 @@
---
import type {
FormattedElement,
FormattedText,
Layout,
Media,
} from "@turbopress/api/types";
import RenderLayout from "../components/RenderLayout.astro";
interface Props {
title?: string;
description?: string;
layout?: string | Layout;
content?: (FormattedElement | FormattedText | any)[];
image?: string | Media;
}
const {
title = "AstroCMS",
description = "Astro, TailwindCSS, and PayloadCMS",
layout,
content,
image,
} = Astro.props;
const metaImage = image
? typeof image === "string"
? image
: image.url
: undefined;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content={description} />
{metaImage && <meta property="og:image" content={metaImage} />}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body class="h-screen">
{
layout && typeof layout != "string" && (
<RenderLayout layout={layout} {content} />
)
}
{!layout && <slot />}
</body>
</html>

View File

@ -0,0 +1,12 @@
---
import MainLayout from "../layouts/MainLayout.astro";
import { getLayoutSingle } from "../services/api/layout.service";
const layout = await getLayoutSingle("404");
---
<MainLayout title="Error 404" layout={layout} description="Page not found">
Not found, error 404 The page you are looking for no longer exists. Perhaps
you can return back to the homepage and see if you can find what you are
looking for. Or, you can try finding it by using the search form below.
</MainLayout>

View File

@ -0,0 +1,16 @@
---
import MainLayout from "../layouts/MainLayout.astro";
import { getPageSingle } from "../services/api";
const { slug } = Astro.params;
const page = await getPageSingle(slug!);
if (!page) return Astro.redirect("/404");
if (page.slug == "home") return Astro.redirect("/");
---
<MainLayout title={page.title} layout={page.layout} description="">
{page.title}
{page.layout}
<!-- {homePage && <RenderPage page={homePage} />}
{!homePage && <NoHomePage />} -->
</MainLayout>

View File

@ -0,0 +1,18 @@
---
import MainLayout from "../layouts/MainLayout.astro";
import { getPageSingle } from "../services/api";
const homePage = await getPageSingle("home");
const pageTitle = homePage?.meta?.title ?? homePage?.title ?? "TurboPress";
const layout = homePage?.layout;
---
<MainLayout
title={pageTitle}
{layout}
description={homePage?.meta?.description}
content={homePage?.content}
image={homePage?.meta?.image}
/>

View File

@ -0,0 +1,31 @@
import qs from "qs";
import type { PayloadCollection } from "../../types";
export async function apiFetch<T = any>(
url: string | URL,
options: RequestInit = {},
) {
const defaultOptions = {
headers: {
"Content-Type": "application/json",
},
};
const res = await fetch(url, { ...defaultOptions, ...options });
if (res.ok) {
return res.json() as T;
}
throw new Error(`Error fetching data: ${res.statusText} (${res.status})}`);
}
export async function getPayloadCollection<CollectionType>(
url: string | URL,
query: any = null,
) {
const stringifiedQuery = qs.stringify(query, { addQueryPrefix: true });
return apiFetch<PayloadCollection<CollectionType>>(url + stringifiedQuery);
}
export async function getPayloadDocument<CollectionType>(url: string | URL) {
return apiFetch<CollectionType>(url);
}

View File

@ -0,0 +1,16 @@
import type { Layout } from "@turbopress/api/types";
import { getPayloadCollection } from "./api.service";
export async function getContentCollection(query: any = null) {
const url = ` ${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/layouts`;
return getPayloadCollection<Layout>(url, query);
}
export async function getContentSingle(
name: string,
): Promise<Layout | undefined> {
const pages = await getContentCollection({
where: { name: { equals: name } },
});
if (pages.docs[0]) return pages.docs[0];
}

View File

@ -0,0 +1,2 @@
export * from "./api.service";
export * from "./page.service";

View File

@ -0,0 +1,16 @@
import type { Layout } from "@turbopress/api/types";
import { getPayloadCollection } from "./api.service";
export async function getLayoutCollection(query: any = null) {
const url = ` ${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/layouts`;
return getPayloadCollection<Layout>(url, query);
}
export async function getLayoutSingle(
name: string,
): Promise<Layout | undefined> {
const pages = await getLayoutCollection({
where: { name: { equals: name } },
});
if (pages.docs[0]) return pages.docs[0];
}

View File

@ -0,0 +1,19 @@
import type { Media } from "@turbopress/api/types";
import { getPayloadCollection, getPayloadDocument } from "./api.service";
export async function getMediaCollection(query: any = null) {
const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/medias`;
return getPayloadCollection<Media>(url, query);
}
export async function getMediaSingle(slug: string): Promise<Media | undefined> {
const medias = await getMediaCollection({
where: { slug: { equals: slug } },
});
if (medias.docs[0]) return medias.docs[0];
}
export async function getMediaById(id: string): Promise<Media | undefined> {
const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/media/` + id;
return getPayloadDocument<Media>(url);
}

View File

@ -0,0 +1,14 @@
import type { Page } from "@turbopress/api/types";
import { getPayloadCollection } from "./api.service";
export async function getPageCollection(query: any = null) {
const url = `${import.meta.env.PAYLOAD_PUBLIC_SERVER_URL}/api/pages`;
return getPayloadCollection<Page>(url, query);
}
export async function getPageSingle(slug: string): Promise<Page | undefined> {
const pages = await getPageCollection({
where: { slug: { equals: slug } },
});
if (pages.docs[0]) return pages.docs[0];
}

22
apps/web/src/types.ts Normal file
View File

@ -0,0 +1,22 @@
import type { FormattedElement, FormattedText } from "@turbopress/api/types";
declare module "slate" {
interface CustomTypes {
Element: FormattedElement;
Text: FormattedText;
}
}
export type PayloadCollection<CollectionType = any> = {
totalDocs?: number;
limit?: number;
totalPages?: number;
page?: number;
pagingCounter?: number;
hasPrevPage?: boolean;
hasNextPage?: boolean;
prevPage?: number;
nextPage?: number;
hasMore?: boolean;
docs: CollectionType[];
};

View File

@ -0,0 +1,7 @@
module.exports = {
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
extend: {},
},
plugins: [require("@tailwindcss/typography")],
};

4
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "astro/tsconfigs/strict",
"exclude": ["node_modules"]
}

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "turbopress",
"description": "A web + headless CMS turborepo",
"private": true,
"scripts": {
"prepare": "husky install",
"build": "turbo build",
"build:api": "dotenv -- turbo build --filter=api",
"build:web": "dotenv -- turbo build --filter=web",
"dev": "dotenv -- turbo dev",
"dev:api": "dotenv -- turbo dev --filter=api",
"dev:web": "dotenv -- turbo dev --filter=web",
"test": "turbo test",
"lint": "turbo lint",
"format": "prettier --write \"**/*.{ts,tsx,md,astro}\"",
"generate:types": "dotenv -- pnpm --filter=api generate:types ",
"serve:api": "dotenv -- turbo serve --filter=api",
"serve:web": "dotenv -- turbo serve --filter=web",
"serve": "dotenv -- turbo serve"
},
"devDependencies": {
"@turbo/gen": "^1.9.7",
"dotenv": "^8.2.0",
"dotenv-cli": "^7.2.1",
"eslint": "latest",
"eslint-config-custom": "*",
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
"prettier": "latest",
"prettier-plugin-astro": "latest",
"prettier-plugin-svelte": "latest",
"turbo": "latest"
},
"preinstall": "npx only-allow pnpm",
"lint-staged": {
"apps/**/*.{js,ts,astro}": [
"eslint --fix"
],
"packages/**/*.{js,ts,astro}": [
"eslint --fix"
],
"*.json": [
"prettier --write"
]
}
}

View File

@ -0,0 +1,9 @@
module.exports = {
root: true,
extends: ["plugin:@typescript-eslint/recommended", "turbo", "prettier"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
parserOptions: {},
plugins: ["@typescript-eslint", "turbo"],
};

View File

@ -0,0 +1,15 @@
{
"name": "eslint-config-custom",
"version": "0.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"eslint-config-prettier": "latest",
"eslint-config-turbo": "latest",
"@typescript-eslint/parser": "latest",
"@typescript-eslint/eslint-plugin": "latest"
},
"publishConfig": {
"access": "public"
}
}

13953
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,5 @@
packages:
- "apps/*"
- "packages/*"
- "!**/test/**"

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "node",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true
},
"exclude": ["node_modules"]
}

16
turbo.json Normal file
View File

@ -0,0 +1,16 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDotEnv": [".env"],
"pipeline": {
"build": {
"outputs": ["dist/**", "umd/**", "build/**"]
},
"test": {},
"lint": {},
"dev": {
"cache": false,
"persistent": true
},
"serve": {}
}
}