43 Commits

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
74db2e1034 README
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 1m1s
2024-10-08 21:21:06 -04:00
e2dfb6f237 Implement rich text for project description 2024-10-08 20:36:48 -04:00
708634fa68 Fix db seed overwriting password every time 2024-10-08 15:45:21 -04:00
178 changed files with 3742 additions and 2788 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
LAST_NAME=lastname
GMAIL=example@gmail.com
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
DEFAULT_THEME=light
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
API_DOMAIN=api.example.com

17
.gitea/workflows/ci.yml Normal file → Executable file
View File

@ -10,20 +10,27 @@ jobs:
build:
name: Publish Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v3
- 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
run: |
docker build -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
docker build \
--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
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:20-bookworm-slim as base
FROM node:lts-alpine 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.
# 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/*
RUN apk add --no-cache openssl && corepack enable
USER node
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 graphql.config.js .
COPY --chown=node:node .env.defaults .env.defaults
# 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!)
FROM base AS api_build
ARG ADDRESS_PROD
ARG ADDRESS_DEV
ARG DOMAIN
ARG API_DOMAIN
ARG MAX_HTTP_CONNECTIONS_PER_MINUTE
ARG GMAIL
ARG GMAIL_SMTP_PASSWORD
ARG SMTP_HOST
ARG SMTP_PORT
ARG SMTP_SECURE
ARG SMTP_USER
ARG SMTP_PASSWORD
ARG EMAIL_FROM
ARG EMAIL_TO
ARG FIRST_NAME
ARG LAST_NAME
ARG APP_VERSION
COPY --chown=node:node api 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 LAST_NAME
ARG COUNTRY
ARG STATE
ARG CITY
ARG DEFAULT_THEME
ARG API_ADDRESS_PROD
ARG API_ADDRESS_DEV
ARG APP_VERSION
ENV APP_VERSION=${APP_VERSION}
COPY --chown=node:node web web
RUN yarn rw build web
# web build
# ---------
FROM base as web_build
FROM base AS web_build
ARG FIRST_NAME
ARG LAST_NAME
ARG COUNTRY
ARG STATE
ARG CITY
ARG DEFAULT_THEME
ARG API_ADDRESS_PROD
ARG API_ADDRESS_DEV
ARG APP_VERSION
ENV APP_VERSION=${APP_VERSION}
COPY --chown=node:node web web
RUN yarn rw build web --no-prerender
# api serve
# ---------
FROM node:20-bookworm-slim as api_serve
FROM node:lts-alpine AS api_serve
RUN corepack enable
RUN apk add --no-cache openssl && corepack enable
RUN apt-get update && apt-get install -y \
openssl \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /home/node/app/api/files_prod \
&& chown -R node:node /home/node/app/api/files_prod
USER node
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 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/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
ENV NODE_ENV=production
ARG APP_VERSION
# default api serve command
# ---------
# 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.
ENV NODE_ENV=production
ENV APP_VERSION=${APP_VERSION}
CMD [ "./api/dist/server.js" ]
# CMD [ "node_modules/.bin/rw-server", "api" ]
# web serve
# ---------
FROM node:20-bookworm-slim as web_serve
FROM node:lts-alpine AS web_serve
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 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
ARG APP_VERSION
ENV APP_VERSION=${APP_VERSION}
ENV NODE_ENV=production \
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"
# console
# -------
FROM base as console
# To add more packages:
#
# ```
# USER root
#
# RUN apt-get update && apt-get install -y \
# curl
#
# USER node
# ```
FROM base AS console
COPY --chown=node:node api api
COPY --chown=node:node web web

199
README.md Normal file → Executable file
View File

