40 Commits
v1.0.1 ... main

Author SHA1 Message Date
47777acd74 Docker tweaks and PDF width fix
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 4s
2025-05-06 18:07:52 -04:00
32d98f4bc0 Updates 2025-05-06 14:03:50 -04:00
ef60832bc2 Add CORS for GETs
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 4s
2025-05-01 22:11:06 -04:00
4f782560de downgrade tus server
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 41s
2025-05-01 21:59:27 -04:00
979cf7320e Switch to alpine
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 43s
2025-05-01 21:41:35 -04:00
1f9f11e1be Fix crash (thank you gpt o3!)
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 5s
2025-05-01 21:18:38 -04:00
1d183c37f8 Cors is gone..? reverting old changes
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 5s
2025-05-01 21:05:40 -04:00
3aeec4d23e Fix PDF once and for all
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 57s
2025-05-01 20:46:50 -04:00
debfcf7226 Attempt 2
All checks were successful
Publish Development Docker Image / Publish Development Docker Image (push) Successful in 8s
Publish Docker Image / Publish Docker Image (push) Successful in 27s
2025-04-07 17:33:54 -04:00
15bbc27238 Attempt to propegate app version properly
All checks were successful
Publish Development Docker Image / Publish Development Docker Image (push) Successful in 8s
2025-04-07 17:17:05 -04:00
16bd44c599 Fix app version?
All checks were successful
Publish Development Docker Image / Publish Development Docker Image (push) Successful in 9s
2025-04-07 17:06:40 -04:00
d13b16c032 Update TUS backend
All checks were successful
Publish Development Docker Image / Publish Development Docker Image (push) Successful in 1m34s
2025-04-07 16:54:17 -04:00
d144f7385b Quietly embed version to homepage 2025-04-07 15:08:07 -04:00
0283c293ef An attempt to fix the PDF iframe not loading properly when the API domain is third-partyBasic printer CRUD
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 39s
2025-04-06 18:08:05 -04:00
8d75849c55 More reliable docker-compose.yml 2024-11-10 14:48:43 -05:00
58b44dddad Update Dockerfile
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 1m51s
2024-11-08 12:07:04 -05:00
c9de531389 Remove defaults, as they override the optional values
Some checks failed
Publish Docker Image / Publish Docker Image (push) Failing after 12s
2024-10-26 16:37:49 -04:00
d48cfe12f2 [#1] Cleaner homepage
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 1m4s
2024-10-25 18:35:23 -04:00
3a9cc20f86 [#1] Less cluttered projects page 2024-10-25 17:34:29 -04:00
54a34ef5ee [#1] No animated text 2024-10-24 21:22:02 -04:00
f097d7761d [#1] Clearer location 2024-10-24 20:06:09 -04:00
03717113f4 [#1] Add default theme env variable 2024-10-24 18:38:07 -04:00
284a4c5520 [#1] Remove carousel 2024-10-24 18:14:19 -04:00
f03faabbee UI tweaks on admin side
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 11s
2024-10-23 16:52:10 -04:00
353fb3899e PDF error handling 2024-10-23 12:26:21 -04:00
62ce137bcb Add og metatags to public facing pages
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 11s
2024-10-22 16:48:48 -04:00
f8987b08da Add persistent flag of origin
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 1m12s
2024-10-17 21:36:50 -04:00
cbf75acbeb Fix empty images section if no images + Remove postgres port 2024-10-17 20:46:34 -04:00
7973663b2a Allow separate from and to emails
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 10s
2024-10-16 21:45:55 -04:00
6540329f36 Use SMTP credentials instead of Gmail auth for Email 2024-10-16 21:36:57 -04:00
bac5b5fe48 Revert prefers-color-scheme 2024-10-16 21:21:58 -04:00
f3f75d3e57 Fix file uploads clearing on upgrade 2024-10-15 22:51:41 -04:00
f14732cdf0 Upgrade RedwoodJS
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 1m6s
2024-10-15 14:52:01 -04:00
77db153fe6 Add Matrix social 2024-10-15 14:51:43 -04:00
684d6f88c2 Combine GraphQL queries for contact card (2 -> 1) 2024-10-15 14:30:05 -04:00
03f606bbde Improve color picker paste logic 2024-10-15 14:21:52 -04:00
1eafaee2c0 Follow system color scheme by default instead of light 2024-10-10 14:09:09 -04:00
b8063e8692 Auth tweaks
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 26s
2024-10-09 20:44:12 -04:00
738260f7de Watermark 2024-10-09 20:37:27 -04:00
82313bef46 Simplify PDF embed 2024-10-09 20:32:39 -04:00
175 changed files with 2745 additions and 2398 deletions

0
.dockerignore Normal file → Executable file
View File

0
.editorconfig Normal file → Executable file
View File

View 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
View 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
View 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
View File

0
.redwood/README.md Normal file → Executable file
View File

0
.vscode/extensions.json vendored Normal file → Executable file
View File

0
.vscode/launch.json vendored Normal file → Executable file
View File

0
.vscode/settings.json vendored Normal file → Executable file
View File

0
.vscode/tasks.json vendored Normal file → Executable file
View File

0
.yarnrc.yml Normal file → Executable file
View File

100
Dockerfile Normal file → Executable file
View 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
View 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
View File

0
api/db/migrations/20240819213158_social/migration.sql Normal file → Executable file
View File

View File

0
api/db/migrations/20240824001030_project/migration.sql Normal file → Executable file
View File

View File

View File

0
api/db/migrations/20240927031102_/migration.sql Normal file → Executable file
View File

0
api/db/migrations/20240929164343_/migration.sql Normal file → Executable file
View File

View File

0
api/db/migrations/20241005014130_/migration.sql Normal file → Executable file
View File

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Handle" ADD VALUE 'matrix';

0
api/db/migrations/migration_lock.toml Normal file → Executable file
View File

1
api/db/schema.prisma Normal file → Executable file
View File

@ -25,6 +25,7 @@ enum Handle {
discord discord
twitch twitch
linkedin linkedin
matrix
github github
gitea gitea
forgejo forgejo

0
api/jest.config.js Normal file → Executable file
View File

13
api/package.json Normal file → Executable file
View 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.3.0", "@redwoodjs/api": "8.6.1",
"@redwoodjs/api-server": "8.3.0", "@redwoodjs/api-server": "8.6.1",
"@redwoodjs/auth-dbauth-api": "8.3.0", "@redwoodjs/auth-dbauth-api": "8.6.1",
"@redwoodjs/graphql-server": "8.3.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
View File

0
api/src/directives/requireAuth/requireAuth.ts Normal file → Executable file
View File

0
api/src/directives/skipAuth/skipAuth.ts Normal file → Executable file
View File

4
api/src/functions/auth.ts Normal file → Executable file
View File

@ -111,9 +111,7 @@ ${domain}/reset-password?resetToken=${resetToken}
// the database. Returning anything truthy will automatically log the user // the database. Returning anything truthy will automatically log the user
// in. Return `false` otherwise, and in the Reset Password page redirect the // in. Return `false` otherwise, and in the Reset Password page redirect the
// user to the login page. // user to the login page.
handler: (_user) => { handler: (_user) => false,
return true
},
// If `false` then the new password MUST be different from the current one // If `false` then the new password MUST be different from the current one
allowReusedPassword: true, allowReusedPassword: true,

0
api/src/functions/graphql.ts Normal file → Executable file
View File

0
api/src/graphql/.keep Normal file → Executable file
View File

0
api/src/graphql/portrait.sdl.ts Normal file → Executable file
View File

0
api/src/graphql/projects.sdl.ts Normal file → Executable file
View File

0
api/src/graphql/resume.sdl.ts Normal file → Executable file
View File

0
api/src/graphql/scalars.sdl.ts Normal file → Executable file
View File

1
api/src/graphql/socials.sdl.ts Normal file → Executable file
View File

@ -17,6 +17,7 @@ export const schema = gql`
discord discord
twitch twitch
linkedin linkedin
matrix
github github
gitea gitea
forgejo forgejo

0
api/src/graphql/tags.sdl.ts Normal file → Executable file
View File

0
api/src/graphql/title.sdl.ts Normal file → Executable file
View File

0
api/src/lib/auth.ts Normal file → Executable file
View File

16
api/src/lib/cors.ts Normal file → Executable file
View 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
View File

15
api/src/lib/email.ts Normal file → Executable file
View File

@ -8,22 +8,23 @@ 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) =>
return 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,
html, html,
}) })
}
export const censorEmail = (email: string): string => { export const censorEmail = (email: string): string => {
const [localPart, domain] = email.split('@') const [localPart, domain] = email.split('@')

0
api/src/lib/logger.ts Normal file → Executable file
View File

12
api/src/lib/tus.ts Normal file → Executable file
View 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
View 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
View File

0
api/src/services/portrait/portrait.ts Normal file → Executable file
View File

0
api/src/services/projects/projects.ts Normal file → Executable file
View File

0
api/src/services/resume/resume.ts Normal file → Executable file
View File

0
api/src/services/socials/socials.ts Normal file → Executable file
View File

0
api/src/services/tags/tags.ts Normal file → Executable file
View File

0
api/src/services/title/title.ts Normal file → Executable file
View File

2
api/tsconfig.json Normal file → Executable file
View 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
View 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
View File

0
jest.config.js Normal file → Executable file
View File

6
package.json Normal file → Executable file
View File

@ -7,9 +7,9 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@redwoodjs/auth-dbauth-setup": "8.3.0", "@redwoodjs/auth-dbauth-setup": "8.6.1",
"@redwoodjs/core": "8.3.0", "@redwoodjs/core": "8.6.1",
"@redwoodjs/project-config": "8.3.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
View File

2
redwood.toml Normal file → Executable file
View 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
View File

20
scripts/seed.ts Normal file → Executable file
View 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
View File

0
web/config/postcss.config.js Normal file → Executable file
View File

5
web/config/tailwind.config.js Normal file → Executable file
View File

@ -6,6 +6,7 @@ import {
SiTiktokHex, SiTiktokHex,
SiYoutubeHex, SiYoutubeHex,
SiLinkedinHex, SiLinkedinHex,
SiMatrixHex,
SiGithubHex, SiGithubHex,
SiGiteaHex, SiGiteaHex,
SiLeetcodeHex, SiLeetcodeHex,
@ -112,6 +113,10 @@ export const theme = {
light: SiLinkedinHex, light: SiLinkedinHex,
dark: SiLinkedinHex, dark: SiLinkedinHex,
}, },
matrix: {
light: SiMatrixHex,
dark: invertColor(SiMatrixHex),
},
github: { github: {
light: SiGithubHex, light: SiGithubHex,
dark: invertColor(SiGithubHex), dark: invertColor(SiGithubHex),

0
web/jest.config.js Normal file → Executable file
View File

15
web/package.json Normal file → Executable file
View 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.3.0", "@redwoodjs/auth-dbauth-web": "8.6.1",
"@redwoodjs/forms": "8.3.0", "@redwoodjs/forms": "8.6.1",
"@redwoodjs/router": "8.3.0", "@redwoodjs/router": "8.6.1",
"@redwoodjs/web": "8.3.0", "@redwoodjs/web": "8.6.1",
"@redwoodjs/web-server": "8.3.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.3.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
View File

0
web/public/favicon.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 757 B

After

Width:  |  Height:  |  Size: 757 B

0
web/public/no_portrait.webp Normal file → Executable file
View 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
View File

0
web/public/robots.txt Normal file → Executable file
View File

0
web/quick-lint-js.config Normal file → Executable file
View File

0
web/src/App.tsx Normal file → Executable file
View File

4
web/src/Routes.tsx Normal file → Executable file
View 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
View File

0
web/src/components/.keep Normal file → Executable file
View File

View 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
View File

0
web/src/components/Cell/CellFailure/CellFailure.tsx Normal file → Executable file
View File

0
web/src/components/Cell/CellLoading/CellLoading.tsx Normal file → Executable file
View File

24
web/src/components/ColorPicker/ColorPicker.tsx Normal file → Executable file
View File

@ -9,15 +9,12 @@ 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">
<section className="w-52">
<HexColorPicker color={color} onChange={setColor} /> <HexColorPicker color={color} onChange={setColor} />
</section> <div className="flex space-x-2">
<div className="flex space-x-2 w-52">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className="input input-bordered flex items-center gap-2 input-sm w-full grow"> <label className="input input-bordered flex items-center gap-2 input-sm grow">
<Icon path={mdiPound} className="size-4 opacity-70" /> <Icon path={mdiPound} className="size-4 opacity-70" />
<HexColorInput color={color} className="w-16" /> <HexColorInput color={color} className="w-16" />
</label> </label>
@ -37,10 +34,16 @@ const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
</button> </button>
<button <button
type="button" type="button"
className="btn btn-square btn-sm " className="btn btn-square btn-sm"
onClick={async () => { onClick={async () => {
try { try {
setColor(await navigator.clipboard.readText()) const clipboardText = await navigator.clipboard.readText()
const hexColorRegex =
/^#(?:(?:[\da-f]{3}){1,2}|(?:[\da-f]{4}){1,2})$/i
if (!hexColorRegex.test(clipboardText))
toast.error(`Text is not a valid hex color`)
else setColor(clipboardText)
} catch { } catch {
toast.error(`Failed to paste, please try again`) toast.error(`Failed to paste, please try again`)
} }
@ -50,7 +53,6 @@ const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
</button> </button>
</div> </div>
</div> </div>
) )
}
export default ColorPicker export default ColorPicker

View File

@ -1,14 +1,17 @@
import { useState, useRef, useLayoutEffect } from 'react' import { useState, useRef, useLayoutEffect } from 'react'
import SocialLinksCell from 'src/components/Social/SocialLinksCell' import { ContactCardPortrait } from 'types/graphql'
import SocialLinks from 'src/components/Social/SocialLinks/SocialLinks'
interface ContactCardProps { interface ContactCardProps {
portraitUrl: string portraitUrl: string
socials: ContactCardPortrait['socials']
} }
const ContactCard = ({ portraitUrl }: 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)
@ -50,25 +53,19 @@ const ContactCard = ({ portraitUrl }: 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 aspect-portrait object-cover" className="contact-me-image rounded-box aspect-portrait p-2 object-cover"
src={portraitUrl} src={portraitUrl}
alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`} alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`}
/> />
</figure> <div className="card-body mx-auto w-fit md:mx-0" ref={observedDiv}>
<div <h2 className="card-title justify-center text-3xl pb-2 md:justify-start">
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} />
<SocialLinksCell />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,9 +1,14 @@
import type { FindPortrait, FindPortraitVariables } from 'types/graphql'
import type { import type {
TypedDocumentNode, ContactCardPortrait,
CellFailureProps, ContactCardPortraitVariables,
CellSuccessProps, } from 'types/graphql'
import { routes } from '@redwoodjs/router'
import {
type TypedDocumentNode,
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'
@ -11,25 +16,49 @@ import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
import CellLoading from 'src/components/Cell/CellLoading/CellLoading' import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard' import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard'
export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> = export const QUERY: TypedDocumentNode<
gql` ContactCardPortrait,
ContactCardPortraitVariables
> = gql`
query ContactCardPortrait { query ContactCardPortrait {
portrait: portrait { portrait {
fileId fileId
} }
socials {
id
name
type
username
} }
` }
`
export const Loading = () => <CellLoading /> export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty /> export const Empty = () => <CellEmpty />
export const Failure = ({ error }: CellFailureProps<FindPortrait>) => ( export const Failure = ({ error }: CellFailureProps<ContactCardPortrait>) => (
<CellFailure error={error} /> <CellFailure error={error} />
) )
export const Success = ({ export const Success = ({
portrait, portrait,
}: CellSuccessProps<FindPortrait, FindPortraitVariables>) => ( socials,
<ContactCard portraitUrl={portrait.fileId} /> }: CellSuccessProps<ContactCardPortrait, ContactCardPortraitVariables>) => (
<>
<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
View File

0
web/src/components/FormTextList/FormTextList.tsx Normal file → Executable file
View File

70
web/src/components/PDF/PDF.tsx Executable file
View File

@ -0,0 +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 {
url: string
form?: boolean
}
const PDF = ({ url, form = false }: PDFProps) => {
const [numPages, setNumPages] = useState<number>(0)
function onLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages)
}
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState<number>(0)
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

View File

View File

@ -102,7 +102,7 @@ const PortraitForm = ({ portrait }: PortraitFormProps) => {
return ( return (
<div className="mx-auto w-fit space-y-2"> <div className="mx-auto w-fit space-y-2">
<img <img
className="aspect-portrait max-w-2xl rounded-xl object-cover" className="aspect-portrait max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl rounded-xl object-cover"
src={portrait?.fileId} src={portrait?.fileId}
alt={`${process.env.FIRST_NAME} Portrait`} alt={`${process.env.FIRST_NAME} Portrait`}
/> />
@ -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

View 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>&nbsp;</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 <a
href={link} href={link}
target="_blank" target="_blank"
className="badge badge-ghost text-nowrap" className="hidden sm:flex badge badge-ghost text-nowrap"
key={i} key={i}
rel="noreferrer" rel="noreferrer"
> >
{link} {link}
</a> </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>

View File

View 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
View 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>

9
web/src/components/Project/Project/Project.tsx Normal file → Executable file
View File

@ -69,16 +69,19 @@ const Project = ({ project }: Props) => {
</div> </div>
</> </>
)} )}
{project.images.length > 0 && (
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2> <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 &&
project.images.map((image, i) => (
<a <a
href={image} href={image}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
key={i} key={i}
className="rounded-xl" className="rounded-box"
> >
<img src={image} alt="" className="rounded-xl" /> <img src={image} alt="" className="rounded-xl" />
</a> </a>

28
web/src/components/Project/ProjectCell/ProjectCell.tsx Normal file → Executable file
View 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>) => (
<>
<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} /> <Project project={project} />
</>
) )

17
web/src/components/Project/ProjectForm/ProjectForm.tsx Normal file → Executable file
View 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