Compare commits
32 Commits
Author | SHA1 | Date | |
---|---|---|---|
47777acd74
|
|||
32d98f4bc0
|
|||
ef60832bc2
|
|||
4f782560de
|
|||
979cf7320e
|
|||
1f9f11e1be
|
|||
1d183c37f8
|
|||
3aeec4d23e
|
|||
debfcf7226
|
|||
15bbc27238
|
|||
16bd44c599
|
|||
d13b16c032
|
|||
d144f7385b
|
|||
0283c293ef
|
|||
8d75849c55 | |||
58b44dddad | |||
c9de531389 | |||
d48cfe12f2 | |||
3a9cc20f86 | |||
54a34ef5ee | |||
f097d7761d | |||
03717113f4 | |||
284a4c5520 | |||
f03faabbee | |||
353fb3899e | |||
62ce137bcb | |||
f8987b08da | |||
cbf75acbeb | |||
7973663b2a | |||
6540329f36 | |||
bac5b5fe48 | |||
f3f75d3e57 |
0
.dockerignore
Normal file → Executable file
0
.dockerignore
Normal file → Executable file
0
.editorconfig
Normal file → Executable file
0
.editorconfig
Normal file → Executable file
@ -1,35 +0,0 @@
|
|||||||
# These environment variables will be used by default if you do not create any
|
|
||||||
# yourself in .env. This file should be safe to check into your version control
|
|
||||||
# system. Any custom values should go in .env and .env should *not* be checked
|
|
||||||
# into version control.
|
|
||||||
|
|
||||||
# location of the test database for api service scenarios (defaults to ./.redwood/test.db if not set)
|
|
||||||
# TEST_DATABASE_URL=file:./.redwood/test.db
|
|
||||||
|
|
||||||
# disables Prisma CLI update notifier
|
|
||||||
PRISMA_HIDE_UPDATE_MESSAGE=true
|
|
||||||
|
|
||||||
# Option to override the current environment's default api-side log level
|
|
||||||
# See: https://redwoodjs.com/docs/logger for level options, defaults to "trace" otherwise.
|
|
||||||
# Most applications want "debug" or "info" during dev, "trace" when you have issues and "warn" in production.
|
|
||||||
# Ordered by how verbose they are: trace | debug | info | warn | error | silent
|
|
||||||
# LOG_LEVEL=debug
|
|
||||||
|
|
||||||
FIRST_NAME=firstname
|
|
||||||
LAST_NAME=lastname
|
|
||||||
|
|
||||||
GMAIL=example@gmail.com
|
|
||||||
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
|
|
||||||
|
|
||||||
DOMAIN=example.com
|
|
||||||
API_DOMAIN=api.example.com
|
|
||||||
|
|
||||||
# Must not end with "/"
|
|
||||||
ADDRESS_PROD=https://portfolio.example.com
|
|
||||||
ADDRESS_DEV=http://localhost:8910
|
|
||||||
API_ADDRESS_PROD=https://api-portfolio.example.com
|
|
||||||
API_ADDRESS_DEV=http://localhost:8911
|
|
||||||
|
|
||||||
MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
|
||||||
|
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/rw_portfolio
|
|
15
.env.example
Normal file → Executable file
15
.env.example
Normal file → Executable file
@ -6,8 +6,19 @@
|
|||||||
FIRST_NAME=firstname
|
FIRST_NAME=firstname
|
||||||
LAST_NAME=lastname
|
LAST_NAME=lastname
|
||||||
|
|
||||||
GMAIL=example@gmail.com
|
DEFAULT_THEME=light
|
||||||
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
|
|
||||||
|
COUNTRY=US
|
||||||
|
STATE=New York
|
||||||
|
CITY=Manhattan
|
||||||
|
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_SECURE=true
|
||||||
|
SMTP_USER=noreply@example.com
|
||||||
|
EMAIL_FROM=noreply@example.com
|
||||||
|
EMAIL_TO=email@example.com
|
||||||
|
SMTP_PASSWORD=password
|
||||||
|
|
||||||
DOMAIN=example.com
|
DOMAIN=example.com
|
||||||
API_DOMAIN=api.example.com
|
API_DOMAIN=api.example.com
|
||||||
|
17
.gitea/workflows/ci.yml
Normal file → Executable file
17
.gitea/workflows/ci.yml
Normal file → Executable file
@ -10,20 +10,27 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Publish Docker Image
|
name: Publish Docker Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Registry
|
- name: Login to Registry
|
||||||
run: echo "${{ secrets.ACCESS_TOKEN }}" | docker login git.altaiar.dev -u "${{ secrets.USERNAME }}" --password-stdin
|
run: echo "${{ secrets.ACCESS_TOKEN }}" \
|
||||||
|
| docker login git.altaiar.dev -u "${{ secrets.USERNAME }}" --password-stdin
|
||||||
|
|
||||||
- name: Build & Tag Image
|
- name: Build & Tag Image
|
||||||
run: |
|
run: |
|
||||||
docker build -t git.altaiar.dev/${{ gitea.repository }}:${{ gitea.ref_name }} .
|
docker build \
|
||||||
docker tag git.altaiar.dev/${{ gitea.repository }}:${{ gitea.ref_name }} git.altaiar.dev/${{ gitea.repository }}:latest
|
--build-arg APP_VERSION=${{ gitea.ref_name }} \
|
||||||
|
--label org.opencontainers.image.version=${{ gitea.ref_name }} \
|
||||||
|
-t git.altaiar.dev/${{ gitea.repository }}:${{ gitea.ref_name }} .
|
||||||
|
docker tag \
|
||||||
|
git.altaiar.dev/${{ gitea.repository }}:${{ gitea.ref_name }} \
|
||||||
|
git.altaiar.dev/${{ gitea.repository }}:latest
|
||||||
|
|
||||||
- name: Push Images
|
- name: Push Images
|
||||||
run: |
|
run: |
|
||||||
|
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.redwood/README.md
Normal file → Executable file
0
.redwood/README.md
Normal file → Executable file
0
.vscode/extensions.json
vendored
Normal file → Executable file
0
.vscode/extensions.json
vendored
Normal file → Executable file
0
.vscode/launch.json
vendored
Normal file → Executable file
0
.vscode/launch.json
vendored
Normal file → Executable file
0
.vscode/settings.json
vendored
Normal file → Executable file
0
.vscode/settings.json
vendored
Normal file → Executable file
0
.vscode/tasks.json
vendored
Normal file → Executable file
0
.vscode/tasks.json
vendored
Normal file → Executable file
0
.yarnrc.yml
Normal file → Executable file
0
.yarnrc.yml
Normal file → Executable file
100
Dockerfile
Normal file → Executable file
100
Dockerfile
Normal file → Executable file
@ -1,15 +1,9 @@
|
|||||||
# base
|
FROM node:lts-alpine AS base
|
||||||
# ----
|
|
||||||
FROM node:20-bookworm-slim as base
|
|
||||||
|
|
||||||
RUN corepack enable
|
ARG APP_VERSION=dev
|
||||||
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
# We tried to make the Dockerfile as lean as possible. In some cases, that means we excluded a dependency your project needs.
|
RUN apk add --no-cache openssl && corepack enable
|
||||||
# By far the most common is Python. If you're running into build errors because `python3` isn't available,
|
|
||||||
# add `python3 make gcc \` before the `openssl \` line below and in other stages as necessary:
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
openssl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
WORKDIR /home/node/app
|
WORKDIR /home/node/app
|
||||||
@ -29,61 +23,68 @@ RUN --mount=type=cache,target=/home/node/.yarn/berry/cache,uid=1000 \
|
|||||||
|
|
||||||
COPY --chown=node:node redwood.toml .
|
COPY --chown=node:node redwood.toml .
|
||||||
COPY --chown=node:node graphql.config.js .
|
COPY --chown=node:node graphql.config.js .
|
||||||
COPY --chown=node:node .env.defaults .env.defaults
|
|
||||||
|
|
||||||
# api build
|
FROM base AS api_build
|
||||||
# ---------
|
|
||||||
FROM base as api_build
|
|
||||||
|
|
||||||
# If your api side build relies on build-time environment variables,
|
|
||||||
# specify them here as ARGs. (But don't put secrets in your Dockerfile!)
|
|
||||||
|
|
||||||
ARG ADDRESS_PROD
|
ARG ADDRESS_PROD
|
||||||
ARG ADDRESS_DEV
|
ARG ADDRESS_DEV
|
||||||
ARG DOMAIN
|
ARG DOMAIN
|
||||||
ARG API_DOMAIN
|
ARG API_DOMAIN
|
||||||
ARG MAX_HTTP_CONNECTIONS_PER_MINUTE
|
ARG MAX_HTTP_CONNECTIONS_PER_MINUTE
|
||||||
ARG GMAIL
|
ARG SMTP_HOST
|
||||||
ARG GMAIL_SMTP_PASSWORD
|
ARG SMTP_PORT
|
||||||
|
ARG SMTP_SECURE
|
||||||
|
ARG SMTP_USER
|
||||||
|
ARG SMTP_PASSWORD
|
||||||
|
ARG EMAIL_FROM
|
||||||
|
ARG EMAIL_TO
|
||||||
ARG FIRST_NAME
|
ARG FIRST_NAME
|
||||||
ARG LAST_NAME
|
ARG LAST_NAME
|
||||||
|
ARG APP_VERSION
|
||||||
|
|
||||||
COPY --chown=node:node api api
|
COPY --chown=node:node api api
|
||||||
RUN yarn rw build api
|
RUN yarn rw build api
|
||||||
|
|
||||||
# web prerender build
|
FROM api_build AS web_build_with_prerender
|
||||||
# -------------------
|
|
||||||
FROM api_build as web_build_with_prerender
|
|
||||||
|
|
||||||
ARG FIRST_NAME
|
ARG FIRST_NAME
|
||||||
ARG LAST_NAME
|
ARG LAST_NAME
|
||||||
|
ARG COUNTRY
|
||||||
|
ARG STATE
|
||||||
|
ARG CITY
|
||||||
|
ARG DEFAULT_THEME
|
||||||
ARG API_ADDRESS_PROD
|
ARG API_ADDRESS_PROD
|
||||||
ARG API_ADDRESS_DEV
|
ARG API_ADDRESS_DEV
|
||||||
|
ARG APP_VERSION
|
||||||
|
|
||||||
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
COPY --chown=node:node web web
|
COPY --chown=node:node web web
|
||||||
RUN yarn rw build web
|
RUN yarn rw build web
|
||||||
|
|
||||||
# web build
|
FROM base AS web_build
|
||||||
# ---------
|
|
||||||
FROM base as web_build
|
|
||||||
|
|
||||||
ARG FIRST_NAME
|
ARG FIRST_NAME
|
||||||
ARG LAST_NAME
|
ARG LAST_NAME
|
||||||
|
ARG COUNTRY
|
||||||
|
ARG STATE
|
||||||
|
ARG CITY
|
||||||
|
ARG DEFAULT_THEME
|
||||||
ARG API_ADDRESS_PROD
|
ARG API_ADDRESS_PROD
|
||||||
ARG API_ADDRESS_DEV
|
ARG API_ADDRESS_DEV
|
||||||
|
ARG APP_VERSION
|
||||||
|
|
||||||
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
COPY --chown=node:node web web
|
COPY --chown=node:node web web
|
||||||
RUN yarn rw build web --no-prerender
|
RUN yarn rw build web --no-prerender
|
||||||
|
|
||||||
# api serve
|
FROM node:lts-alpine AS api_serve
|
||||||
# ---------
|
|
||||||
FROM node:20-bookworm-slim as api_serve
|
|
||||||
|
|
||||||
RUN corepack enable
|
RUN apk add --no-cache openssl && corepack enable
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN mkdir -p /home/node/app/api/files_prod \
|
||||||
openssl \
|
&& chown -R node:node /home/node/app/api/files_prod
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
WORKDIR /home/node/app
|
WORKDIR /home/node/app
|
||||||
@ -102,26 +103,19 @@ RUN --mount=type=cache,target=/home/node/.yarn/berry/cache,uid=1000 \
|
|||||||
|
|
||||||
COPY --chown=node:node redwood.toml .
|
COPY --chown=node:node redwood.toml .
|
||||||
COPY --chown=node:node graphql.config.js .
|
COPY --chown=node:node graphql.config.js .
|
||||||
COPY --chown=node:node .env.defaults .env.defaults
|
|
||||||
|
|
||||||
COPY --chown=node:node --from=api_build /home/node/app/api/dist /home/node/app/api/dist
|
COPY --chown=node:node --from=api_build /home/node/app/api/dist /home/node/app/api/dist
|
||||||
COPY --chown=node:node --from=api_build /home/node/app/api/db /home/node/app/api/db
|
COPY --chown=node:node --from=api_build /home/node/app/api/db /home/node/app/api/db
|
||||||
COPY --chown=node:node --from=api_build /home/node/app/node_modules/.prisma /home/node/app/node_modules/.prisma
|
COPY --chown=node:node --from=api_build /home/node/app/node_modules/.prisma /home/node/app/node_modules/.prisma
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ARG APP_VERSION
|
||||||
|
|
||||||
# default api serve command
|
ENV NODE_ENV=production
|
||||||
# ---------
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
# If you are using a custom server file, you must use the following
|
|
||||||
# command to launch your server instead of the default api-server below.
|
|
||||||
# This is important if you intend to configure GraphQL to use Realtime.
|
|
||||||
|
|
||||||
CMD [ "./api/dist/server.js" ]
|
CMD [ "./api/dist/server.js" ]
|
||||||
# CMD [ "node_modules/.bin/rw-server", "api" ]
|
|
||||||
|
|
||||||
# web serve
|
FROM node:lts-alpine AS web_serve
|
||||||
# ---------
|
|
||||||
FROM node:20-bookworm-slim as web_serve
|
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
@ -142,30 +136,18 @@ RUN --mount=type=cache,target=/home/node/.yarn/berry/cache,uid=1000 \
|
|||||||
|
|
||||||
COPY --chown=node:node redwood.toml .
|
COPY --chown=node:node redwood.toml .
|
||||||
COPY --chown=node:node graphql.config.js .
|
COPY --chown=node:node graphql.config.js .
|
||||||
COPY --chown=node:node .env.defaults .env.defaults
|
|
||||||
|
|
||||||
COPY --chown=node:node --from=web_build /home/node/app/web/dist /home/node/app/web/dist
|
COPY --chown=node:node --from=web_build /home/node/app/web/dist /home/node/app/web/dist
|
||||||
|
|
||||||
|
ARG APP_VERSION
|
||||||
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
API_PROXY_TARGET=http://api:8911
|
API_PROXY_TARGET=http://api:8911
|
||||||
|
|
||||||
# We use the shell form here for variable expansion.
|
|
||||||
CMD "node_modules/.bin/rw-web-server" "--api-proxy-target" "$API_PROXY_TARGET"
|
CMD "node_modules/.bin/rw-web-server" "--api-proxy-target" "$API_PROXY_TARGET"
|
||||||
|
|
||||||
# console
|
FROM base AS console
|
||||||
# -------
|
|
||||||
FROM base as console
|
|
||||||
|
|
||||||
# To add more packages:
|
|
||||||
#
|
|
||||||
# ```
|
|
||||||
# USER root
|
|
||||||
#
|
|
||||||
# RUN apt-get update && apt-get install -y \
|
|
||||||
# curl
|
|
||||||
#
|
|
||||||
# USER node
|
|
||||||
# ```
|
|
||||||
|
|
||||||
COPY --chown=node:node api api
|
COPY --chown=node:node api api
|
||||||
COPY --chown=node:node web web
|
COPY --chown=node:node web web
|
||||||
|
53
README.md
Normal file → Executable file
53
README.md
Normal file → Executable file
@ -6,10 +6,8 @@ Create two A records, one for the web side of the website and one for the api si
|
|||||||
- It doesn't matter what reverse proxy you use (Nginx, Apache, Traefik, Caddy, etc)
|
- It doesn't matter what reverse proxy you use (Nginx, Apache, Traefik, Caddy, etc)
|
||||||
1. Point the web domain to the web port (default: 8910)
|
1. Point the web domain to the web port (default: 8910)
|
||||||
2. Point the api domain to the api port (default: 8911)
|
2. Point the api domain to the api port (default: 8911)
|
||||||
### Gmail App Password
|
### SMTP
|
||||||
1. Go to your Google [account dashboard](https://myaccount.google.com)
|
You will need credentials to authorize sending Email, instructions vary depending on provider (Gmail, Hotmail, etc).
|
||||||
2. Go to Security > 2-Step Verification > App Passwords > Create a new app password
|
|
||||||
3. Copy the 16 character password
|
|
||||||
### [Docker Compose](./docker-compose.yml)
|
### [Docker Compose](./docker-compose.yml)
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
@ -18,26 +16,39 @@ services:
|
|||||||
portfolio:
|
portfolio:
|
||||||
container_name: portfolio
|
container_name: portfolio
|
||||||
image: git.altaiar.dev/ahmed/portfolio:latest
|
image: git.altaiar.dev/ahmed/portfolio:latest
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- API_PROXY_TARGET=http://localhost:8911
|
- API_PROXY_TARGET=http://localhost:8911
|
||||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||||
- DATABASE_URL=postgresql://redwood:redwood@db:5432/portfolio
|
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||||
- FIRST_NAME=first name # Your first name
|
- FIRST_NAME=first name # Your first name
|
||||||
- LAST_NAME=lastname # Your last name
|
- LAST_NAME=lastname # Your last name
|
||||||
- GMAIL=example@gmail.com # The Gmail address associated with the app password created earlier
|
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||||
- GMAIL_SMTP_PASSWORD=aaaa bbbb cccc dddd # The app password created earlier
|
- STATE=New York # Optional, state or province
|
||||||
|
- CITY=Manhattan # Optional
|
||||||
|
- DEFAULT_THEME=light # 'light' or 'dark'
|
||||||
|
- SMTP_HOST=smtp.example.com
|
||||||
|
- SMTP_PORT=465
|
||||||
|
- SMTP_SECURE=true
|
||||||
|
- SMTP_USER=noreply@example.com
|
||||||
|
- EMAIL_FROM=noreply@example.com
|
||||||
|
- EMAIL_TO=email@example.com
|
||||||
|
- SMTP_PASSWORD=password
|
||||||
- DOMAIN=portfolio.example.com
|
- DOMAIN=portfolio.example.com
|
||||||
- API_DOMAIN=api.portfolio.example.com
|
- API_DOMAIN=api.portfolio.example.com
|
||||||
# Careful, addresses below must not end with a '/'
|
# Careful, addresses below must not end with a '/'
|
||||||
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
||||||
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
||||||
ports:
|
ports:
|
||||||
- '8910:8910' # Web
|
- 8910:8910 # Web
|
||||||
- '8911:8911' # API
|
- 8911:8911 # API
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- files:/home/node/app/api/files_prod
|
||||||
command: >
|
command: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
yarn rw build &&
|
yarn rw build &&
|
||||||
@ -45,28 +56,32 @@ services:
|
|||||||
yarn rw prisma db seed &&
|
yarn rw prisma db seed &&
|
||||||
yarn rw serve"
|
yarn rw serve"
|
||||||
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
container_name: portfolio-db
|
container_name: portfolio-db
|
||||||
image: postgres:16-bookworm
|
image: postgres:16-bookworm
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: redwood
|
- POSTGRES_USER=redwood
|
||||||
POSTGRES_PASSWORD: redwood
|
- POSTGRES_PASSWORD=changeme # Change to a more secure password
|
||||||
POSTGRES_DB: portfolio
|
- POSTGRES_DB=portfolio
|
||||||
ports:
|
restart: unless-stopped
|
||||||
- '5432:5432'
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d DATABASE_URL"] # Replace DATABASE_URL with the database URL from the portfolio container
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
volumes:
|
volumes:
|
||||||
- postgres:/var/lib/postgresql/data
|
- postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres:
|
postgres:
|
||||||
|
files: # For persistent file storage across upgrades
|
||||||
```
|
```
|
||||||
## Logging In
|
## Logging In
|
||||||
- Once the container is up and running, head to `/login` (`https://portfolio.example.com/login`), default credentials are below
|
- Once the container is up and running, head to `/login` (`https://portfolio.example.com/login`), default credentials are below
|
||||||
- If you would like to change the password, head to `/forgot-password` (`https://portfolio.example.com/forgot-password`), the username is `admin`
|
- If you would like to change the password, head to `/forgot-password` (`https://portfolio.example.com/forgot-password`), the username is `admin`
|
||||||
- If you correctly set up the Gmail app password, you should receive an email from yourself
|
- If you correctly configured [SMTP](#smtp), you should receive an Email from [`EMAIL_FROM`](#docker-compose) to [`EMAIL_TO`](#docker-compose)
|
||||||
- It contains the link needed to change your password
|
- The Email contains the link needed to change your password
|
||||||
### Default Credentials
|
### Default Credentials
|
||||||
**Username:** `admin`
|
**Username:** `admin`
|
||||||
|
|
||||||
**Password:** [`GMAIL_SMTP_PASSWORD`](#gmail-app-password)
|
**Password:** [`SMTP_PASSWORD`](#docker-compose)
|
||||||
|
0
api/db/migrations/20240810184713_user/migration.sql
Normal file → Executable file
0
api/db/migrations/20240810184713_user/migration.sql
Normal file → Executable file
0
api/db/migrations/20240819213158_social/migration.sql
Normal file → Executable file
0
api/db/migrations/20240819213158_social/migration.sql
Normal file → Executable file
0
api/db/migrations/20240821020900_portrait/migration.sql
Normal file → Executable file
0
api/db/migrations/20240821020900_portrait/migration.sql
Normal file → Executable file
0
api/db/migrations/20240824001030_project/migration.sql
Normal file → Executable file
0
api/db/migrations/20240824001030_project/migration.sql
Normal file → Executable file
0
api/db/migrations/20240921164727_project_date_is_mandatory_default_today_12_am/migration.sql
Normal file → Executable file
0
api/db/migrations/20240921164727_project_date_is_mandatory_default_today_12_am/migration.sql
Normal file → Executable file
0
api/db/migrations/20240921181721_more_socials/migration.sql
Normal file → Executable file
0
api/db/migrations/20240921181721_more_socials/migration.sql
Normal file → Executable file
0
api/db/migrations/20240921183021_even_more_socials/migration.sql
Normal file → Executable file
0
api/db/migrations/20240921183021_even_more_socials/migration.sql
Normal file → Executable file
0
api/db/migrations/20240927031102_/migration.sql
Normal file → Executable file
0
api/db/migrations/20240927031102_/migration.sql
Normal file → Executable file
0
api/db/migrations/20240929164343_/migration.sql
Normal file → Executable file
0
api/db/migrations/20240929164343_/migration.sql
Normal file → Executable file
0
api/db/migrations/20241001005227_title_and_resume/migration.sql
Normal file → Executable file
0
api/db/migrations/20241001005227_title_and_resume/migration.sql
Normal file → Executable file
0
api/db/migrations/20241005014130_/migration.sql
Normal file → Executable file
0
api/db/migrations/20241005014130_/migration.sql
Normal file → Executable file
0
api/db/migrations/20241015183037_matrix/migration.sql
Normal file → Executable file
0
api/db/migrations/20241015183037_matrix/migration.sql
Normal file → Executable file
0
api/db/migrations/migration_lock.toml
Normal file → Executable file
0
api/db/migrations/migration_lock.toml
Normal file → Executable file
0
api/db/schema.prisma
Normal file → Executable file
0
api/db/schema.prisma
Normal file → Executable file
0
api/jest.config.js
Normal file → Executable file
0
api/jest.config.js
Normal file → Executable file
13
api/package.json
Normal file → Executable file
13
api/package.json
Normal file → Executable file
@ -5,12 +5,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^9.0.1",
|
||||||
"@fastify/rate-limit": "^9.1.0",
|
"@fastify/rate-limit": "^9.1.0",
|
||||||
"@redwoodjs/api": "8.4.0",
|
"@redwoodjs/api": "8.6.1",
|
||||||
"@redwoodjs/api-server": "8.4.0",
|
"@redwoodjs/api-server": "8.6.1",
|
||||||
"@redwoodjs/auth-dbauth-api": "8.4.0",
|
"@redwoodjs/auth-dbauth-api": "8.6.1",
|
||||||
"@redwoodjs/graphql-server": "8.4.0",
|
"@redwoodjs/graphql-server": "8.6.1",
|
||||||
"@tus/file-store": "^1.4.0",
|
"@tus/file-store": "1.4.0",
|
||||||
"@tus/server": "^1.7.0",
|
"@tus/server": "1.7.0",
|
||||||
|
"countries-list": "^3.1.1",
|
||||||
"graphql-scalars": "^1.23.0",
|
"graphql-scalars": "^1.23.0",
|
||||||
"nodemailer": "^6.9.14"
|
"nodemailer": "^6.9.14"
|
||||||
},
|
},
|
||||||
|
0
api/quick-lint-js.config
Normal file → Executable file
0
api/quick-lint-js.config
Normal file → Executable file
0
api/src/directives/requireAuth/requireAuth.ts
Normal file → Executable file
0
api/src/directives/requireAuth/requireAuth.ts
Normal file → Executable file
0
api/src/directives/skipAuth/skipAuth.ts
Normal file → Executable file
0
api/src/directives/skipAuth/skipAuth.ts
Normal file → Executable file
0
api/src/functions/auth.ts
Normal file → Executable file
0
api/src/functions/auth.ts
Normal file → Executable file
0
api/src/functions/graphql.ts
Normal file → Executable file
0
api/src/functions/graphql.ts
Normal file → Executable file
0
api/src/graphql/.keep
Normal file → Executable file
0
api/src/graphql/.keep
Normal file → Executable file
0
api/src/graphql/portrait.sdl.ts
Normal file → Executable file
0
api/src/graphql/portrait.sdl.ts
Normal file → Executable file
0
api/src/graphql/projects.sdl.ts
Normal file → Executable file
0
api/src/graphql/projects.sdl.ts
Normal file → Executable file
0
api/src/graphql/resume.sdl.ts
Normal file → Executable file
0
api/src/graphql/resume.sdl.ts
Normal file → Executable file
0
api/src/graphql/scalars.sdl.ts
Normal file → Executable file
0
api/src/graphql/scalars.sdl.ts
Normal file → Executable file
0
api/src/graphql/socials.sdl.ts
Normal file → Executable file
0
api/src/graphql/socials.sdl.ts
Normal file → Executable file
0
api/src/graphql/tags.sdl.ts
Normal file → Executable file
0
api/src/graphql/tags.sdl.ts
Normal file → Executable file
0
api/src/graphql/title.sdl.ts
Normal file → Executable file
0
api/src/graphql/title.sdl.ts
Normal file → Executable file
0
api/src/lib/auth.ts
Normal file → Executable file
0
api/src/lib/auth.ts
Normal file → Executable file
16
api/src/lib/cors.ts
Normal file → Executable file
16
api/src/lib/cors.ts
Normal file → Executable file
@ -2,10 +2,17 @@ import type { FastifyReply } from 'fastify'
|
|||||||
|
|
||||||
import { isProduction } from '@redwoodjs/api/logger'
|
import { isProduction } from '@redwoodjs/api/logger'
|
||||||
|
|
||||||
export const setCorsHeaders = (res: FastifyReply) => {
|
export const setCorsHeaders = (
|
||||||
|
res: FastifyReply,
|
||||||
|
isPublic: boolean = false
|
||||||
|
) => {
|
||||||
res.raw.setHeader(
|
res.raw.setHeader(
|
||||||
'Access-Control-Allow-Origin',
|
'Access-Control-Allow-Origin',
|
||||||
isProduction ? process.env.ADDRESS_PROD : process.env.ADDRESS_DEV
|
isPublic
|
||||||
|
? '*'
|
||||||
|
: isProduction
|
||||||
|
? process.env.ADDRESS_PROD
|
||||||
|
: process.env.ADDRESS_DEV
|
||||||
)
|
)
|
||||||
res.raw.setHeader(
|
res.raw.setHeader(
|
||||||
'Access-Control-Allow-Methods',
|
'Access-Control-Allow-Methods',
|
||||||
@ -16,4 +23,9 @@ export const setCorsHeaders = (res: FastifyReply) => {
|
|||||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset'
|
'Origin, X-Requested-With, Content-Type, Accept, Authorization, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset'
|
||||||
)
|
)
|
||||||
res.raw.setHeader('Access-Control-Allow-Credentials', 'true')
|
res.raw.setHeader('Access-Control-Allow-Credentials', 'true')
|
||||||
|
res.raw.setHeader(
|
||||||
|
'Access-Control-Expose-Headers',
|
||||||
|
'Upload-Offset, Upload-Length, Upload-Metadata, Tus-Version,' +
|
||||||
|
'Tus-Resumable, Tus-Max-Size, Tus-Extension, Tus-Checksum-Algorithm'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
0
api/src/lib/db.ts
Normal file → Executable file
0
api/src/lib/db.ts
Normal file → Executable file
10
api/src/lib/email.ts
Normal file → Executable file
10
api/src/lib/email.ts
Normal file → Executable file
@ -8,16 +8,18 @@ interface Options {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
service: 'gmail',
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT),
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.GMAIL,
|
user: process.env.SMTP_USER,
|
||||||
pass: process.env.GMAIL_SMTP_PASSWORD,
|
pass: process.env.SMTP_PASSWORD,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sendEmail = async ({ to, subject, text, html }: Options) =>
|
export const sendEmail = async ({ to, subject, text, html }: Options) =>
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: `"${process.env.FIRST_NAME} ${process.env.LAST_NAME} (noreply)" <${process.env.GMAIL}>`,
|
from: `${process.env.FIRST_NAME} ${process.env.LAST_NAME} <${process.env.EMAIL_FROM}>`,
|
||||||
to: Array.isArray(to) ? to : [to],
|
to: Array.isArray(to) ? to : [to],
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
|
0
api/src/lib/logger.ts
Normal file → Executable file
0
api/src/lib/logger.ts
Normal file → Executable file
12
api/src/lib/tus.ts
Normal file → Executable file
12
api/src/lib/tus.ts
Normal file → Executable file
@ -25,10 +25,16 @@ export const handleTusUpload = (
|
|||||||
tusHandler: Server,
|
tusHandler: Server,
|
||||||
isPublicEndpoint: boolean
|
isPublicEndpoint: boolean
|
||||||
) => {
|
) => {
|
||||||
|
res.hijack()
|
||||||
|
|
||||||
|
if (req.method === 'GET' && isPublicEndpoint) {
|
||||||
|
setCorsHeaders(res)
|
||||||
|
}
|
||||||
|
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
if (req.method === 'OPTIONS') handleOptionsRequest(res)
|
if (req.method === 'OPTIONS') handleOptionsRequest(res)
|
||||||
else if (isPublicEndpoint && req.method === 'GET')
|
else if (isPublicEndpoint && req.method === 'GET')
|
||||||
tusHandler.handle(req.raw, res.raw)
|
void tusHandler.handle(req.raw, res.raw)
|
||||||
else if (['GET', 'POST', 'HEAD', 'PATCH'].includes(req.method)) {
|
else if (['GET', 'POST', 'HEAD', 'PATCH'].includes(req.method)) {
|
||||||
if (req.headers.cookie) handleAuthenticatedRequest(req, res, tusHandler)
|
if (req.headers.cookie) handleAuthenticatedRequest(req, res, tusHandler)
|
||||||
else {
|
else {
|
||||||
@ -40,8 +46,8 @@ export const handleTusUpload = (
|
|||||||
res.raw.end('Method not allowed')
|
res.raw.end('Method not allowed')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setCorsHeaders(res)
|
setCorsHeaders(res, isPublicEndpoint)
|
||||||
tusHandler.handle(req.raw, res.raw)
|
void tusHandler.handle(req.raw, res.raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
35
api/src/server.ts
Normal file → Executable file
35
api/src/server.ts
Normal file → Executable file
@ -9,7 +9,27 @@ import { createServer } from '@redwoodjs/api-server'
|
|||||||
|
|
||||||
import { logger } from 'src/lib/logger'
|
import { logger } from 'src/lib/logger'
|
||||||
import { handleTusUpload } from 'src/lib/tus'
|
import { handleTusUpload } from 'src/lib/tus'
|
||||||
|
|
||||||
|
enum Theme {
|
||||||
|
light = 'light',
|
||||||
|
dark = 'dark',
|
||||||
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
const { countries } = await import('countries-list')
|
||||||
|
|
||||||
|
if (!Object.keys(countries).includes(process.env.COUNTRY))
|
||||||
|
throw new Error(
|
||||||
|
'Invalid COUNTRY environment variable, please select a valid ISO-3166-1 alpha-2 country code\n See https://en.wikipedia.org/wiki/ISO_3166-1#Codes'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!(process.env.DEFAULT_THEME.toLowerCase() in Theme))
|
||||||
|
throw new Error(
|
||||||
|
'Invalid DEFAULT_THEME environment variable, please select either light or dark'
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(`Portfolio ${process.env.APP_VERSION}`)
|
||||||
|
|
||||||
const server = await createServer({
|
const server = await createServer({
|
||||||
logger,
|
logger,
|
||||||
configureApiServer: async (server) => {
|
configureApiServer: async (server) => {
|
||||||
@ -34,7 +54,10 @@ import { handleTusUpload } from 'src/lib/tus'
|
|||||||
datastore: new FileStore({
|
datastore: new FileStore({
|
||||||
directory: `./files_${isProduction ? 'prod' : 'dev'}`,
|
directory: `./files_${isProduction ? 'prod' : 'dev'}`,
|
||||||
}),
|
}),
|
||||||
onResponseError: (_req, res, _err) => logger.error(res),
|
onResponseError(_, err) {
|
||||||
|
logger.error(err)
|
||||||
|
return { status_code: 500, body: 'Internal Server Error' }
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
server.addContentTypeParser(
|
server.addContentTypeParser(
|
||||||
@ -42,12 +65,14 @@ import { handleTusUpload } from 'src/lib/tus'
|
|||||||
(_request, _payload, done) => done(null)
|
(_request, _payload, done) => done(null)
|
||||||
)
|
)
|
||||||
|
|
||||||
server.all('/files', (req, res) =>
|
server.all('/files', (req, res) => {
|
||||||
|
res.hijack()
|
||||||
handleTusUpload(req, res, tusServer, false)
|
handleTusUpload(req, res, tusServer, false)
|
||||||
)
|
})
|
||||||
server.all('/files/*', (req, res) =>
|
server.all('/files/*', (req, res) => {
|
||||||
|
res.hijack()
|
||||||
handleTusUpload(req, res, tusServer, true)
|
handleTusUpload(req, res, tusServer, true)
|
||||||
)
|
})
|
||||||
|
|
||||||
await server.start()
|
await server.start()
|
||||||
})()
|
})()
|
||||||
|
0
api/src/services/.keep
Normal file → Executable file
0
api/src/services/.keep
Normal file → Executable file
0
api/src/services/portrait/portrait.ts
Normal file → Executable file
0
api/src/services/portrait/portrait.ts
Normal file → Executable file
0
api/src/services/projects/projects.ts
Normal file → Executable file
0
api/src/services/projects/projects.ts
Normal file → Executable file
0
api/src/services/resume/resume.ts
Normal file → Executable file
0
api/src/services/resume/resume.ts
Normal file → Executable file
0
api/src/services/socials/socials.ts
Normal file → Executable file
0
api/src/services/socials/socials.ts
Normal file → Executable file
0
api/src/services/tags/tags.ts
Normal file → Executable file
0
api/src/services/tags/tags.ts
Normal file → Executable file
0
api/src/services/title/title.ts
Normal file → Executable file
0
api/src/services/title/title.ts
Normal file → Executable file
2
api/tsconfig.json
Normal file → Executable file
2
api/tsconfig.json
Normal file → Executable file
@ -5,7 +5,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"module": "Node16",
|
"module": "Node16",
|
||||||
"moduleResolution": "Node16",
|
"moduleResolution": "node16",
|
||||||
"skipLibCheck": false,
|
"skipLibCheck": false,
|
||||||
"rootDirs": [
|
"rootDirs": [
|
||||||
"./src",
|
"./src",
|
||||||
|
41
docker-compose.yml
Normal file → Executable file
41
docker-compose.yml
Normal file → Executable file
@ -4,26 +4,39 @@ services:
|
|||||||
portfolio:
|
portfolio:
|
||||||
container_name: portfolio
|
container_name: portfolio
|
||||||
image: git.altaiar.dev/ahmed/portfolio:latest
|
image: git.altaiar.dev/ahmed/portfolio:latest
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- API_PROXY_TARGET=http://localhost:8911
|
- API_PROXY_TARGET=http://localhost:8911
|
||||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||||
- DATABASE_URL=postgresql://redwood:redwood@db:5432/portfolio
|
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||||
- FIRST_NAME=first name # Your first name
|
- FIRST_NAME=first name # Your first name
|
||||||
- LAST_NAME=lastname # Your last name
|
- LAST_NAME=lastname # Your last name
|
||||||
- GMAIL=example@gmail.com # The Gmail address associated with the app password created earlier
|
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||||
- GMAIL_SMTP_PASSWORD=aaaa bbbb cccc dddd # The app password created earlier
|
- STATE=New York # Optional, state or province
|
||||||
|
- CITY=Manhattan # Optional
|
||||||
|
- DEFAULT_THEME=light # 'light' or 'dark'
|
||||||
|
- SMTP_HOST=smtp.example.com
|
||||||
|
- SMTP_PORT=465
|
||||||
|
- SMTP_SECURE=true
|
||||||
|
- SMTP_USER=noreply@example.com
|
||||||
|
- EMAIL_FROM=noreply@example.com
|
||||||
|
- EMAIL_TO=email@example.com
|
||||||
|
- SMTP_PASSWORD=password
|
||||||
- DOMAIN=portfolio.example.com
|
- DOMAIN=portfolio.example.com
|
||||||
- API_DOMAIN=api.portfolio.example.com
|
- API_DOMAIN=api.portfolio.example.com
|
||||||
# Careful, addresses below must not end with a '/'
|
# Careful, addresses below must not end with a '/'
|
||||||
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
||||||
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
||||||
ports:
|
ports:
|
||||||
- '8910:8910' # Web
|
- 8910:8910 # Web
|
||||||
- '8911:8911' # API
|
- 8911:8911 # API
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- files:/home/node/app/api/files_prod
|
||||||
command: >
|
command: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
yarn rw build &&
|
yarn rw build &&
|
||||||
@ -31,18 +44,22 @@ services:
|
|||||||
yarn rw prisma db seed &&
|
yarn rw prisma db seed &&
|
||||||
yarn rw serve"
|
yarn rw serve"
|
||||||
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
container_name: portfolio-db
|
container_name: portfolio-db
|
||||||
image: postgres:16-bookworm
|
image: postgres:16-bookworm
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: redwood
|
- POSTGRES_USER=redwood
|
||||||
POSTGRES_PASSWORD: redwood
|
- POSTGRES_PASSWORD=changeme # Change to a more secure password
|
||||||
POSTGRES_DB: portfolio
|
- POSTGRES_DB=portfolio
|
||||||
ports:
|
restart: unless-stopped
|
||||||
- '5432:5432'
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d DATABASE_URL"] # Replace DATABASE_URL with the database URL from the portfolio container
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
volumes:
|
volumes:
|
||||||
- postgres:/var/lib/postgresql/data
|
- postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres:
|
postgres:
|
||||||
|
files: # For persistent file storage across upgrades
|
||||||
|
0
graphql.config.js
Normal file → Executable file
0
graphql.config.js
Normal file → Executable file
0
jest.config.js
Normal file → Executable file
0
jest.config.js
Normal file → Executable file
6
package.json
Normal file → Executable file
6
package.json
Normal file → Executable file
@ -7,9 +7,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@redwoodjs/auth-dbauth-setup": "8.4.0",
|
"@redwoodjs/auth-dbauth-setup": "8.6.1",
|
||||||
"@redwoodjs/core": "8.4.0",
|
"@redwoodjs/core": "8.6.1",
|
||||||
"@redwoodjs/project-config": "8.4.0",
|
"@redwoodjs/project-config": "8.6.1",
|
||||||
"prettier-plugin-tailwindcss": "0.4.1"
|
"prettier-plugin-tailwindcss": "0.4.1"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
0
prettier.config.mjs
Normal file → Executable file
0
prettier.config.mjs
Normal file → Executable file
2
redwood.toml
Normal file → Executable file
2
redwood.toml
Normal file → Executable file
@ -9,7 +9,7 @@
|
|||||||
title = "${FIRST_NAME} ${LAST_NAME}"
|
title = "${FIRST_NAME} ${LAST_NAME}"
|
||||||
port = 8910
|
port = 8910
|
||||||
apiUrl = "/api"
|
apiUrl = "/api"
|
||||||
includeEnvironmentVariables = ["FIRST_NAME", "LAST_NAME", "API_ADDRESS_PROD", "API_ADDRESS_DEV"]
|
includeEnvironmentVariables = ["FIRST_NAME", "LAST_NAME", "COUNTRY", "STATE", "CITY", "DEFAULT_THEME", "API_ADDRESS_PROD", "API_ADDRESS_DEV", "APP_VERSION"]
|
||||||
[generate]
|
[generate]
|
||||||
tests = false
|
tests = false
|
||||||
stories = false
|
stories = false
|
||||||
|
0
scripts/.keep
Normal file → Executable file
0
scripts/.keep
Normal file → Executable file
20
scripts/seed.ts
Normal file → Executable file
20
scripts/seed.ts
Normal file → Executable file
@ -3,21 +3,19 @@ import { db } from 'api/src/lib/db'
|
|||||||
|
|
||||||
import { hashPassword } from '@redwoodjs/auth-dbauth-api'
|
import { hashPassword } from '@redwoodjs/auth-dbauth-api'
|
||||||
|
|
||||||
const MAX_TITLES = 5
|
|
||||||
|
|
||||||
export default async () => {
|
export default async () => {
|
||||||
try {
|
try {
|
||||||
const admin = {
|
const admin = {
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
email: process.env.GMAIL,
|
email: process.env.EMAIL_TO,
|
||||||
password: process.env.GMAIL_SMTP_PASSWORD,
|
password: process.env.SMTP_PASSWORD,
|
||||||
}
|
}
|
||||||
|
|
||||||
const [hashedPassword, salt] = hashPassword(admin.password)
|
const [hashedPassword, salt] = hashPassword(admin.password)
|
||||||
|
|
||||||
const existingAdmin = await db.user.findFirst({
|
const existingAdmin = await db.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email: admin.email,
|
username: admin.username,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -30,15 +28,21 @@ export default async () => {
|
|||||||
salt,
|
salt,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
else
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: existingAdmin.id },
|
||||||
|
data: {
|
||||||
|
username: admin.username,
|
||||||
|
email: admin.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const titles = await db.titles.findFirst()
|
const titles = await db.titles.findFirst()
|
||||||
|
|
||||||
if (!titles)
|
if (!titles)
|
||||||
await db.titles.create({
|
await db.titles.create({
|
||||||
data: {
|
data: {
|
||||||
titles: Array.from({ length: MAX_TITLES }).map(
|
titles: Array.from({ length: 3 }).map((_, i) => `title ${i + 1}`),
|
||||||
(_, i) => `a title ${i + 1}`
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
0
scripts/tsconfig.json
Normal file → Executable file
0
scripts/tsconfig.json
Normal file → Executable file
0
web/config/postcss.config.js
Normal file → Executable file
0
web/config/postcss.config.js
Normal file → Executable file
0
web/config/tailwind.config.js
Normal file → Executable file
0
web/config/tailwind.config.js
Normal file → Executable file
0
web/jest.config.js
Normal file → Executable file
0
web/jest.config.js
Normal file → Executable file
15
web/package.json
Normal file → Executable file
15
web/package.json
Normal file → Executable file
@ -14,11 +14,11 @@
|
|||||||
"@icons-pack/react-simple-icons": "^10.0.0",
|
"@icons-pack/react-simple-icons": "^10.0.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@redwoodjs/auth-dbauth-web": "8.4.0",
|
"@redwoodjs/auth-dbauth-web": "8.6.1",
|
||||||
"@redwoodjs/forms": "8.4.0",
|
"@redwoodjs/forms": "8.6.1",
|
||||||
"@redwoodjs/router": "8.4.0",
|
"@redwoodjs/router": "8.6.1",
|
||||||
"@redwoodjs/web": "8.4.0",
|
"@redwoodjs/web": "8.6.1",
|
||||||
"@redwoodjs/web-server": "8.4.0",
|
"@redwoodjs/web-server": "8.6.1",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tiptap/extension-link": "^2.8.0",
|
"@tiptap/extension-link": "^2.8.0",
|
||||||
"@tiptap/extension-text-style": "^2.8.0",
|
"@tiptap/extension-text-style": "^2.8.0",
|
||||||
@ -35,16 +35,17 @@
|
|||||||
"@uppy/react": "^4.0.1",
|
"@uppy/react": "^4.0.1",
|
||||||
"@uppy/tus": "^4.0.0",
|
"@uppy/tus": "^4.0.0",
|
||||||
"@uppy/webcam": "^4.0.1",
|
"@uppy/webcam": "^4.0.1",
|
||||||
|
"countries-list": "^3.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"humanize-string": "2.1.0",
|
"humanize-string": "2.1.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-html-parser": "^2.0.2",
|
"react-html-parser": "^2.0.2",
|
||||||
"react-typed": "^2.0.12"
|
"react-pdf": "^9.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@redwoodjs/vite": "8.4.0",
|
"@redwoodjs/vite": "8.6.1",
|
||||||
"@types/react": "^18.2.55",
|
"@types/react": "^18.2.55",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^18.2.19",
|
||||||
"@types/react-html-parser": "^2",
|
"@types/react-html-parser": "^2",
|
||||||
|
0
web/public/README.md
Normal file → Executable file
0
web/public/README.md
Normal file → Executable file
0
web/public/favicon.png
Normal file → Executable file
0
web/public/favicon.png
Normal file → Executable file
Before Width: | Height: | Size: 757 B After Width: | Height: | Size: 757 B |
0
web/public/no_portrait.webp
Normal file → Executable file
0
web/public/no_portrait.webp
Normal file → Executable file
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
0
web/public/no_resume.pdf
Normal file → Executable file
0
web/public/no_resume.pdf
Normal file → Executable file
0
web/public/robots.txt
Normal file → Executable file
0
web/public/robots.txt
Normal file → Executable file
0
web/quick-lint-js.config
Normal file → Executable file
0
web/quick-lint-js.config
Normal file → Executable file
0
web/src/App.tsx
Normal file → Executable file
0
web/src/App.tsx
Normal file → Executable file
4
web/src/Routes.tsx
Normal file → Executable file
4
web/src/Routes.tsx
Normal file → Executable file
@ -31,11 +31,11 @@ const Routes = () => {
|
|||||||
<Route path="/admin/titles" page={TitleTitlesPage} name="titles" />
|
<Route path="/admin/titles" page={TitleTitlesPage} name="titles" />
|
||||||
</Set>
|
</Set>
|
||||||
|
|
||||||
<Set wrap={ScaffoldLayout} title="Resume" titleTo="adminResume">
|
<Set wrap={ScaffoldLayout} title="Résumé" titleTo="adminResume">
|
||||||
<Route path="/admin/resume" page={ResumeAdminResumePage} name="adminResume" />
|
<Route path="/admin/resume" page={ResumeAdminResumePage} name="adminResume" />
|
||||||
</Set>
|
</Set>
|
||||||
|
|
||||||
<Set wrap={ScaffoldLayout} title="Projects" titleTo="projects" buttonLabel="New Project" buttonTo="newProject">
|
<Set wrap={ScaffoldLayout} title="Projects" titleTo="adminProjects" buttonLabel="New Project" buttonTo="newProject">
|
||||||
<Route path="/admin/projects/new" page={ProjectNewProjectPage} name="newProject" />
|
<Route path="/admin/projects/new" page={ProjectNewProjectPage} name="newProject" />
|
||||||
<Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" />
|
<Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" />
|
||||||
<Route path="/admin/projects/{id:Int}" page={ProjectAdminProjectPage} name="adminProject" />
|
<Route path="/admin/projects/{id:Int}" page={ProjectAdminProjectPage} name="adminProject" />
|
||||||
|
0
web/src/auth.ts
Normal file → Executable file
0
web/src/auth.ts
Normal file → Executable file
0
web/src/components/.keep
Normal file → Executable file
0
web/src/components/.keep
Normal file → Executable file
@ -1,56 +0,0 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
|
||||||
|
|
||||||
const SCROLL_INTERVAL_SECONDS = 3
|
|
||||||
|
|
||||||
interface AutoCarouselProps {
|
|
||||||
images: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const AutoCarousel = ({ images }: AutoCarouselProps) => {
|
|
||||||
const [activeItem, setActiveItem] = useState<number>(0)
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const scroll = useCallback(() => {
|
|
||||||
setActiveItem((prev) => {
|
|
||||||
if (images.length - 1 > prev) return prev + 1
|
|
||||||
else return 0
|
|
||||||
})
|
|
||||||
}, [images.length])
|
|
||||||
|
|
||||||
const autoScroll = useCallback(
|
|
||||||
() => setInterval(scroll, SCROLL_INTERVAL_SECONDS * 1000),
|
|
||||||
[scroll]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const play = autoScroll()
|
|
||||||
return () => clearInterval(play)
|
|
||||||
}, [autoScroll])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const width = ref.current?.getBoundingClientRect().width
|
|
||||||
ref.current?.scroll({ left: activeItem * (width || 0) })
|
|
||||||
}, [activeItem])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className="carousel carousel-center p-2 space-x-2 rounded-box"
|
|
||||||
>
|
|
||||||
{images.map((image, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="carousel-item w-full h-fit my-auto justify-center"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={image}
|
|
||||||
alt={`${i}`}
|
|
||||||
className="object-contain rounded-xl size-fit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AutoCarousel
|
|
0
web/src/components/Cell/CellEmpty/CellEmpty.tsx
Normal file → Executable file
0
web/src/components/Cell/CellEmpty/CellEmpty.tsx
Normal file → Executable file
0
web/src/components/Cell/CellFailure/CellFailure.tsx
Normal file → Executable file
0
web/src/components/Cell/CellFailure/CellFailure.tsx
Normal file → Executable file
0
web/src/components/Cell/CellLoading/CellLoading.tsx
Normal file → Executable file
0
web/src/components/Cell/CellLoading/CellLoading.tsx
Normal file → Executable file
90
web/src/components/ColorPicker/ColorPicker.tsx
Normal file → Executable file
90
web/src/components/ColorPicker/ColorPicker.tsx
Normal file → Executable file
@ -9,54 +9,50 @@ interface ColorPickerProps {
|
|||||||
setColor: React.Dispatch<React.SetStateAction<string>>
|
setColor: React.Dispatch<React.SetStateAction<string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
|
const ColorPicker = ({ color, setColor }: ColorPickerProps) => (
|
||||||
return (
|
<div className="flex flex-col space-y-2 bg-base-100 p-2 rounded-xl">
|
||||||
<div className="flex flex-col space-y-2 bg-base-100 p-2 rounded-xl w-min">
|
<HexColorPicker color={color} onChange={setColor} />
|
||||||
<section className="w-52">
|
<div className="flex space-x-2">
|
||||||
<HexColorPicker color={color} onChange={setColor} />
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
</section>
|
<label className="input input-bordered flex items-center gap-2 input-sm grow">
|
||||||
<div className="flex space-x-2 w-52">
|
<Icon path={mdiPound} className="size-4 opacity-70" />
|
||||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
<HexColorInput color={color} className="w-16" />
|
||||||
<label className="input input-bordered flex items-center gap-2 input-sm w-full grow">
|
</label>
|
||||||
<Icon path={mdiPound} className="size-4 opacity-70" />
|
<button
|
||||||
<HexColorInput color={color} className="w-16" />
|
type="button"
|
||||||
</label>
|
className="btn btn-square btn-sm"
|
||||||
<button
|
onClick={async () => {
|
||||||
type="button"
|
try {
|
||||||
className="btn btn-square btn-sm"
|
await navigator.clipboard.writeText(color)
|
||||||
onClick={async () => {
|
toast.success('Copied color to clipboard')
|
||||||
try {
|
} catch {
|
||||||
await navigator.clipboard.writeText(color)
|
toast.error(`Failed to copy, please try again`)
|
||||||
toast.success('Copied color to clipboard')
|
}
|
||||||
} catch {
|
}}
|
||||||
toast.error(`Failed to copy, please try again`)
|
>
|
||||||
}
|
<Icon path={mdiContentCopy} className="size-4" />
|
||||||
}}
|
</button>
|
||||||
>
|
<button
|
||||||
<Icon path={mdiContentCopy} className="size-4" />
|
type="button"
|
||||||
</button>
|
className="btn btn-square btn-sm"
|
||||||
<button
|
onClick={async () => {
|
||||||
type="button"
|
try {
|
||||||
className="btn btn-square btn-sm "
|
const clipboardText = await navigator.clipboard.readText()
|
||||||
onClick={async () => {
|
const hexColorRegex =
|
||||||
try {
|
/^#(?:(?:[\da-f]{3}){1,2}|(?:[\da-f]{4}){1,2})$/i
|
||||||
const clipboardText = await navigator.clipboard.readText()
|
|
||||||
const hexColorRegex =
|
|
||||||
/^#(?:(?:[\da-f]{3}){1,2}|(?:[\da-f]{4}){1,2})$/i
|
|
||||||
|
|
||||||
if (!hexColorRegex.test(clipboardText))
|
if (!hexColorRegex.test(clipboardText))
|
||||||
toast.error(`Text is not a valid hex color`)
|
toast.error(`Text is not a valid hex color`)
|
||||||
else setColor(clipboardText)
|
else setColor(clipboardText)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`Failed to paste, please try again`)
|
toast.error(`Failed to paste, please try again`)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon path={mdiContentPaste} className="size-4" />
|
<Icon path={mdiContentPaste} className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
|
||||||
export default ColorPicker
|
export default ColorPicker
|
||||||
|
30
web/src/components/ContactCard/ContactCard/ContactCard.tsx
Normal file → Executable file
30
web/src/components/ContactCard/ContactCard/ContactCard.tsx
Normal file → Executable file
@ -10,8 +10,8 @@ interface ContactCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ContactCard = ({ portraitUrl, socials }: ContactCardProps) => {
|
const ContactCard = ({ portraitUrl, socials }: ContactCardProps) => {
|
||||||
const [width, setWidth] = useState()
|
const [width, setWidth] = useState<number>(0)
|
||||||
const [height, setHeight] = useState()
|
const [height, setHeight] = useState<number>(0)
|
||||||
|
|
||||||
const observedDiv = useRef(null)
|
const observedDiv = useRef(null)
|
||||||
|
|
||||||
@ -53,24 +53,18 @@ const ContactCard = ({ portraitUrl, socials }: ContactCardProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex min-h-[calc(100vh-6rem)] items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<div className="card bg-base-100 shadow-xl md:card-side">
|
<div className="card card-compact bg-base-100 shadow-xl md:card-side">
|
||||||
<figure>
|
<img
|
||||||
<img
|
className="contact-me-image rounded-box aspect-portrait p-2 object-cover"
|
||||||
className="contact-me-image aspect-portrait object-cover"
|
src={portraitUrl}
|
||||||
src={portraitUrl}
|
alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`}
|
||||||
alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`}
|
/>
|
||||||
/>
|
<div className="card-body mx-auto w-fit md:mx-0" ref={observedDiv}>
|
||||||
</figure>
|
<h2 className="card-title justify-center text-3xl pb-2 md:justify-start">
|
||||||
<div
|
|
||||||
className="card-body mx-auto h-fit w-fit md:mx-0"
|
|
||||||
ref={observedDiv}
|
|
||||||
>
|
|
||||||
<h2 className="card-title justify-center text-3xl md:justify-start">
|
|
||||||
Contact Me
|
Contact Me
|
||||||
</h2>
|
</h2>
|
||||||
<p className="p-2"></p>
|
<div className="card-actions rounded-btn">
|
||||||
<div className="card-actions">
|
|
||||||
<SocialLinks socials={socials} />
|
<SocialLinks socials={socials} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
27
web/src/components/ContactCard/ContactCardCell/ContactCardCell.tsx
Normal file → Executable file
27
web/src/components/ContactCard/ContactCardCell/ContactCardCell.tsx
Normal file → Executable file
@ -3,10 +3,12 @@ import type {
|
|||||||
ContactCardPortraitVariables,
|
ContactCardPortraitVariables,
|
||||||
} from 'types/graphql'
|
} from 'types/graphql'
|
||||||
|
|
||||||
import type {
|
import { routes } from '@redwoodjs/router'
|
||||||
TypedDocumentNode,
|
import {
|
||||||
CellFailureProps,
|
type TypedDocumentNode,
|
||||||
CellSuccessProps,
|
type CellFailureProps,
|
||||||
|
type CellSuccessProps,
|
||||||
|
Metadata,
|
||||||
} from '@redwoodjs/web'
|
} from '@redwoodjs/web'
|
||||||
|
|
||||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||||
@ -43,5 +45,20 @@ export const Success = ({
|
|||||||
portrait,
|
portrait,
|
||||||
socials,
|
socials,
|
||||||
}: CellSuccessProps<ContactCardPortrait, ContactCardPortraitVariables>) => (
|
}: CellSuccessProps<ContactCardPortrait, ContactCardPortraitVariables>) => (
|
||||||
<ContactCard portraitUrl={portrait.fileId} socials={socials} />
|
<>
|
||||||
|
<Metadata
|
||||||
|
title="Contact"
|
||||||
|
og={{
|
||||||
|
title: 'Contact',
|
||||||
|
type: 'website',
|
||||||
|
image: {
|
||||||
|
url: portrait.fileId,
|
||||||
|
type: 'image/webp',
|
||||||
|
alt: `${process.env.FIRST_NAME} ${process.env.LAST_NAME}`,
|
||||||
|
},
|
||||||
|
url: routes.contact(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ContactCard portraitUrl={portrait.fileId} socials={socials} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
0
web/src/components/DatePicker/DatePicker.tsx
Normal file → Executable file
0
web/src/components/DatePicker/DatePicker.tsx
Normal file → Executable file
0
web/src/components/FormTextList/FormTextList.tsx
Normal file → Executable file
0
web/src/components/FormTextList/FormTextList.tsx
Normal file → Executable file
75
web/src/components/PDF/PDF.tsx
Normal file → Executable file
75
web/src/components/PDF/PDF.tsx
Normal file → Executable file
@ -1,19 +1,70 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
|
||||||
|
import { mdiOpenInNew } from '@mdi/js'
|
||||||
|
import Icon from '@mdi/react'
|
||||||
|
import { Document, Page as PdfPage, pdfjs } from 'react-pdf'
|
||||||
|
|
||||||
|
import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||||||
|
import 'react-pdf/dist/Page/TextLayer.css'
|
||||||
|
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||||
|
import.meta.url
|
||||||
|
).toString()
|
||||||
|
|
||||||
interface PDFProps {
|
interface PDFProps {
|
||||||
url: string
|
url: string
|
||||||
form?: boolean
|
form?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const PDF = ({ url, form = false }: PDFProps) => (
|
const PDF = ({ url, form = false }: PDFProps) => {
|
||||||
<embed
|
const [numPages, setNumPages] = useState<number>(0)
|
||||||
src={url}
|
function onLoadSuccess({ numPages }: { numPages: number }) {
|
||||||
title="PDF"
|
setNumPages(numPages)
|
||||||
type="application/pdf"
|
}
|
||||||
style={{
|
|
||||||
width: 'calc(100vw - 1rem)',
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
height: `calc(100vh - ${form ? '8.5rem' : '6rem'})`,
|
const [containerWidth, setContainerWidth] = useState<number>(0)
|
||||||
}}
|
|
||||||
className="rounded-xl"
|
useEffect(() => {
|
||||||
/>
|
function updateWidth() {
|
||||||
)
|
if (containerRef.current) {
|
||||||
|
setContainerWidth(containerRef.current.clientWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateWidth()
|
||||||
|
window.addEventListener('resize', updateWidth)
|
||||||
|
return () => window.removeEventListener('resize', updateWidth)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="overflow-auto flex justify-center"
|
||||||
|
style={{
|
||||||
|
width: 'calc(100vw - 1rem)',
|
||||||
|
height: `calc(100vh - ${form ? '8.5rem' : '6rem'})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="fixed top-20 left-0 z-10 m-2 p-2 rounded-xl btn btn-square btn-ghost shadow-lg"
|
||||||
|
>
|
||||||
|
<Icon path={mdiOpenInNew} size={1} className="text-gray-600" />
|
||||||
|
</a>
|
||||||
|
<Document file={url} onLoadSuccess={onLoadSuccess}>
|
||||||
|
{Array.from({ length: numPages }, (_, i) => (
|
||||||
|
<PdfPage
|
||||||
|
key={i}
|
||||||
|
pageNumber={i + 1}
|
||||||
|
width={Math.min(containerWidth, 800)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Document>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default PDF
|
export default PDF
|
||||||
|
0
web/src/components/Portrait/PortraitCell/PortraitCell.tsx
Normal file → Executable file
0
web/src/components/Portrait/PortraitCell/PortraitCell.tsx
Normal file → Executable file
7
web/src/components/Portrait/PortraitForm/PortraitForm.tsx
Normal file → Executable file
7
web/src/components/Portrait/PortraitForm/PortraitForm.tsx
Normal file → Executable file
@ -127,14 +127,13 @@ const PortraitForm = ({ portrait }: PortraitFormProps) => {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-fit space-y-2">
|
<div className="mx-auto max-w-prose space-y-2">
|
||||||
{!fileId ? (
|
{!fileId ? (
|
||||||
<>
|
<>
|
||||||
<Uploader
|
<Uploader
|
||||||
onComplete={onUploadComplete}
|
onComplete={onUploadComplete}
|
||||||
width="22rem"
|
width="auto"
|
||||||
height="34.5rem"
|
height="30rem"
|
||||||
className="flex justify-center"
|
|
||||||
/>
|
/>
|
||||||
<p className="text-center">
|
<p className="text-center">
|
||||||
High quality, 4:5 aspect ratio image recommended
|
High quality, 4:5 aspect ratio image recommended
|
||||||
|
50
web/src/components/Project/AdminProject/AdminProject.tsx
Normal file → Executable file
50
web/src/components/Project/AdminProject/AdminProject.tsx
Normal file → Executable file
@ -46,29 +46,28 @@ const AdminProject = ({ project }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full justify-center">
|
<div className="flex justify-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead className="bg-base-200 font-syne">
|
<thead className="bg-base-200 font-syne">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-0">
|
<th colSpan={2}>
|
||||||
Project {project.id}: {project.title}
|
Project {project.id}: {project.title}
|
||||||
</th>
|
</th>
|
||||||
<th> </th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th className="text-right">ID</th>
|
||||||
<td>{project.id}</td>
|
<td>{project.id}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th className="text-right">Title</th>
|
||||||
<td>{project.title}</td>
|
<td>{project.title}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Description</th>
|
<th className="text-right">Description</th>
|
||||||
<td>
|
<td>
|
||||||
<article className="prose">
|
<article className="prose">
|
||||||
{parseHtml(project.description)}
|
{parseHtml(project.description)}
|
||||||
@ -76,11 +75,11 @@ const AdminProject = ({ project }: Props) => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th className="text-right">Date</th>
|
||||||
<td>{timeTag(project.date)}</td>
|
<td>{timeTag(project.date)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Images</th>
|
<th className="text-right">Images</th>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{project.images.map((image, i) => (
|
{project.images.map((image, i) => (
|
||||||
@ -88,7 +87,7 @@ const AdminProject = ({ project }: Props) => {
|
|||||||
key={i}
|
key={i}
|
||||||
href={image}
|
href={image}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="btn btn-sm btn-square"
|
className={`btn btn-sm btn-square ${i === 0 && 'btn-primary'}`}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
@ -98,7 +97,7 @@ const AdminProject = ({ project }: Props) => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Tags</th>
|
<th className="text-right">Tags</th>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{project.tags.map((tag, i) => (
|
{project.tags.map((tag, i) => (
|
||||||
@ -120,19 +119,30 @@ const AdminProject = ({ project }: Props) => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Links</th>
|
<th className="text-right">Links</th>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{project.links.map((link, i) => (
|
{project.links.map((link, i) => (
|
||||||
<a
|
<>
|
||||||
href={link}
|
<a
|
||||||
target="_blank"
|
href={link}
|
||||||
className="badge badge-ghost text-nowrap"
|
target="_blank"
|
||||||
key={i}
|
className="hidden sm:flex badge badge-ghost text-nowrap"
|
||||||
rel="noreferrer"
|
key={i}
|
||||||
>
|
rel="noreferrer"
|
||||||
{link}
|
>
|
||||||
</a>
|
{link}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={link}
|
||||||
|
target="_blank"
|
||||||
|
className="btn btn-sm btn-square sm:hidden"
|
||||||
|
key={i}
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
0
web/src/components/Project/AdminProjectCell/AdminProjectCell.tsx
Normal file → Executable file
0
web/src/components/Project/AdminProjectCell/AdminProjectCell.tsx
Normal file → Executable file
8
web/src/components/Project/EditProjectCell/EditProjectCell.tsx
Normal file → Executable file
8
web/src/components/Project/EditProjectCell/EditProjectCell.tsx
Normal file → Executable file
@ -73,12 +73,12 @@ export const Success = ({ project }: CellSuccessProps<EditProjectById>) => {
|
|||||||
) => updateProject({ variables: { id, input } })
|
) => updateProject({ variables: { id, input } })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full justify-center">
|
<div className="flex mx-auto max-w-prose justify-center">
|
||||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||||
<table className="table w-80">
|
<table className="table">
|
||||||
<thead className="bg-base-200 font-syne">
|
<thead className="bg-base-200 font-syne">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-0">Edit Project {project.id}</th>
|
<th>Edit Project {project.id}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
6
web/src/components/Project/NewProject/NewProject.tsx
Normal file → Executable file
6
web/src/components/Project/NewProject/NewProject.tsx
Normal file → Executable file
@ -38,9 +38,9 @@ const NewProject = () => {
|
|||||||
createProject({ variables: { input } })
|
createProject({ variables: { input } })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full justify-center">
|
<div className="flex mx-auto max-w-prose justify-center">
|
||||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||||
<table className="table w-80">
|
<table className="table">
|
||||||
<thead className="bg-base-200 font-syne">
|
<thead className="bg-base-200 font-syne">
|
||||||
<tr>
|
<tr>
|
||||||
<th>New Project</th>
|
<th>New Project</th>
|
||||||
|
29
web/src/components/Project/Project/Project.tsx
Normal file → Executable file
29
web/src/components/Project/Project/Project.tsx
Normal file → Executable file
@ -69,20 +69,23 @@ const Project = ({ project }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
|
{project.images.length > 0 && (
|
||||||
|
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-4 items-center pt-8 justify-center">
|
<div className="flex flex-wrap gap-4 pt-8 justify-center h-fit sm:p-8">
|
||||||
{project.images.map((image, i) => (
|
{project.images.length > 0 &&
|
||||||
<a
|
project.images.map((image, i) => (
|
||||||
href={image}
|
<a
|
||||||
target="_blank"
|
href={image}
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
key={i}
|
rel="noreferrer"
|
||||||
className="rounded-xl"
|
key={i}
|
||||||
>
|
className="rounded-box"
|
||||||
<img src={image} alt="" className="rounded-xl" />
|
>
|
||||||
</a>
|
<img src={image} alt="" className="rounded-xl" />
|
||||||
))}
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
30
web/src/components/Project/ProjectCell/ProjectCell.tsx
Normal file → Executable file
30
web/src/components/Project/ProjectCell/ProjectCell.tsx
Normal file → Executable file
@ -1,9 +1,11 @@
|
|||||||
import type { FindProjectById, FindProjectByIdVariables } from 'types/graphql'
|
import type { FindProjectById, FindProjectByIdVariables } from 'types/graphql'
|
||||||
|
|
||||||
import type {
|
import { routes } from '@redwoodjs/router'
|
||||||
CellSuccessProps,
|
import {
|
||||||
CellFailureProps,
|
type CellSuccessProps,
|
||||||
TypedDocumentNode,
|
type CellFailureProps,
|
||||||
|
type TypedDocumentNode,
|
||||||
|
Metadata,
|
||||||
} from '@redwoodjs/web'
|
} from '@redwoodjs/web'
|
||||||
|
|
||||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||||
@ -40,5 +42,23 @@ export const Failure = ({
|
|||||||
export const Success = ({
|
export const Success = ({
|
||||||
project,
|
project,
|
||||||
}: CellSuccessProps<FindProjectById, FindProjectByIdVariables>) => (
|
}: CellSuccessProps<FindProjectById, FindProjectByIdVariables>) => (
|
||||||
<Project project={project} />
|
<>
|
||||||
|
<Metadata
|
||||||
|
title={project.title}
|
||||||
|
og={{
|
||||||
|
title: project.title,
|
||||||
|
type: 'website',
|
||||||
|
image:
|
||||||
|
project.images.length > 0
|
||||||
|
? {
|
||||||
|
url: project.images[0],
|
||||||
|
type: 'image/webp',
|
||||||
|
alt: 'Image 1',
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
url: routes.project({ id: project.id }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Project project={project} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
17
web/src/components/Project/ProjectForm/ProjectForm.tsx
Normal file → Executable file
17
web/src/components/Project/ProjectForm/ProjectForm.tsx
Normal file → Executable file
@ -119,7 +119,7 @@ const ProjectForm = (props: ProjectFormProps) => {
|
|||||||
<Form<FormProject>
|
<Form<FormProject>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
error={props.error}
|
error={props.error}
|
||||||
className="space-y-2 w-80"
|
className="space-y-2"
|
||||||
>
|
>
|
||||||
<Label name="title" className="form-control w-full">
|
<Label name="title" className="form-control w-full">
|
||||||
<Label
|
<Label
|
||||||
@ -242,7 +242,14 @@ const ProjectForm = (props: ProjectFormProps) => {
|
|||||||
<img src={fileId} alt={i.toString()} />
|
<img src={fileId} alt={i.toString()} />
|
||||||
</figure>
|
</figure>
|
||||||
<div className="card-body p-2 rounded-xl">
|
<div className="card-body p-2 rounded-xl">
|
||||||
<div className="card-actions rounded-md justify-end">
|
<div
|
||||||
|
className={`card-actions rounded-md ${i === 0 ? 'justify-between' : 'justify-end'}`}
|
||||||
|
>
|
||||||
|
{i === 0 && (
|
||||||
|
<div className="btn btn-sm shadow-xl no-animation">
|
||||||
|
Cover Image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-square btn-sm btn-error shadow-xl"
|
className="btn btn-square btn-sm btn-error shadow-xl"
|
||||||
@ -261,9 +268,8 @@ const ProjectForm = (props: ProjectFormProps) => {
|
|||||||
{appendUploader && (
|
{appendUploader && (
|
||||||
<Uploader
|
<Uploader
|
||||||
onComplete={onUploadComplete}
|
onComplete={onUploadComplete}
|
||||||
width="20rem"
|
width="auto"
|
||||||
height="30rem"
|
height="30rem"
|
||||||
className="flex justify-center"
|
|
||||||
maxFiles={10}
|
maxFiles={10}
|
||||||
disabled={props.loading}
|
disabled={props.loading}
|
||||||
/>
|
/>
|
||||||
@ -272,9 +278,8 @@ const ProjectForm = (props: ProjectFormProps) => {
|
|||||||
) : (
|
) : (
|
||||||
<Uploader
|
<Uploader
|
||||||
onComplete={onUploadComplete}
|
onComplete={onUploadComplete}
|
||||||
width="20rem"
|
width="auto"
|
||||||
height="30rem"
|
height="30rem"
|
||||||
className="flex justify-center pt-3"
|
|
||||||
maxFiles={10}
|
maxFiles={10}
|
||||||
disabled={props.loading}
|
disabled={props.loading}
|
||||||
/>
|
/>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user