@ -1,122 +1,87 @@
# README
# Portfolio Website
## Setup
### Domain Records
Create two A records, one for the web side of the website and one for the api side of the website. Ideally, the records should look something like `myportfolio.example.com` for the web side and `api.myportfolio.example.com`, but it is not important.
### Reverse Proxy
- 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)
2. Point the api domain to the api port (default: 8911)
### SMTP
You will need credentials to authorize sending Email, instructions vary depending on provider (Gmail, Hotmail, etc).
### [Docker Compose](./docker-compose.yml)
```yaml
version: '3.8'
Welcome to [RedwoodJS](https://redwoodjs.com)!
services:
portfolio:
container_name: portfolio
image: git.altaiar.dev/ahmed/portfolio:latest
restart: unless-stopped
environment:
- NODE_ENV=production
- API_PROXY_TARGET=http://localhost:8911
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
- FIRST_NAME=first name # Your first name
- LAST_NAME=lastname # Your last name
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
- 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
- API_DOMAIN=api.portfolio.example.com
# Careful, addresses below must not end with a '/'
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
ports:
- 8910:8910 # Web
- 8911:8911 # API
depends_on:
db:
condition: service_healthy
volumes:
- files:/home/node/app/api/files_prod
command: >
/bin/sh -c "
yarn rw build &&
yarn rw prisma migrate deploy &&
yarn rw prisma db seed &&
yarn rw serve"
> **Prerequisites**
>
> - Redwood requires [Node.js](https://nodejs.org/en/) (=20.x) and [Yarn](https://yarnpkg.com/)
> - Are you on Windows? For best results, follow our [Windows development setup](https://redwoodjs.com/docs/how-to/windows-development-setup) guide
Start by installing dependencies:
db:
container_name: portfolio-db
image: postgres:16-bookworm
environment:
- POSTGRES_USER=redwood
- POSTGRES_PASSWORD=changeme # Change to a more secure password
- POSTGRES_DB=portfolio
restart: unless-stopped
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:
- postgres:/var/lib/postgresql/data
volumes:
postgres:
files: # For persistent file storage across upgrades
```
yarn install
```
## Logging In
- 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 correctly configured [SMTP](#smtp), you should receive an Email from [`EMAIL_FROM`](#docker-compose) to [`EMAIL_TO`](#docker-compose)
- The Email contains the link needed to change your password
### Default Credentials
**Username:** `admin`
Then start the development server:
```
yarn redwood dev
```
Your browser should automatically open to [http://localhost:8910](http://localhost:8910) where you'll see the Welcome Page, which links out to many great resources.
> **The Redwood CLI**
>
> Congratulations on running your first Redwood CLI command! From dev to deploy, the CLI is with you the whole way. And there's quite a few commands at your disposal:
>
> ```
> yarn redwood --help
> ```
>
> For all the details, see the [CLI reference](https://redwoodjs.com/docs/cli-commands).
## Prisma and the database
Redwood wouldn't be a full-stack framework without a database. It all starts with the schema. Open the [`schema.prisma`](api/db/schema.prisma) file in `api/db` and replace the `UserExample` model with the following `Post` model:
```prisma
model Post {
id Int @id @default(autoincrement())
title String
body String
createdAt DateTime @default(now())
}
```
Redwood uses [Prisma](https://www.prisma.io/), a next-gen Node.js and TypeScript ORM, to talk to the database. Prisma's schema offers a declarative way of defining your app's data models. And Prisma [Migrate](https://www.prisma.io/migrate) uses that schema to make database migrations hassle-free:
```
yarn rw prisma migrate dev
# ...
? Enter a name for the new migration: create posts
```
> `rw` is short for `redwood`
You'll be prompted for the name of your migration. `create posts` will do.
Now let's generate everything we need to perform all the CRUD (Create, Retrieve, Update, Delete) actions on our `Post` model:
```
yarn redwood generate scaffold post
```
Navigate to [http://localhost:8910/posts/new](http://localhost:8910/posts/new), fill in the title and body, and click "Save".
Did we just create a post in the database? Yup! With `yarn rw generate scaffold <model>`, Redwood created all the pages, components, and services necessary to perform all CRUD actions on our posts table.
## Frontend first with Storybook
Don't know what your data models look like? That's more than ok—Redwood integrates Storybook so that you can work on design without worrying about data. Mockup, build, and verify your React components, even in complete isolation from the backend:
```
yarn rw storybook
```
Seeing "Couldn't find any stories"? That's because you need a `*.stories.{tsx,jsx}` file. The Redwood CLI makes getting one easy enough—try generating a [Cell](https://redwoodjs.com/docs/cells), Redwood's data-fetching abstraction:
```
yarn rw generate cell examplePosts
```
The Storybook server should hot reload and now you'll have four stories to work with. They'll probably look a little bland since there's no styling. See if the Redwood CLI's `setup ui` command has your favorite styling library:
```
yarn rw setup ui --help
```
## Testing with Jest
It'd be hard to scale from side project to startup without a few tests. Redwood fully integrates Jest with both the front- and back-ends, and makes it easy to keep your whole app covered by generating test files with all your components and services:
```
yarn rw test
```
To make the integration even more seamless, Redwood augments Jest with database [scenarios](https://redwoodjs.com/docs/testing#scenarios) and [GraphQL mocking](https://redwoodjs.com/docs/testing#mocking-graphql-calls).
## Ship it
Redwood is designed for both serverless deploy targets like Netlify and Vercel and serverful deploy targets like Render and AWS:
```
yarn rw setup deploy --help
```
Don't go live without auth! Lock down your app with Redwood's built-in, database-backed authentication system ([dbAuth](https://redwoodjs.com/docs/authentication#self-hosted-auth-installation-and-setup)), or integrate with nearly a dozen third-party auth providers:
```
yarn rw setup auth --help
```
## Next Steps
The best way to learn Redwood is by going through the comprehensive [tutorial](https://redwoodjs.com/docs/tutorial/foreword) and joining the community (via the [Discourse forum](https://community.redwoodjs.com) or the [Discord server](https://discord.gg/redwoodjs)).
## Quick Links
- Stay updated: read [Forum announcements](https://community.redwoodjs.com/c/announcements/5), follow us on [Twitter](https://twitter.com/redwoodjs), and subscribe to the [newsletter](https://redwoodjs.com/newsletter)
- [Learn how to contribute](https://redwoodjs.com/docs/contributing)
**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
twitch
linkedin
matrix
github
gitea
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": {
"@fastify/cors": "^9.0.1",
"@fastify/rate-limit": "^9.1.0",
"@redwoodjs/api": "8.3.0",
"@redwoodjs/api-server": "8.3.0",
"@redwoodjs/auth-dbauth-api": "8.3.0",
"@redwoodjs/graphql-server": "8.3.0",
"@tus/file-store": "^1.4.0",
"@tus/server": "^1.7.0",
"@redwoodjs/api": "8.6.1",
"@redwoodjs/api-server": "8.6.1",
"@redwoodjs/auth-dbauth-api": "8.6.1",
"@redwoodjs/graphql-server": "8.6.1",
"@tus/file-store": "1.4.0",
"@tus/server": "1.7.0",
"countries-list": "^3.1.1",
"graphql-scalars": "^1.23.0",
"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
// in. Return `false` otherwise, and in the Reset Password page redirect the
// user to the login page.
handler: (_user) => {
return true
},
handler: (_user) => false,
// If `false` then the new password MUST be different from the current one
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
twitch
linkedin
matrix
github
gitea
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'
export const setCorsHeaders = (res: FastifyReply) => {
export const setCorsHeaders = (
res: FastifyReply,
isPublic: boolean = false
) => {
res.raw.setHeader(
'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(
'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'
)
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({
service: 'gmail',
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.GMAIL,
pass: process.env.GMAIL_SMTP_PASSWORD,
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
})
export const sendEmail = async ({ to, subject, text, html }: Options) => {
return await transporter.sendMail({
from: `"${process.env.FIRST_NAME} ${process.env.LAST_NAME} (noreply)" <${process.env.GMAIL}>`,
export const sendEmail = async ({ to, subject, text, html }: Options) =>
await transporter.sendMail({
from: `${process.env.FIRST_NAME} ${process.env.LAST_NAME} <${process.env.EMAIL_FROM}>`,
to: Array.isArray(to) ? to : [to],
subject,
text,
html,
})
}
export const censorEmail = (email: string): string => {
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,
isPublicEndpoint: boolean
) => {
res.hijack()
if (req.method === 'GET' && isPublicEndpoint) {
setCorsHeaders(res)
}
if (isProduction) {
if (req.method === 'OPTIONS') handleOptionsRequest(res)
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)) {
if (req.headers.cookie) handleAuthenticatedRequest(req, res, tusHandler)
else {
@ -40,8 +46,8 @@ export const handleTusUpload = (
res.raw.end('Method not allowed')
}
} else {
setCorsHeaders(res)
tusHandler.handle(req.raw, res.raw)
setCorsHeaders(res, isPublicEndpoint)
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 { handleTusUpload } from 'src/lib/tus'
enum Theme {
light = 'light',
dark = 'dark',
}
;(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({
logger,
configureApiServer: async (server) => {
@ -34,7 +54,10 @@ import { handleTusUpload } from 'src/lib/tus'
datastore: new FileStore({
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(
@ -42,12 +65,14 @@ import { handleTusUpload } from 'src/lib/tus'
(_request, _payload, done) => done(null)
)
server.all('/files', (req, res) =>
server.all('/files', (req, res) => {
res.hijack()
handleTusUpload(req, res, tusServer, false)
)
server.all('/files/*', (req, res) =>
})
server.all('/files/*', (req, res) => {
res.hijack()
handleTusUpload(req, res, tusServer, true)
)
})
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,
"target": "ES2023",
"module": "Node16",
"moduleResolution": "Node16",
"moduleResolution": "node16",
"skipLibCheck": false,
"rootDirs": [
"./src",

View File

@ -1,58 +0,0 @@
services:
redwood:
build:
context: .
dockerfile: ./Dockerfile
target: base
command: yarn rw dev
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
ports:
- '8910:8910'
depends_on:
- db
environment:
- DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood
- TEST_DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood_test
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
- CI=
- NODE_ENV=development
- REDWOOD_API_HOST=0.0.0.0
db:
image: postgres:16-bookworm
environment:
POSTGRES_USER: redwood
POSTGRES_PASSWORD: redwood
POSTGRES_DB: redwood
ports:
- '5432:5432'
volumes:
- postgres:/var/lib/postgresql/data
# After starting with `docker compose -f ./docker-compose.dev.yml up`,
# use the console to run commands in the container:
#
# ```
# docker compose -f ./docker-compose.dev.yml run --rm -it console /bin/bash
# root@...:/home/node/app# yarn rw prisma migrate dev
# ```
console:
user: root
build:
context: .
dockerfile: ./Dockerfile
target: console
tmpfs:
- /tmp
command: 'true'
environment:
- DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood
- TEST_DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood_test
depends_on:
- db
volumes:
node_modules:
postgres:

