Compare commits

...

No commits in common. "main" and "old-cms-migration" have entirely different histories.

168 changed files with 17030 additions and 15976 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
/Dockerfile

View File

@ -2,46 +2,15 @@
kind: pipeline
name: publish pipeline
steps:
- name: publish astro container
- name: publish container
image: plugins/docker
settings:
username: 3wordchant
password:
from_secret: git_autonomic_zone_token_3wc
repo: git.autonomic.zone/ruangrupa/lumbung-kios-astro
repo: git.autonomic.zone/autonomic-cooperative/justice-equity-technology
auto_tag: true
registry: git.autonomic.zone
context: astro
dockerfile: astro/Dockerfile
target: prod
- name: publish payload container
image: plugins/docker
settings:
username: 3wordchant
password:
from_secret: git_autonomic_zone_token_3wc
repo: git.autonomic.zone/ruangrupa/lumbung-kios-payload
auto_tag: true
registry: git.autonomic.zone
context: payload
dockerfile: payload/Dockerfile
target: prod
- name: deploy stack
image: git.coopcloud.tech/coop-cloud/stack-ssh-deploy:latest
settings:
stack: kios_lumbung_space
host: lumbung.space
deploy_key:
from_secret: drone_ssh_lumbung.space
environment:
DOMAIN: kios.lumbung.space
STACK_NAME: kios_lumbung_space
SECRET_PAYLOAD_SECRET_VERSION: v1
SECRET_TOKEN_VERSION: v1
SECRET_MONGO_PASSWORD_VERSION: v1
depends_on:
- publish astro container
- publish payload container
trigger:
branch:
- main

11
.env
View File

@ -1,11 +0,0 @@
NAME=astroad
ASTRO_HOST=localhost:3000
PAYLOAD_HOST=payload:3001
PAYLOAD_URL=http://localhost:3001
PAYLOAD_PORT=3001
PAYLOAD_SECRET=supersecretkey
MONGODB_URI=mongodb://payload:test@mongo:27017
MONGODB_USER=payload
MONGODB_PW=test
TOKEN=supersecrettoken
REPOSITORY=mooxl/astroad

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
MONGODB_URI=mongodb://db:27017
DB_NAME=turbopress
# # FIXME: this is ignored? 🤔🤔🤔
ASTRO_PORT=3000
ASTRO_HOST=0.0.0.0
HOST=0.0.0.0
PAYLOAD_PORT=3001
PAYLOAD_HOST=0.0.0.0
PAYLOAD_PUBLIC_SERVER_URL=http://jett.localhost:3001
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: {},
};

View File

@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/astro"
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/payload"
schedule:
interval: "daily"

View File

@ -1,29 +0,0 @@
name: Payload update
on:
repository_dispatch:
types: [payload_update]
jobs:
cancel:
name: Cancel Previous Runs
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
ignore_sha: true
access_token: ${{ github.token }}
workflow_id: "payload.yml"
build:
needs: cancel
runs-on: ubuntu-latest
steps:
- name: Trigger build
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
key: ${{ secrets.KEY }}
script: |
cd ${{ secrets.PATH }}
yarn prod astro

View File