View File

@ -1,63 +0,0 @@
services:
api:
build:
context: .
dockerfile: ./Dockerfile
target: api_serve
# Without a command specified, the Dockerfile's api_serve CMD will be used.
# If you are using a custom server file, you should either use the following
# command to launch your server or update the Dockerfile to do so.
# This is important if you intend to configure GraphQL to use Realtime.
# command: "./api/dist/server.js"
ports:
- '8911:8911'
depends_on:
- db
environment:
- DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood
- TEST_DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood_test
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
web:
build:
context: .
dockerfile: ./Dockerfile
target: web_serve
ports:
- '8910:8910'
depends_on:
- api
environment:
- API_PROXY_TARGET=http://api:8911
db:
image: postgres:16-bookworm
environment:
POSTGRES_USER: redwood
POSTGRES_PASSWORD: redwood
POSTGRES_DB: redwood
ports:
- '5432:5432'
volumes:
- ./postgres:/var/lib/postgresql/data
# After starting with `docker compose -f ./docker-compose.prod.yml up`,
# use the console to run commands in the container:
#
# ```
# docker compose -f ./docker-compose.prod.yml run --rm -it console /bin/bash
# ```
console:
user: root
build:
context: .
dockerfile: ./Dockerfile
target: console
tmpfs:
- /tmp
command: 'true'
environment:
- DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood
- TEST_DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood_test
depends_on:
- db

65
docker-compose.yml Executable file
View File

@ -0,0 +1,65 @@
version: '3.8'
services:
portfolio:
container_name: portfolio
image: git.altaiar.dev/ahmed/portfolio:latest
restart: unless-stopped
environment:
- NODE_ENV=production
- API_PROXY_TARGET=http://localhost:8911
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
- FIRST_NAME=first name # Your first name
- LAST_NAME=lastname # Your last name
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
- 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
- API_DOMAIN=api.portfolio.example.com
# Careful, addresses below must not end with a '/'
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
ports:
- 8910:8910 # Web
- 8911:8911 # API
depends_on:
db:
condition: service_healthy
volumes:
- files:/home/node/app/api/files_prod
command: >
/bin/sh -c "
yarn rw build &&
yarn rw prisma migrate deploy &&
yarn rw prisma db seed &&
yarn rw serve"
db:
container_name: portfolio-db
image: postgres:16-bookworm
environment:
- POSTGRES_USER=redwood
- POSTGRES_PASSWORD=changeme # Change to a more secure password
- POSTGRES_DB=portfolio
restart: unless-stopped
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:
- postgres:/var/lib/postgresql/data
volumes:
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": {
"@redwoodjs/auth-dbauth-setup": "8.3.0",
"@redwoodjs/core": "8.3.0",
"@redwoodjs/project-config": "8.3.0",
"@redwoodjs/auth-dbauth-setup": "8.6.1",
"@redwoodjs/core": "8.6.1",
"@redwoodjs/project-config": "8.6.1",
"prettier-plugin-tailwindcss": "0.4.1"
},
"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}"
port = 8910
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]
tests = false
stories = false