@ -1,74 +0,0 @@
name: Code Deployment and Environment Setup
on:
push:
branches:
- main
jobs:
build:
name: Run remote SSH commands
runs-on: ubuntu-latest
steps:
- name: Clone or pull repository
uses: appleboy/ssh-action@v0.1.10
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
key: ${{ secrets.KEY }}
script_stop: false
script: |
if [ -d ${{ secrets.PATH }} ]; then
cd ${{ secrets.PATH }}
git pull
else
mkdir -p ${{ secrets.PATH }}
cd ${{ secrets.PATH }}
git clone git@github.com:${{ github.repository }}.git .
fi
- name: Update environment variables
uses: appleboy/ssh-action@v0.1.10
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
key: ${{ secrets.KEY }}
script_stop: false
script: |
# Bash function to replace env variables
replace_env() {
local env_var=$1
local new_value=$2
awk -v var="${env_var}" -v val="${new_value}" -F '=' '{OFS=FS} $1 == var {$2 = val} 1' .env > .env.tmp && mv .env.tmp .env
}
cd ${{ secrets.PATH }}
# Replace .env with vars
replace_env 'NAME' '${{ vars.NAME }}'
replace_env 'ASTRO_HOST' '${{ vars.ASTRO_HOST }}'
replace_env 'PAYLOAD_HOST' '${{ vars.PAYLOAD_HOST }}'
replace_env 'PAYLOAD_URL' '${{ vars.PAYLOAD_URL }}'
replace_env 'PAYLOAD_PORT' '${{ vars.PAYLOAD_PORT }}'
# Replace .env with secrets
replace_env 'PAYLOAD_SECRET' '${{ secrets.PAYLOAD_SECRET }}'
replace_env 'MONGODB_URI' 'mongodb://${{ secrets.MONGODB_USER }}:${{ secrets.MONGODB_PW }}@mongo:27017'
replace_env 'MONGODB_USER' '${{ secrets.MONGODB_USER }}'
replace_env 'MONGODB_PW' '${{ secrets.MONGODB_PW }}'
replace_env 'TOKEN' '${{ secrets.TOKEN }}'
# Replace .env with GitHub repository
replace_env 'REPOSITORY' '${{ github.repository }}'
mkdir -p ./astro
cp .env ./astro/.env
- name: Start Production Services
uses: appleboy/ssh-action@v0.1.10
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
key: ${{ secrets.KEY }}
script: |
cd ${{ secrets.PATH }}
yarn prod payload
yarn prod astro

37
.gitignore vendored
View File

@ -1,3 +1,36 @@
data
# 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*
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.

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:21-alpine3.18
RUN npm i -g pnpm turbo
WORKDIR /app
COPY . .
RUN pnpm install
ENV PAYLOAD_CONFIG_PATH=src/payload.config.ts
ENV PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
RUN pnpm build
CMD ["pnpm", "serve"]

20
LICENSE
View File

@ -1,20 +0,0 @@
MIT License
Copyright (c) 2023 Max Schmidt
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

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.

127
README.md
View File