0
scripts/.keep Normal file → Executable file
View File

58
scripts/seed.ts Normal file → Executable file
View File

@ -3,52 +3,48 @@ import { db } from 'api/src/lib/db'
import { hashPassword } from '@redwoodjs/auth-dbauth-api'
const MAX_TITLES = 5
export default async () => {
try {
const admin = {
username: 'admin',
email: process.env.GMAIL,
password: process.env.GMAIL_SMTP_PASSWORD,
email: process.env.EMAIL_TO,
password: process.env.SMTP_PASSWORD,
}
const [hashedPassword, salt] = hashPassword(admin.password)
await db.user.upsert({
const existingAdmin = await db.user.findFirst({
where: {
email: admin.email,
},
create: {
username: admin.username,
email: admin.email,
hashedPassword,
salt,
},
update: {
username: admin.username,
hashedPassword,
salt,
},
})
if (!existingAdmin)
await db.user.create({
data: {
username: admin.username,
email: admin.email,
hashedPassword,
salt,
},
})
else
await db.user.update({
where: { id: existingAdmin.id },
data: {
username: admin.username,
email: admin.email,
},
})
const titles = await db.titles.findFirst()
await db.titles.upsert({
where: {
id: 1,
},
create: {
titles: Array.from({ length: MAX_TITLES }).map(
(_, i) => `a title ${i + 1}`
),
},
update: {
titles:
titles?.titles ||
Array.from({ length: MAX_TITLES }).map((_, i) => `a title ${i + 1}`),
},
})
if (!titles)
await db.titles.create({
data: {
titles: Array.from({ length: 3 }).map((_, i) => `title ${i + 1}`),
},
})
} catch (error) {
console.error(error)
}

0
scripts/tsconfig.json Normal file → Executable file
View File

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

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

@ -6,6 +6,7 @@ import {
SiTiktokHex,
SiYoutubeHex,
SiLinkedinHex,
SiMatrixHex,
SiGithubHex,
SiGiteaHex,
SiLeetcodeHex,
@ -112,6 +113,10 @@ export const theme = {
light: SiLinkedinHex,
dark: SiLinkedinHex,
},
matrix: {
light: SiMatrixHex,
dark: invertColor(SiMatrixHex),
},
github: {
light: SiGithubHex,
dark: invertColor(SiGithubHex),
@ -141,7 +146,7 @@ export const theme = {
}
export const darkMode = ['class', '[data-theme="dark"]']
export const plugins = [require('daisyui')]
export const plugins = [require('@tailwindcss/typography'), require('daisyui')]
export const daisyui = {
themes: [
'light',

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

24
web/package.json Normal file → Executable file
View File

@ -14,11 +14,18 @@
"@icons-pack/react-simple-icons": "^10.0.0",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@redwoodjs/auth-dbauth-web": "8.3.0",
"@redwoodjs/forms": "8.3.0",
"@redwoodjs/router": "8.3.0",
"@redwoodjs/web": "8.3.0",
"@redwoodjs/web-server": "8.3.0",
"@redwoodjs/auth-dbauth-web": "8.6.1",
"@redwoodjs/forms": "8.6.1",
"@redwoodjs/router": "8.6.1",
"@redwoodjs/web": "8.6.1",
"@redwoodjs/web-server": "8.6.1",
"@tailwindcss/typography": "^0.5.15",
"@tiptap/extension-link": "^2.8.0",
"@tiptap/extension-text-style": "^2.8.0",
"@tiptap/extension-underline": "^2.8.0",
"@tiptap/pm": "^2.8.0",
"@tiptap/react": "^2.8.0",
"@tiptap/starter-kit": "^2.8.0",
"@uppy/compressor": "^2.0.1",
"@uppy/core": "^4.1.0",
"@uppy/dashboard": "^4.0.2",
@ -28,17 +35,20 @@
"@uppy/react": "^4.0.1",
"@uppy/tus": "^4.0.0",
"@uppy/webcam": "^4.0.1",
"countries-list": "^3.1.1",
"date-fns": "^4.1.0",
"humanize-string": "2.1.0",
"react": "18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "18.3.1",
"react-typed": "^2.0.12"
"react-html-parser": "^2.0.2",
"react-pdf": "^9.2.1"
},
"devDependencies": {
"@redwoodjs/vite": "8.3.0",
"@redwoodjs/vite": "8.6.1",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@types/react-html-parser": "^2",
"autoprefixer": "^10.4.20",
"daisyui": "^4.12.10",
"postcss": "^8.4.41",

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

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

@ -5,7 +5,6 @@ import { AuthProvider, useAuth } from 'src/auth'
import FatalErrorPage from 'src/pages/FatalErrorPage'
import Routes from 'src/Routes'
import 'src/scaffold.css'
import 'src/index.css'
const App = () => (

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" />
</Set>
<Set wrap={ScaffoldLayout} title="Resume" titleTo="adminResume">
<Set wrap={ScaffoldLayout} title="Résumé" titleTo="adminResume">
<Route path="/admin/resume" page={ResumeAdminResumePage} name="adminResume" />
</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/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" />
<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

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

@ -9,48 +9,50 @@ interface ColorPickerProps {
setColor: React.Dispatch<React.SetStateAction<string>>
}
const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
return (
<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} />
</section>
<div className="flex space-x-2 w-52">
{/* 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">
<Icon path={mdiPound} className="size-4 opacity-70" />
<HexColorInput color={color} className="w-16" />
</label>
<button
type="button"
className="btn btn-square btn-sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(color)
toast.success('Copied color to clipboard')
} catch {
toast.error(`Failed to copy, please try again`)
}
}}
>
<Icon path={mdiContentCopy} className="size-4" />
</button>
<button
type="button"
className="btn btn-square btn-sm "
onClick={async () => {
try {
setColor(await navigator.clipboard.readText())
} catch {
toast.error(`Failed to paste, please try again`)
}
}}
>
<Icon path={mdiContentPaste} className="size-4" />
</button>
</div>
const ColorPicker = ({ color, setColor }: ColorPickerProps) => (
<div className="flex flex-col space-y-2 bg-base-100 p-2 rounded-xl">
<HexColorPicker color={color} onChange={setColor} />
<div className="flex space-x-2">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className="input input-bordered flex items-center gap-2 input-sm grow">
<Icon path={mdiPound} className="size-4 opacity-70" />
<HexColorInput color={color} className="w-16" />
</label>
<button
type="button"
className="btn btn-square btn-sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(color)
toast.success('Copied color to clipboard')
} catch {
toast.error(`Failed to copy, please try again`)
}
}}
>
<Icon path={mdiContentCopy} className="size-4" />
</button>
<button
type="button"
className="btn btn-square btn-sm"
onClick={async () => {
try {
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 {
toast.error(`Failed to paste, please try again`)
}
}}
>
<Icon path={mdiContentPaste} className="size-4" />
</button>
</div>
)
}
</div>
)
export default ColorPicker

View File

@ -1,14 +1,17 @@
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 {
portraitUrl: string
socials: ContactCardPortrait['socials']
}
const ContactCard = ({ portraitUrl }: ContactCardProps) => {
const [width, setWidth] = useState()
const [height, setHeight] = useState()
const ContactCard = ({ portraitUrl, socials }: ContactCardProps) => {
const [width, setWidth] = useState<number>(0)
const [height, setHeight] = useState<number>(0)
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="card bg-base-100 shadow-xl md:card-side">
<figure>
<img
className="contact-me-image aspect-portrait object-cover"
src={portraitUrl}
alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`}
/>
</figure>
<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">
<div className="flex items-center justify-center">
<div className="card card-compact bg-base-100 shadow-xl md:card-side">
<img
className="contact-me-image rounded-box aspect-portrait p-2 object-cover"
src={portraitUrl}
alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`}
/>
<div className="card-body mx-auto w-fit md:mx-0" ref={observedDiv}>
<h2 className="card-title justify-center text-3xl pb-2 md:justify-start">
Contact Me
</h2>
<p className="p-2"></p>
<div className="card-actions">
<SocialLinksCell />
<div className="card-actions rounded-btn">
<SocialLinks socials={socials} />
</div>
</div>
</div>

View File

@ -1,9 +1,14 @@
import type { FindPortrait, FindPortraitVariables } from 'types/graphql'
import type {
TypedDocumentNode,
CellFailureProps,
CellSuccessProps,
ContactCardPortrait,
ContactCardPortraitVariables,
} from 'types/graphql'
import { routes } from '@redwoodjs/router'
import {
type TypedDocumentNode,
type CellFailureProps,
type CellSuccessProps,
Metadata,
} from '@redwoodjs/web'
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 ContactCard from 'src/components/ContactCard/ContactCard/ContactCard'
export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
gql`
query ContactCardPortrait {
portrait: portrait {
fileId
}
export const QUERY: TypedDocumentNode<
ContactCardPortrait,
ContactCardPortraitVariables
> = gql`
query ContactCardPortrait {
portrait {
fileId
}
`
socials {
id
name
type
username
}
}
`
export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({ error }: CellFailureProps<FindPortrait>) => (
export const Failure = ({ error }: CellFailureProps<ContactCardPortrait>) => (
<CellFailure error={error} />
)
export const Success = ({
portrait,
}: CellSuccessProps<FindPortrait, FindPortraitVariables>) => (
<ContactCard portraitUrl={portrait.fileId} />
socials,
}: 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 (
<div className="mx-auto w-fit space-y-2">
<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}
alt={`${process.env.FIRST_NAME} Portrait`}
/>
@ -127,14 +127,13 @@ const PortraitForm = ({ portrait }: PortraitFormProps) => {
)
else
return (
<div className="mx-auto w-fit space-y-2">
<div className="mx-auto max-w-prose space-y-2">
{!fileId ? (
<>
<Uploader
onComplete={onUploadComplete}
width="22rem"
height="34.5rem"
className="flex justify-center"
width="auto"
height="30rem"
/>
<p className="text-center">
High quality, 4:5 aspect ratio image recommended

View File

@ -1,3 +1,4 @@
import parseHtml from 'react-html-parser'
import type {
DeleteProjectMutation,
DeleteProjectMutationVariables,
@ -45,37 +46,40 @@ const AdminProject = ({ project }: Props) => {
}
return (
<div className="flex w-full justify-center">
<div className="flex justify-center">
<div>
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="table">
<thead className="bg-base-200 font-syne">
<tr>
<th className="w-0">
<th colSpan={2}>
Project {project.id}: {project.title}
</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<th>ID</th>
<th className="text-right">ID</th>
<td>{project.id}</td>
</tr>
<tr>
<th>Title</th>
<th className="text-right">Title</th>
<td>{project.title}</td>
</tr>
<tr>
<th>Description</th>
<td>{project.description}</td>
<th className="text-right">Description</th>
<td>
<article className="prose">
{parseHtml(project.description)}
</article>
</td>
</tr>
<tr>
<th>Date</th>
<th className="text-right">Date</th>
<td>{timeTag(project.date)}</td>
</tr>
<tr>
<th>Images</th>
<th className="text-right">Images</th>
<td>
<div className="flex flex-wrap gap-2">
{project.images.map((image, i) => (
@ -83,7 +87,7 @@ const AdminProject = ({ project }: Props) => {
key={i}
href={image}
target="_blank"
className="btn btn-sm btn-square"
className={`btn btn-sm btn-square ${i === 0 && 'btn-primary'}`}
rel="noreferrer"
>
{i + 1}
@ -93,7 +97,7 @@ const AdminProject = ({ project }: Props) => {
</td>
</tr>
<tr>
<th>Tags</th>
<th className="text-right">Tags</th>
<td>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag, i) => (
@ -115,19 +119,30 @@ const AdminProject = ({ project }: Props) => {
</td>
</tr>
<tr>
<th>Links</th>
<th className="text-right">Links</th>
<td>
<div className="flex flex-wrap gap-2">
{project.links.map((link, i) => (
<a
href={link}
target="_blank"
className="badge badge-ghost text-nowrap"
key={i}
rel="noreferrer"
>
{link}
</a>
<>
<a
href={link}
target="_blank"
className="hidden sm:flex badge badge-ghost text-nowrap"
key={i}
rel="noreferrer"
>
{link}
</a>
<a
href={link}
target="_blank"
className="btn btn-sm btn-square sm:hidden"
key={i}
rel="noreferrer"
>
{i + 1}
</a>
</>
))}
</div>
</td>

View File

View File

@ -73,12 +73,12 @@ export const Success = ({ project }: CellSuccessProps<EditProjectById>) => {
) => updateProject({ variables: { id, input } })
return (
<div className="flex w-full justify-center">
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="table w-80">
<div className="flex mx-auto max-w-prose justify-center">
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
<table className="table">
<thead className="bg-base-200 font-syne">
<tr>
<th className="w-0">Edit Project {project.id}</th>
<th>Edit Project {project.id}</th>
</tr>
</thead>
<tbody>

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

@ -38,9 +38,9 @@ const NewProject = () => {
createProject({ variables: { input } })
return (
<div className="flex w-full justify-center">
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="table w-80">
<div className="flex mx-auto max-w-prose justify-center">
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
<table className="table">
<thead className="bg-base-200 font-syne">
<tr>
<th>New Project</th>

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

@ -1,6 +1,7 @@
import { mdiLinkVariant } from '@mdi/js'
import Icon from '@mdi/react'
import { format, isAfter, startOfToday } from 'date-fns'
import parseHtml from 'react-html-parser'
import type { FindProjectById } from 'types/graphql'
import { calculateLuminance } from 'src/lib/color'
@ -41,7 +42,9 @@ const Project = ({ project }: Props) => {
))}
</div>
)}
{project.description && <p>{project.description}</p>}
{project.description && (
<article className="prose">{parseHtml(project.description)}</article>
)}
{project.links.length > 0 && (
<>
<h2 className="font-bold text-3xl w-fit">Links</h2>
@ -66,20 +69,23 @@ const Project = ({ project }: Props) => {
</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 className="flex flex-wrap gap-4 items-center pt-8 justify-center">
{project.images.map((image, i) => (
<a
href={image}
target="_blank"
rel="noreferrer"
key={i}
className="rounded-xl"
>
<img src={image} alt="" className="rounded-xl" />
</a>
))}
<div className="flex flex-wrap gap-4 pt-8 justify-center h-fit sm:p-8">
{project.images.length > 0 &&
project.images.map((image, i) => (
<a
href={image}
target="_blank"
rel="noreferrer"
key={i}
className="rounded-box"
>
<img src={image} alt="" className="rounded-xl" />
</a>
))}
</div>
</div>
)

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