@ -1,86 +1,77 @@
# Astroad
Astroad is a pre-configured setup for Astro and Payloadcms, designed to make it easy for you to start building your website. With Astroad, you'll have a complete development environment that you can run locally using Docker. This setup simplifies the testing and development of your website before deploying it to a production environment.
## Important notes
### Adding dependencies
1. When adding a dedpendency to astro or payload, you must regenerate the yarn.lock file for the dev server toinstall the dependencies.
2. Regenerate the lock file:
`cd astro` or `cd payload`
`rm yarn.lock`
`yarn install`
3. Restart dev server `yarn dev`
## Dev server getting stuck
Sometimes the dev script gets stuck, for an unknown reason:
## Quickstart
```
[+] Running 3/0
✔ Container astroad-mongo Running 0.0s
✔ Container astroad-payload Running 0.0s
✔ Container astroad-astro Running
docker-compose -f docker-compose.yml up
```
Running `yarn stop` and then `yarn dev` should resolve the issue.
## About
This project was bootstrapped with a [turbopress](https://github.com/turbopress/turbopress)
### Useful docs
- [Astro docs: Integrating with PayloadCMS](https://docs.astro.build/en/guides/cms/payload/)
- [Astro docs: Deployment and SSR adapters](https://docs.astro.build/en/guides/deploy/)
- [Astro docs: On demand rendering adapters](https://docs.astro.build/en/guides/server-side-rendering/)
## 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
Before getting started with Astroad, make sure you have the necessary software installed:
Install `nodejs`, `pnpm` and `turborepo` on your local machine
- Docker
- Node.js
- Yarn
## Develop
## Configuration
Create a `.env` file in the root folder, you can use the `.env.example` file as an example
While there's no configuration necessary for local development, deployment via Github Workflows requires specific secrets and variables to be set.
I use `pnpm` for this project.
### Secrets:
Run the following command:
- `USER`: User on the server
- `HOST`: IP or URL of the server
- `KEY`: SSH KEY for connecting to the server
- `MONGODB_PW`: Password for MongoDB
- `MONGODB_USER`: User for MongoDB
- `PATH`: Path where the repository resides on the server
- `PAYLOAD_PORT`: Port at which Payload listens
- `PAYLOAD_SECRET`: String to encrypt Payload data
- `TOKEN`: Github Access Token for the webhook to trigger the payload.yml workflow and execute a new Astro build
```sh
pnpm installl
pnpm dev
```
### Variables:
By default, the payloadCMS will run on port 3000, and Astro will be served on port 3001.
- `ASTRO_HOST`: Hostdomain of the Frontend
- `PAYLOAD_HOST`: Hostdomain of the CMS
- `PAYLOAD_URL`: URL of the CMS
- `NAME`: Name of the Container and Project
## Build & Serve (NodeJs)
Please remember to set these secrets and variables in your repository settings to ensure a successful deployment through Github Workflows.
```sh
pnpm build
pnpm serve
```
Once the secrets and variables are set on GitHub, they will replace the existing ones in the `.env` file on the server during deployment. This is done by the push.yml workflow, which replaces the placeholders in the `.env` with the actual secrets and variables defined in the repository settings. Please ensure that the names of your secrets and variables match with the placeholders in the `.env` file.
## TypeScript support
## Getting started
To fully utilize the type safe features, manually generate the types for PayloadCMS by runng `pnpm generate:types`
To get started with Astroad, you'll need to have Docker and NPM || Yarn || PNPM installed on your machine.
Then, you can import the types easily as simple as
You have two options for getting the repository:
1. Use the 'Use this template' button on the Github repository. This will create a new repository in your Github account with the same directory structure and files as Astroad. After the new repository is created, you can clone it to your local machine.
1. Alternatively, you can directly clone the Astroad repository: git clone https://github.com/mooxl/astroad.git. If you choose this option, remember to change the origin of your remote repository to a new one to avoid pushing changes directly to the Astroad repository. This can be done with the command: git remote set-url origin https://github.com/USERNAME/REPOSITORY.git where USERNAME is your username and REPOSITORY is the name of your new repository.
Once you've cloned the repository or created your own from the template, follow these steps:
1. Change into the repository directory: `cd {newName}`
1. Start the containers: `yarn dev`
This will start up the Astro, Payloadcms and Mongo containers and make them available on your local machine. Astro will be served at http://localhost:3000 and the Payload will be available at http://localhost:3001.
## Development
The `docker-compose.yml` and `docker-compose-dev.yml` files includes everything you need to run the containers. The containers use the environment variables declared in the `.env` file and mounted volumes to store data persistently even after the containers are stopped and started.
## Deployment
Deployment is handled by a Github Actions Workflow on every push. It logs into the server via SSH, pulls or clones the latest version of the repository, and runs `yarn prod`.
Because Astro is completely static, a content change in the CMS must trigger a new build of Astro. Therefore, theres a `payload.yml` workflow that gets triggered by a webhook after every content change from Payload.
Ensure you have Traefik set up as a reverse proxy before deployment. The prod script will launch your site in a production-ready environment.
```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": "1.12.0",
"@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: "*",
});

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

@ -0,0 +1,39 @@
import express from "express";
import payload from "payload";
const app = express();
// Redirect root to Admin panel
app.get("/", (_, res) => {
res.redirect("/admin");
});
const PORT = Number(process.env.PAYLOAD_PORT) ?? 3000;
const HOST = process.env.PAYLOAD_HOST ?? 'localhost';
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(
PORT,
HOST,
() => {
console.log(`Server running at http://${HOST}:${PORT}/`);
}
);
};
start();

View File

@ -2,17 +2,21 @@
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react",
"paths": {
"@/*": ["./src/*", "./dist/*", "./dist/src/*"]
},
"jsx": "react"
"payload/generated-types": ["./types/payload.ts"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist", "build"],
"ts-node": {
"transpileOnly": true,
"require": ["tsconfig-paths/register"]
"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",
},
},
],
};

View File

@ -1,6 +1,5 @@
# build output
dist/
# generated types
.astro/
@ -14,8 +13,9 @@ yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# types
src/types.ts

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).

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

@ -0,0 +1,27 @@
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"),
// FIXME 3wc: seems to be ignored?
hostname: process.env.ASTRO_HOST ?? "0.0.0.0",
},
output: "server",
adapter: node({
mode: "standalone",
}),
vite: {
// FIXME 3wc: shouldn't need to hardcode this
define: {
"import.meta.env.PAYLOAD_PUBLIC_SERVER_URL": JSON.stringify(
"http://api:3001"
)
},
},
});

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,16 @@
import type { Page } from "@turbopress/api/types";
import { getPayloadCollection } from "./api.service";
export async function getPageCollection(query: any = null) {
// FIXME 3wc: shouldn't need to hardcode this?!
//const url = `${import.meta.env.PAYLOAD_PUBLIC_SERVER_URL}/api/pages`;
const url = `http://api:3001/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"]
}

View File

@ -1,3 +0,0 @@
node_modules
Dockerfile
README.md

View File

@ -1 +0,0 @@
PAYLOAD_URL=http://localhost:3001

View File

@ -1,11 +0,0 @@
{
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.astro",
"options": {
"parser": "astro"
}
}
]
}

View File

@ -1,22 +0,0 @@
FROM node:lts-alpine as base
WORKDIR /base
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
FROM base AS dev
ENV NODE_ENV=development
EXPOSE 3000
CMD ["yarn","dev"]
FROM base AS build
ENV NODE_ENV=production
WORKDIR /build
COPY --from=base /base .
ADD "https://random-uuid.deno.dev" skipcache
RUN yarn build
FROM nginx:stable AS prod
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=build /build/dist /usr/share/nginx/html
EXPOSE 3000

View File

@ -1,25 +0,0 @@
import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind";
import image from "@astrojs/image";
import sitemap from "@astrojs/sitemap";
import prefetch from "@astrojs/prefetch";
import react from "@astrojs/react";
// https://astro.build/config
export default defineConfig({
compressHTML: true,
server: { port: 3000 },
build: {
inlineStylesheets: "auto"
},
viewTransitions: true,
integrations: [tailwind({
config: {
applyBaseStyles: false
}
}), image({
serviceEntryPoint: "@astrojs/image/sharp"
}), prefetch({
selector: "a"
}), sitemap(), react()]
});

View File

@ -1,18 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.cjs",
"css": "@/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/utils",
"styles": "@/styles"
}
}

View File

@ -1,36 +0,0 @@
events {
worker_connections 1024;
}
http {
include mime.types;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
server {
listen 80;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ =404;
}
location /error_page.html {
internal;
}
location ~* \.(jpg|jpeg|png|gif|ico|css|js|htm|html)$ {
root /usr/share/nginx/html;
expires 30d;
add_header Pragma public;
add_header Cache-Control "public";
}
error_page 404 /error_page.html;
}
}

View File

@ -1,51 +0,0 @@
{
"name": "astroad",
"description": "Astroad - Astro",
"type": "module",
"version": "1.1",
"license": "MIT",
"scripts": {
"dev": "astro dev --host",
"build": "astro build"
},
"dependencies": {
"@astrojs/image": "0.18.0",
"@astrojs/prefetch": "0.4.1",
"@astrojs/react": "^3.1.1",
"@astrojs/sitemap": "3.1.2",
"@astrojs/tailwind": "5.1.0",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@tanstack/react-query": "^5.28.14",
"@tanstack/react-query-devtools": "^5.29.0",
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.23",
"astro": "^4.5.13",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"css-select": "5.1.0",
"human-id": "^4.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.364.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.2",
"react-leaflet": "^4.2.1",
"sharp": "^0.32.6",
"slate-serializers": "0.4.1",
"tailwind-merge": "^2.2.2",
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.3",
"zod": "^3.22.4"
},
"devDependencies": {
"prettier": "^3.0.2",
"prettier-plugin-astro": "^0.11.1",
"prettier-plugin-tailwindcss": "^0.5.4"
}
}

View File

@ -1,5 +0,0 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M50.0529 99.6475C22.7705 99.7533 -0.0352444 77.3352 0.422986 49.2422C0.669726 35.6715 5.63976 24.0395 15.3331 14.5224C24.9559 5.04054 36.6937 0.281993 50.1586 0.317242C63.835 0.317242 75.6433 5.28728 85.3014 15.0159C94.9947 24.7797 99.7533 36.7289 99.718 50.4759C99.6828 59.2527 97.3916 67.5009 92.915 75.1146C88.4385 82.7282 82.3757 88.7557 74.7268 93.1618C67.1132 97.4974 58.865 99.6475 50.0529 99.6475Z" fill="#CFADC5"/>
<path d="M42.4392 67.2541C41.9105 67.2541 41.417 67.2541 40.8883 67.2541C40.6768 67.2541 40.5358 67.1837 40.3948 67.0074C37.7864 63.6236 35.2133 60.2749 32.6049 56.8911C31.7942 55.8336 31.4769 55.8336 30.8424 56.9968C30.7367 57.1731 30.7014 57.3846 30.7367 57.5608C30.8424 60.2397 30.7719 62.8833 30.7719 65.5622C30.7719 65.9852 30.7367 66.4082 30.7367 66.8312C30.7367 67.1484 30.631 67.2541 30.3137 67.2541C28.4808 67.2541 26.6479 67.2541 24.815 67.2541C24.4977 67.2541 24.392 67.1837 24.3567 66.8312C24.2862 66.1967 24.2862 65.5975 24.2862 64.963C24.2862 54.5647 24.2862 44.1664 24.2862 33.7681C24.2862 29.8202 24.2862 25.9077 24.2862 21.9598C24.2862 21.8541 24.2862 21.7483 24.2862 21.6073C24.2862 20.6556 24.674 20.3031 25.6257 20.3736C26.6126 20.4794 27.6348 20.4441 28.6218 20.4441C28.9743 20.4441 29.362 20.3736 29.7145 20.3736C30.4547 20.3736 30.8072 20.6556 30.8072 21.3958C30.8072 23.4755 30.8072 25.5552 30.8072 27.5996C30.8072 33.2041 30.8072 38.8086 30.8072 44.3779C30.8072 45.4353 30.8424 46.528 30.7719 47.5855C30.7719 47.8675 30.7015 48.2552 31.0187 48.3962C31.2654 48.5019 31.4769 48.1847 31.6884 47.9732C33.2393 46.387 34.755 44.8009 36.306 43.2147C37.1519 42.3335 38.0331 41.4522 38.8791 40.571C39.2668 40.1481 39.7251 39.9366 40.289 39.9718C41.9105 40.0423 43.5672 40.0071 45.1886 40.0071C45.6821 40.0071 46.2108 39.9718 46.7043 39.9718C46.81 39.9718 46.9158 39.9718 46.9863 39.9718C47.2682 40.0071 47.6207 39.9718 47.7265 40.289C47.797 40.5005 47.4797 40.6415 47.3035 40.8178C45.4001 42.6507 43.4967 44.5189 41.5932 46.3518C40.148 47.7617 38.6676 49.1364 37.2224 50.5111C36.5879 51.1103 36.5175 51.5686 37.0814 52.2735C40.289 56.3271 43.4967 60.3807 46.7043 64.4343C47.3035 65.1745 47.3035 65.1745 48.1142 64.6105C48.1847 64.5753 48.22 64.54 48.2905 64.4695C48.6077 64.2228 48.8544 64.0465 49.3127 64.1875C50.0529 64.399 50.8283 63.9408 51.0751 63.1301C51.3218 62.3899 51.8505 61.9316 52.4145 61.5086C52.8375 61.1914 53.2605 60.9094 53.613 60.5217C53.895 60.2749 54.1065 59.993 54.2474 59.6405C54.4237 59.2527 54.6704 58.9355 55.0229 58.724C55.1992 58.6535 55.3049 58.5125 55.4106 58.3363C55.6574 57.9485 55.9394 57.6313 56.2566 57.3493C56.7148 56.9616 57.1026 56.5386 57.2788 55.9394C57.3493 55.7279 57.4903 55.5869 57.6313 55.4106C58.0543 54.9172 58.5125 54.4589 58.865 53.9302C59.0412 53.7187 59.2175 53.472 59.4642 53.331C60.1339 52.9433 60.4864 52.344 60.7332 51.6391C61.1562 50.4759 61.3676 49.2774 61.6496 48.079C61.9669 46.8805 62.7071 46.0698 63.976 45.7526C64.3638 45.6468 64.7868 45.6821 65.1392 45.8583C66.5139 46.528 67.9239 47.1625 69.1576 48.079C70.4265 49.0307 71.0962 50.4054 71.2725 51.9915C71.343 52.6613 71.1667 53.331 70.6028 53.8245C70.2855 54.0712 70.1093 54.4237 70.0035 54.8114C69.933 55.1992 69.792 55.5516 69.51 55.8336C68.7698 56.6443 68.5936 57.6666 68.5231 58.6888C68.4526 59.8872 68.2764 61.0504 67.9591 62.2136C67.7124 63.0596 67.6066 63.9408 67.9944 64.7868C68.1001 65.0335 68.2411 65.245 68.4173 65.4565C69.1928 66.3024 70.1445 66.2672 70.8495 65.386C71.202 64.9277 71.484 64.399 72.0832 64.2228C72.2242 64.1875 72.3299 63.9408 72.4004 63.7998C72.8234 63.1301 73.2464 62.4251 73.6694 61.7554C73.9866 61.2619 74.2686 60.7684 74.7268 60.3807C75.0793 60.0635 75.2556 59.6052 75.4671 59.1822C75.6433 58.8298 75.7843 58.5125 75.9605 58.16C76.2425 57.5256 76.8065 57.2788 77.441 57.1731C77.7229 57.1378 77.8639 57.3141 78.0049 57.5256C78.7452 58.9355 79.2034 60.3807 79.4854 61.9316C79.6969 63.1301 79.6969 64.3638 79.6616 65.5975C79.6616 65.95 79.5559 66.2672 79.3444 66.5492C78.7452 67.3246 78.1459 68.1354 77.5115 68.9461C77.4762 69.0166 77.441 69.0518 77.3705 69.0871C76.6655 69.3691 76.1368 69.933 75.5728 70.3913C75.1498 70.7085 74.7268 70.9905 74.1629 71.0257C73.7399 71.061 73.3521 71.202 72.9644 71.4135C72.4004 71.6955 71.766 71.7307 71.1667 71.7307C69.4396 71.6602 67.7476 71.343 66.1262 70.6733C64.822 70.1445 63.7998 69.2633 62.9538 68.1354C61.7906 66.6197 61.7906 66.6197 61.6849 64.5753C61.6496 64.117 61.5791 63.694 61.3676 63.2711C61.0504 62.7071 60.4864 62.5661 59.9577 62.9538C59.6405 63.1653 59.429 63.4473 59.2527 63.7998C59.006 64.3285 58.6535 64.7515 58.2305 65.1392C56.7853 66.4434 55.6574 67.9591 54.3884 69.4043C53.5072 70.356 52.5203 71.202 51.4981 72.0127C51.0398 72.4004 50.4054 72.4709 49.8414 72.5414C48.9954 72.6472 48.1847 72.9292 47.515 73.4579C45.8583 74.7268 44.1311 74.5858 42.404 73.6341C41.417 73.1054 40.571 72.4709 39.8661 71.5545C39.2668 70.779 39.1258 69.8978 39.3726 68.9461C39.5136 68.3821 39.8661 68.0296 40.43 67.8886C41.135 67.7124 41.8047 67.5009 42.5097 67.2894C42.4392 67.2894 42.4392 67.2894 42.4392 67.2541Z" fill="#84285E"/>
<path d="M73.7046 36.5175C73.7046 37.5397 73.6694 38.4561 73.4579 39.3726C73.3874 39.7603 73.2111 40.1128 72.9644 40.43C72.2947 41.3818 71.3782 42.122 70.497 42.8974C70.4618 42.9327 70.3913 42.9679 70.356 43.0032C68.8051 43.4262 67.2541 43.8139 65.7737 42.7564C64.4695 42.545 64.0113 41.417 63.4826 40.43C62.9186 39.4431 62.7776 38.3504 62.7776 37.2577C62.7776 36.0945 63.0948 35.0018 63.4121 33.9091C63.694 32.9574 64.4343 32.3229 64.963 31.5122C64.963 31.5122 64.963 31.4769 64.9982 31.4769C65.527 31.3359 65.9147 30.8777 66.5139 30.8425C67.0427 30.8072 67.5714 30.7367 68.1001 30.6662C70.1093 30.3842 71.5545 31.2654 72.6119 32.9221C73.0349 33.5566 73.3169 34.2263 73.5989 34.9313C73.8104 35.4953 73.6694 36.0592 73.7046 36.5175Z" fill="#84285E"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Some files were not shown because too many files have changed in this diff Show More