20 Commits

Author SHA1 Message Date
Ahmed Al-Taiar
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
Ahmed Al-Taiar
353fb3899e PDF error handling 2024-10-23 12:26:21 -04:00
Ahmed Al-Taiar
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
Ahmed Al-Taiar
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
Ahmed Al-Taiar
cbf75acbeb Fix empty images section if no images + Remove postgres port 2024-10-17 20:46:34 -04:00
Ahmed Al-Taiar
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
Ahmed Al-Taiar
6540329f36 Use SMTP credentials instead of Gmail auth for Email 2024-10-16 21:36:57 -04:00
Ahmed Al-Taiar
bac5b5fe48 Revert prefers-color-scheme 2024-10-16 21:21:58 -04:00
Ahmed Al-Taiar
f3f75d3e57 Fix file uploads clearing on upgrade 2024-10-15 22:51:41 -04:00
Ahmed Al-Taiar
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
Ahmed Al-Taiar
77db153fe6 Add Matrix social 2024-10-15 14:51:43 -04:00
Ahmed Al-Taiar
684d6f88c2 Combine GraphQL queries for contact card (2 -> 1) 2024-10-15 14:30:05 -04:00
Ahmed Al-Taiar
03f606bbde Improve color picker paste logic 2024-10-15 14:21:52 -04:00
Ahmed Al-Taiar
1eafaee2c0 Follow system color scheme by default instead of light 2024-10-10 14:09:09 -04:00
Ahmed Al-Taiar
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
Ahmed Al-Taiar
738260f7de Watermark 2024-10-09 20:37:27 -04:00
Ahmed Al-Taiar
82313bef46 Simplify PDF embed 2024-10-09 20:32:39 -04:00
Ahmed Al-Taiar
74db2e1034 README
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 1m1s
2024-10-08 21:21:06 -04:00
Ahmed Al-Taiar
e2dfb6f237 Implement rich text for project description 2024-10-08 20:36:48 -04:00
Ahmed Al-Taiar
708634fa68 Fix db seed overwriting password every time 2024-10-08 15:45:21 -04:00
68 changed files with 2153 additions and 1174 deletions

View File

@@ -18,8 +18,15 @@ PRISMA_HIDE_UPDATE_MESSAGE=true
FIRST_NAME=firstname FIRST_NAME=firstname
LAST_NAME=lastname LAST_NAME=lastname
GMAIL=example@gmail.com COUNTRY=US
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
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

View File

@@ -6,8 +6,15 @@
FIRST_NAME=firstname FIRST_NAME=firstname
LAST_NAME=lastname LAST_NAME=lastname
GMAIL=example@gmail.com COUNTRY=US
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
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

View File

@@ -43,8 +43,13 @@ 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

188
README.md
View File

@@ -1,122 +1,82 @@
# 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
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
- 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
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
- POSTGRES_DB=portfolio
volumes:
- postgres:/var/lib/postgresql/data
volumes:
postgres:
files: # For persistent file storage across upgrades
``` ```
yarn install ## Fix Files Ownership
The `files` volume in Docker is owned by `root`, since the portfolio container runs under the `node` user, file uploads will fail. Run this command to give ownership to the `node` user:
``` ```
sudo docker exec -u root portfolio chown -R node:node /home/node/app/api/files_prod
Then start the development server:
```
yarn redwood dev
``` ```
## 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`
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. **Password:** [`SMTP_PASSWORD`](#docker-compose)
> **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)

View File

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

View File

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

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.4.0",
"@redwoodjs/api-server": "8.3.0", "@redwoodjs/api-server": "8.4.0",
"@redwoodjs/auth-dbauth-api": "8.3.0", "@redwoodjs/auth-dbauth-api": "8.4.0",
"@redwoodjs/graphql-server": "8.3.0", "@redwoodjs/graphql-server": "8.4.0",
"@tus/file-store": "^1.4.0", "@tus/file-store": "^1.4.0",
"@tus/server": "^1.7.0", "@tus/server": "^1.7.0",
"country-flag-icons": "^1.5.13",
"graphql-scalars": "^1.23.0", "graphql-scalars": "^1.23.0",
"nodemailer": "^6.9.14" "nodemailer": "^6.9.14"
}, },

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,

View File

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

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('@')

View File

@@ -10,6 +10,13 @@ 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'
;(async () => { ;(async () => {
const { hasFlag } = await import('country-flag-icons')
if (!hasFlag(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'
)
const server = await createServer({ const server = await createServer({
logger, logger,
configureApiServer: async (server) => { configureApiServer: async (server) => {

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

55
docker-compose.yml Normal file
View File

@@ -0,0 +1,55 @@
version: '3.8'
services:
portfolio:
container_name: portfolio
image: git.altaiar.dev/ahmed/portfolio:latest
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
- 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
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
- POSTGRES_DB=portfolio
volumes:
- postgres:/var/lib/postgresql/data
volumes:
postgres:
files: # For persistent file storage across upgrades

View File

@@ -7,9 +7,9 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@redwoodjs/auth-dbauth-setup": "8.3.0", "@redwoodjs/auth-dbauth-setup": "8.4.0",
"@redwoodjs/core": "8.3.0", "@redwoodjs/core": "8.4.0",
"@redwoodjs/project-config": "8.3.0", "@redwoodjs/project-config": "8.4.0",
"prettier-plugin-tailwindcss": "0.4.1" "prettier-plugin-tailwindcss": "0.4.1"
}, },
"eslintConfig": { "eslintConfig": {

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", "API_ADDRESS_PROD", "API_ADDRESS_DEV"]
[generate] [generate]
tests = false tests = false
stories = false stories = false

View File

@@ -9,46 +9,46 @@ 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)
await db.user.upsert({ const existingAdmin = await db.user.findFirst({
where: { where: {
email: admin.email,
},
create: {
username: admin.username, 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() const titles = await db.titles.findFirst()
await db.titles.upsert({ if (!titles)
where: { await db.titles.create({
id: 1, data: {
}, titles: Array.from({ length: MAX_TITLES }).map(
create: { (_, i) => `a title ${i + 1}`
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}`),
},
})
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }

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),
@@ -141,7 +146,7 @@ export const theme = {
} }
export const darkMode = ['class', '[data-theme="dark"]'] export const darkMode = ['class', '[data-theme="dark"]']
export const plugins = [require('daisyui')] export const plugins = [require('@tailwindcss/typography'), require('daisyui')]
export const daisyui = { export const daisyui = {
themes: [ themes: [
'light', 'light',

View File

@@ -14,11 +14,18 @@
"@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.4.0",
"@redwoodjs/forms": "8.3.0", "@redwoodjs/forms": "8.4.0",
"@redwoodjs/router": "8.3.0", "@redwoodjs/router": "8.4.0",
"@redwoodjs/web": "8.3.0", "@redwoodjs/web": "8.4.0",
"@redwoodjs/web-server": "8.3.0", "@redwoodjs/web-server": "8.4.0",
"@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/compressor": "^2.0.1",
"@uppy/core": "^4.1.0", "@uppy/core": "^4.1.0",
"@uppy/dashboard": "^4.0.2", "@uppy/dashboard": "^4.0.2",
@@ -28,17 +35,20 @@
"@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",
"country-flag-icons": "^1.5.13",
"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-typed": "^2.0.12" "react-typed": "^2.0.12"
}, },
"devDependencies": { "devDependencies": {
"@redwoodjs/vite": "8.3.0", "@redwoodjs/vite": "8.4.0",
"@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",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"daisyui": "^4.12.10", "daisyui": "^4.12.10",
"postcss": "^8.4.41", "postcss": "^8.4.41",

View File

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

View File

@@ -35,7 +35,7 @@ const Routes = () => {
<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" />

View File

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

View File

@@ -1,12 +1,15 @@
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()
const [height, setHeight] = useState() const [height, setHeight] = useState()
@@ -68,7 +71,7 @@ const ContactCard = ({ portraitUrl }: ContactCardProps) => {
</h2> </h2>
<p className="p-2"></p> <p className="p-2"></p>
<div className="card-actions"> <div className="card-actions">
<SocialLinksCell /> <SocialLinks socials={socials} />
</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,
query ContactCardPortrait { ContactCardPortraitVariables
portrait: portrait { > = gql`
fileId query ContactCardPortrait {
} portrait {
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} />
</>
) )

View File

@@ -0,0 +1,37 @@
import { useState } from 'react'
import { mdiAlertOutline } from '@mdi/js'
import Icon from '@mdi/react'
interface PDFProps {
url: string
form?: boolean
}
const PDF = ({ url, form = false }: PDFProps) => {
const [error, setError] = useState<boolean>(false)
return error ? (
<div role="alert" className="alert alert-warning">
<Icon path={mdiAlertOutline} className="size-7" />
<span>
Could not load PDF, this is common in in-app browsers, try opening this
page in a regular browser
</span>
</div>
) : (
<iframe
src={url}
title="PDF"
style={{
width: 'calc(100vw - 1rem)',
height: `calc(100vh - ${form ? '8.5rem' : '6rem'})`,
}}
className="rounded-xl"
onError={() => setError(true)}
onLoad={() => setError(false)}
/>
)
}
export default PDF

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

@@ -1,3 +1,4 @@
import parseHtml from 'react-html-parser'
import type { import type {
DeleteProjectMutation, DeleteProjectMutation,
DeleteProjectMutationVariables, DeleteProjectMutationVariables,
@@ -45,37 +46,40 @@ 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>{project.description}</td> <td>
<article className="prose">
{parseHtml(project.description)}
</article>
</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) => (
@@ -93,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) => (
@@ -115,19 +119,30 @@ const AdminProject = ({ project }: Props) => {
</td> </td>
</tr> </tr>
<tr> <tr>
<th>Links</th> <th className="text-right">Links</th>
<td> <td>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{project.links.map((link, i) => ( {project.links.map((link, i) => (
<a <>
href={link} <a
target="_blank" href={link}
className="badge badge-ghost text-nowrap" target="_blank"
key={i} className="hidden sm:flex badge badge-ghost text-nowrap"
rel="noreferrer" key={i}
> rel="noreferrer"
{link} >
</a> {link}
</a>
<a
href={link}
target="_blank"
className="btn btn-sm btn-square sm:hidden"
key={i}
rel="noreferrer"
>
{i + 1}
</a>
</>
))} ))}
</div> </div>
</td> </td>

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>

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>

View File

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

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>) => (
<Project project={project} /> <>
<Metadata
title={project.title}
og={{
title: project.title,
type: 'website',
image:
project.images.length > 0
? {
url: project.images[0],
type: 'image/webp',
alt: 'Image 1',
}
: undefined,
url: routes.project({ id: project.id }),
}}
/>
<Project project={project} />
</>
) )

View File

@@ -2,6 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { mdiCalendar, mdiDelete, mdiFormatTitle, mdiLinkVariant } from '@mdi/js' import { mdiCalendar, mdiDelete, mdiFormatTitle, mdiLinkVariant } from '@mdi/js'
import Icon from '@mdi/react' import Icon from '@mdi/react'
import Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline'
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Meta, UploadResult } from '@uppy/core' import { Meta, UploadResult } from '@uppy/core'
import { format, isAfter, startOfToday } from 'date-fns' import { format, isAfter, startOfToday } from 'date-fns'
import type { import type {
@@ -11,18 +15,12 @@ import type {
} from 'types/graphql' } from 'types/graphql'
import type { RWGqlError } from '@redwoodjs/forms' import type { RWGqlError } from '@redwoodjs/forms'
import { import { Form, FieldError, Label, TextField, Submit } from '@redwoodjs/forms'
Form,
FieldError,
Label,
TextField,
Submit,
TextAreaField,
} from '@redwoodjs/forms'
import { toast } from '@redwoodjs/web/toast' import { toast } from '@redwoodjs/web/toast'
import DatePicker from 'src/components/DatePicker' import DatePicker from 'src/components/DatePicker'
import FormTextList from 'src/components/FormTextList' import FormTextList from 'src/components/FormTextList'
import RichTextEditor from 'src/components/RichTextEditor/RichTextEditor'
import TagsSelectorCell from 'src/components/Tag/TagsSelectorCell' import TagsSelectorCell from 'src/components/Tag/TagsSelectorCell'
import Uploader from 'src/components/Uploader' import Uploader from 'src/components/Uploader'
import { batchDelete } from 'src/lib/tus' import { batchDelete } from 'src/lib/tus'
@@ -39,6 +37,20 @@ interface ProjectFormProps {
const ProjectForm = (props: ProjectFormProps) => { const ProjectForm = (props: ProjectFormProps) => {
const today = startOfToday() const today = startOfToday()
const descEditor = useEditor({
extensions: [
StarterKit,
Underline,
Link.configure({
openOnClick: false,
autolink: true,
linkOnPaste: true,
defaultProtocol: 'https',
}),
],
content: props.project?.description || '',
})
const [links, setLinks] = useState<string[]>(props.project?.links || []) const [links, setLinks] = useState<string[]>(props.project?.links || [])
const [linkErrors, setLinkErrors] = useState<boolean[]>([]) const [linkErrors, setLinkErrors] = useState<boolean[]>([])
const [pickerVisible, setPickerVisible] = useState<boolean>(false) const [pickerVisible, setPickerVisible] = useState<boolean>(false)
@@ -87,7 +99,7 @@ const ProjectForm = (props: ProjectFormProps) => {
props.onSave( props.onSave(
{ {
title: data.title, title: data.title,
description: data.description, description: descEditor.getHTML(),
date: date.toISOString(), date: date.toISOString(),
links: links.filter((link) => link.trim().length > 0), links: links.filter((link) => link.trim().length > 0),
images: fileIds, images: fileIds,
@@ -107,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
@@ -144,21 +156,7 @@ const ProjectForm = (props: ProjectFormProps) => {
</div> </div>
</Label> </Label>
<Label name="description" className="form-control w-full"> <RichTextEditor editor={descEditor} />
<TextAreaField
name="description"
defaultValue={props.project?.description}
className="textarea textarea-bordered"
errorClassName="textarea textarea-bordered textarea-error"
placeholder="Description"
/>
<div className="label">
<FieldError
name="description"
className="text-xs font-semibold text-error"
/>
</div>
</Label>
<div className="form-control w-full"> <div className="form-control w-full">
<Label <Label
@@ -263,9 +261,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}
/> />
@@ -274,9 +271,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}
/> />

View File

@@ -1,6 +1,7 @@
import { mdiDotsVertical } from '@mdi/js' import { mdiDotsVertical } from '@mdi/js'
import Icon from '@mdi/react' import Icon from '@mdi/react'
import { isAfter } from 'date-fns' import { isAfter } from 'date-fns'
import parseHtml from 'react-html-parser'
import type { import type {
DeleteProjectMutation, DeleteProjectMutation,
DeleteProjectMutationVariables, DeleteProjectMutationVariables,
@@ -94,7 +95,11 @@ const ProjectsList = ({ projects }: FindProjects) => {
return ( return (
<tr key={project.id}> <tr key={project.id}>
<td>{truncate(project.title)}</td> <td>{truncate(project.title)}</td>
<td className="max-w-72">{truncate(project.description)}</td> <td className="max-w-72">
<article className="prose text-sm line-clamp-3">
{parseHtml(project.description)}
</article>
</td>
<td className="max-w-36">{timeTag(project.date)}</td> <td className="max-w-36">{timeTag(project.date)}</td>
<td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td> <td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td>
<td> <td>

View File

@@ -1,6 +1,7 @@
import { useLayoutEffect, useRef, useState } from 'react' import { useLayoutEffect, useRef, useState } from 'react'
import { format, isAfter, startOfToday } from 'date-fns' import { format, isAfter, startOfToday } from 'date-fns'
import parseHtml from 'react-html-parser'
import { FindProjects } from 'types/graphql' import { FindProjects } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router' import { Link, routes } from '@redwoodjs/router'
@@ -56,7 +57,11 @@ const ProjectsShowcase = ({ projects }: FindProjects) => {
<div className="card-title overflow-auto"> <div className="card-title overflow-auto">
<p className="whitespace-nowrap">{project.title}</p> <p className="whitespace-nowrap">{project.title}</p>
</div> </div>
<div className="line-clamp-5">{project.description}</div> <div className="line-clamp-5">
<article className="prose text-sm">
{parseHtml(project.description)}
</article>
</div>
<div className="card-actions justify-between"> <div className="card-actions justify-between">
<div className="flex gap-2"> <div className="flex gap-2">
{isAfter(new Date(project.date), startOfToday()) && ( {isAfter(new Date(project.date), startOfToday()) && (

View File

@@ -1,9 +1,11 @@
import type { FindProjects, FindProjectsVariables } from 'types/graphql' import type { FindProjects, FindProjectsVariables } 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,16 @@ export const Failure = ({ error }: CellFailureProps<FindProjectsVariables>) => (
export const Success = ({ export const Success = ({
projects, projects,
}: CellSuccessProps<FindProjects, FindProjectsVariables>) => ( }: CellSuccessProps<FindProjects, FindProjectsVariables>) => (
<ProjectsShowcase projects={projects} /> <>
<Metadata
title="Projects"
og={{
title: 'Projects',
type: 'website',
description: `${projects.length} projects`,
url: routes.projects(),
}}
/>
<ProjectsShowcase projects={projects} />
</>
) )

View File

@@ -1,26 +1,11 @@
import { useState } from 'react'
import { Resume as ResumeType } from 'types/graphql' import { Resume as ResumeType } from 'types/graphql'
import PDF from 'src/components/PDF/PDF'
interface ResumeProps { interface ResumeProps {
resume?: ResumeType resume?: ResumeType
} }
const Resume = ({ resume }: ResumeProps) => { const Resume = ({ resume }: ResumeProps) => <PDF url={resume?.fileId} />
const [fileId] = useState<string>(resume?.fileId)
return (
<object
data={fileId}
type="application/pdf"
aria-label="Resume PDF"
style={{
width: 'calc(100vw - 1rem)',
height: 'calc(100vh - 6rem)',
}}
className="rounded-xl"
/>
)
}
export default Resume export default Resume

View File

@@ -1,9 +1,11 @@
import type { FindResume, FindResumeVariables } from 'types/graphql' import type { FindResume, FindResumeVariables } 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'
@@ -29,5 +31,15 @@ export const Failure = ({ error }: CellFailureProps<FindResumeVariables>) => (
export const Success = ({ export const Success = ({
resume, resume,
}: CellSuccessProps<FindResume, FindResumeVariables>) => ( }: CellSuccessProps<FindResume, FindResumeVariables>) => (
<Resume resume={resume} /> <>
<Metadata
title="Contact"
og={{
title: 'Resume',
type: 'website',
url: routes.resume(),
}}
/>
<Resume resume={resume} />
</>
) )

View File

@@ -14,6 +14,7 @@ import type {
import { TypedDocumentNode, useMutation } from '@redwoodjs/web' import { TypedDocumentNode, useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast' import { toast } from '@redwoodjs/web/toast'
import PDF from 'src/components/PDF/PDF'
import Uploader from 'src/components/Uploader/Uploader' import Uploader from 'src/components/Uploader/Uploader'
import { deleteFile, handleBeforeUnload } from 'src/lib/tus' import { deleteFile, handleBeforeUnload } from 'src/lib/tus'
@@ -100,16 +101,7 @@ const ResumeForm = ({ resume }: ResumeFormProps) => {
if (resume?.fileId) if (resume?.fileId)
return ( return (
<div className="mx-auto w-fit space-y-2"> <div className="mx-auto w-fit space-y-2">
<object <PDF form url={resume?.fileId} />
data={resume?.fileId}
type="application/pdf"
aria-label="Resume PDF"
style={{
width: 'calc(100vw - 1rem)',
height: 'calc(100vh - 10rem)',
}}
className="rounded-xl"
/>
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
type="button" type="button"
@@ -131,28 +123,18 @@ const ResumeForm = ({ resume }: ResumeFormProps) => {
) )
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="11.5rem" height="30rem"
className="flex justify-center"
type="pdf" type="pdf"
/> />
</> </>
) : ( ) : (
<object <PDF form url={fileId} />
data={fileId}
type="application/pdf"
aria-label="Resume PDF"
style={{
width: 'calc(100vw - 1rem)',
height: 'calc(100vh - 10rem)',
}}
className="rounded-xl"
/>
)} )}
{fileId && ( {fileId && (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">

View File

@@ -0,0 +1,167 @@
import { useCallback } from 'react'
import {
mdiCodeBracesBox,
mdiFormatBold,
mdiFormatClear,
mdiFormatItalic,
mdiFormatListBulleted,
mdiFormatListNumbered,
mdiFormatQuoteClose,
mdiFormatStrikethrough,
mdiFormatUnderline,
mdiLinkVariant,
mdiLinkVariantOff,
mdiRedoVariant,
mdiUndoVariant,
mdiXml,
} from '@mdi/js'
import Icon from '@mdi/react'
import { EditorContent } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
interface RichTextEditorProps {
editor: Editor
}
const RichTextEditor = ({ editor }: RichTextEditorProps) => {
const setLink = useCallback(() => {
const previousUrl = editor.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
if (url === null) return
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}, [editor])
if (!editor) return null
return (
<div className="flex flex-col gap-2">
<div className="flex justify-center flex-wrap gap-2">
<div className="flex gap-2 h-min justify-center">
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('link') ? 'btn-primary' : ''}`}
onClick={setLink}
>
<Icon path={mdiLinkVariant} className="size-5" />
</button>
<button
type="button"
className="btn btn-sm btn-square"
onClick={() => editor.chain().focus().unsetLink().run()}
disabled={!editor.isActive('link')}
>
<Icon path={mdiLinkVariantOff} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('bulletList') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<Icon path={mdiFormatListBulleted} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('orderedList') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<Icon path={mdiFormatListNumbered} className="size-5" />
</button>
<button
type="button"
className="btn btn-sm btn-square"
onClick={() => editor.chain().focus().unsetAllMarks().run()}
>
<Icon path={mdiFormatClear} className="size-5" />
</button>
<button
type="button"
className="btn btn-sm btn-square"
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
>
<Icon path={mdiUndoVariant} className="size-5" />
</button>
<button
type="button"
className="btn btn-sm btn-square"
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
>
<Icon path={mdiRedoVariant} className="size-5" />
</button>
</div>
<div className="flex gap-2 h-min justify-center">
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('bold') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
>
<Icon path={mdiFormatBold} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('italic') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
>
<Icon path={mdiFormatItalic} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('underline') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleUnderline().run()}
disabled={!editor.can().chain().focus().toggleUnderline().run()}
>
<Icon path={mdiFormatUnderline} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('static') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
>
<Icon path={mdiFormatStrikethrough} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('code') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
>
<Icon path={mdiXml} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('codeBlock') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
disabled={!editor.can().chain().focus().toggleCodeBlock().run()}
>
<Icon path={mdiCodeBracesBox} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('blockquote') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<Icon path={mdiFormatQuoteClose} className="size-5" />
</button>
</div>
</div>
<EditorContent
editor={editor}
className="textarea textarea-bordered font-normal prose max-w-full"
/>
</div>
)
}
export default RichTextEditor

View File

@@ -71,9 +71,9 @@ export const Success = ({ social }: CellSuccessProps<EditSocialById>) => {
} }
return ( return (
<div className="flex w-full justify-center"> <div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl 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 Social {social.id}</th> <th className="w-0">Edit Social {social.id}</th>

View File

@@ -38,9 +38,9 @@ const NewSocial = () => {
createSocial({ variables: { input } }) createSocial({ variables: { input } })
return ( return (
<div className="flex w-full justify-center"> <div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl 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 Social</th> <th>New Social</th>

View File

@@ -44,31 +44,32 @@ const Social = ({ social }: Props) => {
} }
return ( return (
<div className="flex w-full justify-center"> <div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl justify-center">
<div className="w-80"> <div className="w-full">
<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">
<th className="w-0"> <tr>
Social {social.id}: {social.name} <th colSpan={2}>
</th> Social {social.id}: {social.name}
<th>&nbsp;</th> </th>
</tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<th>ID</th> <th className="text-right">ID</th>
<td>{social.id}</td> <td>{social.id}</td>
</tr> </tr>
<tr> <tr>
<th>Type</th> <th className="text-right">Type</th>
<td>{getLogoComponent(social.type)}</td> <td>{getLogoComponent(social.type)}</td>
</tr> </tr>
<tr> <tr>
<th>Name</th> <th className="text-right">Name</th>
<td>{social.name}</td> <td>{social.name}</td>
</tr> </tr>
<tr> <tr>
<th>Username</th> <th className="text-right">Username</th>
<td>{social.username}</td> <td>{social.username}</td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -33,6 +33,6 @@ export const Failure = ({
export const Success = ({ export const Success = ({
social, social,
}: CellSuccessProps<FindSocialById, FindSocialByIdVariables>) => { }: CellSuccessProps<FindSocialById, FindSocialByIdVariables>) => (
return <Social social={social} /> <Social social={social} />
} )

View File

@@ -38,6 +38,7 @@ const types: FormSocial['type'][] = [
'discord', 'discord',
'twitch', 'twitch',
'linkedin', 'linkedin',
'matrix',
'github', 'github',
'gitea', 'gitea',
'forgejo', 'forgejo',
@@ -52,6 +53,15 @@ const types: FormSocial['type'][] = [
const urlHandles: FormSocial['type'][] = ['custom', 'gitea', 'forgejo'] const urlHandles: FormSocial['type'][] = ['custom', 'gitea', 'forgejo']
const SocialForm = (props: SocialFormProps) => { const SocialForm = (props: SocialFormProps) => {
const emailRegex =
/^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/
const phoneRegex = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/
const urlRegex =
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i
const matrixRegex =
/^([#@][a-zA-Z0-9_\-\.]+):([a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,})$/
const [type, setType] = useState<FormSocial['type']>( const [type, setType] = useState<FormSocial['type']>(
props.social?.type ?? 'x' props.social?.type ?? 'x'
) )
@@ -89,7 +99,7 @@ const SocialForm = (props: SocialFormProps) => {
<Form<FormSocial> <Form<FormSocial>
onSubmit={onSubmit} onSubmit={onSubmit}
error={props.error} error={props.error}
className="h-128 max-w-80 space-y-2" className="h-128 space-y-2"
> >
<Label name="name" className="form-control w-full"> <Label name="name" className="form-control w-full">
<Label <Label
@@ -177,17 +187,20 @@ const SocialForm = (props: SocialFormProps) => {
pattern: { pattern: {
value: value:
type == 'email' type == 'email'
? /^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/ ? emailRegex
: type == 'phone' : type == 'phone'
? /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/ ? phoneRegex
: urlHandles.includes(type) && : urlHandles.includes(type)
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i, ? urlRegex
: type == 'matrix' && matrixRegex,
message: `Invalid ${ message: `Invalid ${
urlHandles.includes(type) urlHandles.includes(type)
? 'URL' ? 'URL'
: type == 'phone' : type == 'phone'
? 'Phone Number' ? 'phone number'
: type == 'email' && 'Email' : type == 'email'
? 'Email'
: type == 'matrix' && 'Matrix identifier'
}`, }`,
}, },
}} }}

View File

@@ -1,8 +1,8 @@
import { FindSocials } from 'types/graphql' import { ContactCardPortrait } from 'types/graphql'
import { baseUrls, getLogoComponent, sortOrder } from 'src/lib/handle' import { baseUrls, getLogoComponent, sortOrder } from 'src/lib/handle'
const SocialLinks = ({ socials }: FindSocials) => ( const SocialLinks = ({ socials }: ContactCardPortrait) => (
<div className={`grid max-w-68 grid-cols-[repeat(auto-fit,3rem)] gap-2`}> <div className={`grid max-w-68 grid-cols-[repeat(auto-fit,3rem)] gap-2`}>
{[...socials] {[...socials]
.sort((a, b) => sortOrder.indexOf(a.type) - sortOrder.indexOf(b.type)) .sort((a, b) => sortOrder.indexOf(a.type) - sortOrder.indexOf(b.type))

View File

@@ -1,35 +0,0 @@
import type { FindSocials, FindSocialsVariables } from 'types/graphql'
import type {
CellSuccessProps,
CellFailureProps,
TypedDocumentNode,
} from '@redwoodjs/web'
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
import SocialLinks from 'src/components/Social/SocialLinks/SocialLinks'
export const QUERY: TypedDocumentNode<FindSocials, FindSocialsVariables> = gql`
query SocialsQuery {
socials {
id
name
type
username
}
}
`
export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({ error }: CellFailureProps<FindSocials>) => (
<CellFailure error={error} />
)
export const Success = ({ socials }: CellSuccessProps<FindSocials>) => (
<SocialLinks socials={socials} />
)

View File

@@ -59,8 +59,8 @@ export const Success = ({ tag }: CellSuccessProps<EditTagById>) => {
updateTag({ variables: { id, input } }) updateTag({ variables: { id, input } })
return ( return (
<div className="flex w-full justify-center"> <div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl 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"> <table className="table">
<thead className="bg-base-200 font-syne"> <thead className="bg-base-200 font-syne">
<tr> <tr>

View File

@@ -34,8 +34,8 @@ const NewTag = () => {
const onSave = (input: CreateTagInput) => createTag({ variables: { input } }) const onSave = (input: CreateTagInput) => createTag({ variables: { input } })
return ( return (
<div className="flex w-full justify-center"> <div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl 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"> <table className="table">
<thead className="bg-base-200 font-syne"> <thead className="bg-base-200 font-syne">
<tr> <tr>

View File

@@ -42,8 +42,8 @@ const Tag = ({ tag }: Props) => {
} }
return ( return (
<div className="flex w-full justify-center"> <div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl justify-center">
<div className="w-80"> <div className="w-full">
<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">
@@ -54,15 +54,15 @@ const Tag = ({ tag }: Props) => {
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<th>ID</th> <th className="text-right">ID</th>
<td>{tag.id}</td> <td>{tag.id}</td>
</tr> </tr>
<tr> <tr>
<th>Tag</th> <th className="text-right">Tag</th>
<td>{tag.tag}</td> <td>{tag.tag}</td>
</tr> </tr>
<tr> <tr>
<th>Color</th> <th className="text-right">Color</th>
<td> <td>
<div <div
className="badge whitespace-nowrap" className="badge whitespace-nowrap"

View File

@@ -35,7 +35,7 @@ const TagForm = (props: TagFormProps) => {
<Form<FormTag> <Form<FormTag>
onSubmit={onSubmit} onSubmit={onSubmit}
error={props.error} error={props.error}
className="max-w-56 space-y-2" className="space-y-2"
> >
<Label name="tag" className="form-control w-full"> <Label name="tag" className="form-control w-full">
<Label <Label

View File

@@ -50,7 +50,7 @@ const TagsList = ({ tags }: FindTags) => {
<th className="w-0">&nbsp;</th> <th className="w-0">&nbsp;</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="overflow-y-scroll">
{tags.map((tag, i) => { {tags.map((tag, i) => {
const actionButtons = ( const actionButtons = (
<> <>

View File

@@ -26,7 +26,7 @@ const TagsSelector = ({
}, [selectedTags, _tags]) }, [selectedTags, _tags])
return ( return (
<div className="w-80 space-y-2"> <div className="space-y-2">
{tags.length > 0 && ( {tags.length > 0 && (
<> <>
<p className="font-semibold">Tags</p> <p className="font-semibold">Tags</p>

View File

@@ -29,22 +29,24 @@ export const Failure = ({
}: CellFailureProps<AdminTitlesQueryVariables>) => <CellFailure error={error} /> }: CellFailureProps<AdminTitlesQueryVariables>) => <CellFailure error={error} />
export const Success = ({ titles }: CellSuccessProps<AdminTitlesQuery>) => ( export const Success = ({ titles }: CellSuccessProps<AdminTitlesQuery>) => (
<div className="flex w-full justify-center"> <div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl justify-center">
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100"> <div className="w-full">
<table className="table w-80"> <div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<thead className="bg-base-200 font-syne"> <table className="table">
<tr> <thead className="bg-base-200 font-syne">
<th className="w-0">Titles</th> <tr>
</tr> <th className="w-0">Titles</th>
</thead> </tr>
<tbody> </thead>
<tr> <tbody>
<th> <tr>
<TitlesForm titles={titles} /> <th>
</th> <TitlesForm titles={titles} />
</tr> </th>
</tbody> </tr>
</table> </tbody>
</table>
</div>
</div> </div>
</div> </div>
) )

View File

@@ -55,9 +55,9 @@ const TitlesForm = ({ titles }: TitlesFormProps) => {
}) })
return ( return (
<Form onSubmit={onSubmit} className="max-w-80 space-y-2"> <Form onSubmit={onSubmit} className="space-y-2">
<p className="text-center opacity-70"> <p className="text-center opacity-70">
The first one gets displayed for longer The first title gets displayed for longer
</p> </p>
{Array.from({ length: MAX_TITLES }).map((_, i) => ( {Array.from({ length: MAX_TITLES }).map((_, i) => (
<Label key={i} name={`title${i}`} className="form-control w-full"> <Label key={i} name={`title${i}`} className="form-control w-full">
@@ -97,7 +97,7 @@ const TitlesForm = ({ titles }: TitlesFormProps) => {
<nav className="my-2 flex justify-center space-x-2"> <nav className="my-2 flex justify-center space-x-2">
<button <button
type="button" type="button"
className="btn btn-sm" className="btn btn-sm uppercase"
onClick={() => setPreview(!preview)} onClick={() => setPreview(!preview)}
> >
{preview ? 'Hide' : 'Show'} Preview {preview ? 'Hide' : 'Show'} Preview

View File

@@ -1,3 +1,4 @@
import { hasFlag } from 'country-flag-icons'
import { hydrateRoot, createRoot } from 'react-dom/client' import { hydrateRoot, createRoot } from 'react-dom/client'
import App from 'src/App' import App from 'src/App'
@@ -15,6 +16,11 @@ if (!redwoodAppElement)
"exists in your 'web/src/index.html' file." "exists in your 'web/src/index.html' file."
) )
if (!hasFlag(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 (redwoodAppElement.children?.length > 0) if (redwoodAppElement.children?.length > 0)
hydrateRoot(redwoodAppElement, <App />) hydrateRoot(redwoodAppElement, <App />)
else { else {

View File

@@ -19,3 +19,11 @@
.image-full-no-overlay::before { .image-full-no-overlay::before {
background: none !important; background: none !important;
} }
.ProseMirror:focus {
outline: none;
}
.w-full .react-colorful {
width: auto;
}

View File

@@ -8,6 +8,7 @@ import {
SiTiktok, SiTiktok,
SiYoutube, SiYoutube,
SiLinkedin, SiLinkedin,
SiMatrix,
SiGithub, SiGithub,
SiGitea, SiGitea,
SiLeetcode, SiLeetcode,
@@ -33,6 +34,7 @@ export const baseUrls: Record<Handle, string> = {
discord: 'https://discord.gg/', discord: 'https://discord.gg/',
twitch: 'https://www.twitch.tv/', twitch: 'https://www.twitch.tv/',
linkedin: 'https://www.linkedin.com/in/', linkedin: 'https://www.linkedin.com/in/',
matrix: 'https://matrix.to/#/',
github: 'https://github.com/', github: 'https://github.com/',
gitea: '', gitea: '',
forgejo: '', forgejo: '',
@@ -61,6 +63,7 @@ const logoComponents: Record<Handle, ReactElement> = {
linkedin: ( linkedin: (
<SiLinkedin className="text-linkedin-light dark:text-linkedin-dark" /> <SiLinkedin className="text-linkedin-light dark:text-linkedin-dark" />
), ),
matrix: <SiMatrix className="text-matrix-light dark:text-matrix-dark" />,
github: <SiGithub className="text-github-light dark:text-github-dark" />, github: <SiGithub className="text-github-light dark:text-github-dark" />,
gitea: <SiGitea className="text-gitea-light dark:text-gitea-dark" />, gitea: <SiGitea className="text-gitea-light dark:text-gitea-dark" />,
forgejo: <SiForgejo className="text-forgejo-light dark:text-forgejo-dark" />, forgejo: <SiForgejo className="text-forgejo-light dark:text-forgejo-dark" />,
@@ -80,6 +83,7 @@ export const sortOrder: Social['type'][] = [
'phone', 'phone',
'email', 'email',
'custom', 'custom',
'matrix',
'linkedin', 'linkedin',
'leetcode', 'leetcode',
'github', 'github',

View File

@@ -1,7 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Metadata } from '@redwoodjs/web'
import ContactCardCell from 'src/components/ContactCard/ContactCardCell' import ContactCardCell from 'src/components/ContactCard/ContactCardCell'
const ContactPage = () => { const ContactPage = () => {
@@ -29,13 +27,9 @@ const ContactPage = () => {
}, [width, height]) }, [width, height])
return ( return (
<> <div className="flex min-h-[calc(100vh-6rem)] items-center justify-center">
<Metadata title="Contact" /> <ContactCardCell />
</div>
<div className="flex min-h-[calc(100vh-6rem)] items-center justify-center">
<ContactCardCell />
</div>
</>
) )
} }

View File

@@ -13,45 +13,6 @@ import { DevFatalErrorPage } from '@redwoodjs/web/dist/components/DevFatalErrorP
export default DevFatalErrorPage || export default DevFatalErrorPage ||
(() => ( (() => (
<main> <main>
<style <span>Something went wrong</span>
dangerouslySetInnerHTML={{
__html: `
html, body {
margin: 0;
}
html * {
box-sizing: border-box;
}
main {
display: flex;
align-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
text-align: center;
background-color: #E2E8F0;
height: 100vh;
}
section {
background-color: white;
border-radius: 0.25rem;
width: 32rem;
padding: 1rem;
margin: 0 auto;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
h1 {
font-size: 2rem;
margin: 0;
font-weight: 500;
line-height: 1;
color: #2D3748;
}
`,
}}
/>
<section>
<h1>
<span>Something went wrong</span>
</h1>
</section>
</main> </main>
)) ))

View File

@@ -72,7 +72,9 @@ const ForgotPasswordPage = () => {
<FieldError name="username" className="text-sm text-error" /> <FieldError name="username" className="text-sm text-error" />
<div className="flex w-full"> <div className="flex w-full">
<Submit className="btn btn-primary btn-sm mx-auto">Submit</Submit> <Submit className="btn btn-primary btn-sm mx-auto uppercase">
Submit
</Submit>
</div> </div>
</Form> </Form>
</div> </div>

View File

@@ -1,14 +1,24 @@
import { mdiCompass, mdiContacts } from '@mdi/js' import { mdiCompass, mdiContacts } from '@mdi/js'
import Icon from '@mdi/react' import Icon from '@mdi/react'
import getUnicodeFlagIcon from 'country-flag-icons/unicode'
import { Link, routes } from '@redwoodjs/router' import { Link, routes } from '@redwoodjs/router'
import { Metadata } from '@redwoodjs/web' import { Metadata } from '@redwoodjs/web'
import TitlesCell from 'src/components/Title/TitlesCell' import TitlesCell from 'src/components/Title/TitlesCell'
import { getLogoComponent } from 'src/lib/handle'
const HomePage = () => ( const HomePage = () => (
<> <>
<Metadata title="Home" /> <Metadata
title="Home"
og={{
title: `${process.env.FIRST_NAME} ${process.env.LAST_NAME}`,
description: 'Check out my portfolio!',
type: 'website',
url: routes.home(),
}}
/>
<div className="hero min-h-[calc(100vh-6rem)]"> <div className="hero min-h-[calc(100vh-6rem)]">
<div className="hero-content flex flex-col gap-8"> <div className="hero-content flex flex-col gap-8">
@@ -33,6 +43,21 @@ const HomePage = () => (
</div> </div>
</div> </div>
</div> </div>
<div className="fixed bottom-2 left-2 z-10">
<a
href="https://git.altaiar.dev/ahmed/portfolio"
target="_blank"
rel="noreferrer"
className="btn btn-square"
>
{getLogoComponent('gitea')}
</a>
</div>
<div className="fixed bottom-2 right-2 z-10">
<p className="btn btn-square text-xl">
{getUnicodeFlagIcon(process.env.COUNTRY)}
</p>
</div>
</> </>
) )

View File

@@ -103,7 +103,9 @@ const LoginPage = () => {
<FieldError name="password" className="text-sm text-error" /> <FieldError name="password" className="text-sm text-error" />
<div className="flex w-full"> <div className="flex w-full">
<Submit className="btn btn-primary btn-sm mx-auto">Log In</Submit> <Submit className="btn btn-primary btn-sm mx-auto uppercase">
Log In
</Submit>
</div> </div>
</Form> </Form>
</div> </div>

View File

@@ -1,5 +1,3 @@
import { Metadata } from '@redwoodjs/web'
import ProjectCell from 'src/components/Project/ProjectCell' import ProjectCell from 'src/components/Project/ProjectCell'
interface ProjectPageProps { interface ProjectPageProps {
@@ -7,13 +5,7 @@ interface ProjectPageProps {
} }
const ProjectPage = ({ id }: ProjectPageProps) => { const ProjectPage = ({ id }: ProjectPageProps) => {
return ( return <ProjectCell id={id} />
<>
<Metadata title="Project" />
<ProjectCell id={id} />
</>
)
} }
export default ProjectPage export default ProjectPage

View File

@@ -1,14 +1,10 @@
import mobile from 'is-mobile' import mobile from 'is-mobile'
import { Metadata } from '@redwoodjs/web'
import ProjectsShowcaseCell from 'src/components/Project/ProjectsShowcaseCell' import ProjectsShowcaseCell from 'src/components/Project/ProjectsShowcaseCell'
const ProjectsPage = () => { const ProjectsPage = () => {
return ( return (
<> <>
<Metadata title="Projects" />
<div className="hero min-h-64"> <div className="hero min-h-64">
<div className="hero-content"> <div className="hero-content">
<div className="max-w-md text-center"> <div className="max-w-md text-center">

View File

@@ -102,7 +102,7 @@ const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
<div className="flex w-full"> <div className="flex w-full">
<Submit <Submit
className={`btn btn-primary btn-sm mx-auto ${ className={`btn btn-primary btn-sm uppercase mx-auto ${
!enabled ? 'btn-disabled' : '' !enabled ? 'btn-disabled' : ''
}`} }`}
disabled={!enabled} disabled={!enabled}

View File

@@ -1,15 +1,7 @@
import { Metadata } from '@redwoodjs/web'
import ResumeCell from 'src/components/Resume/ResumeCell' import ResumeCell from 'src/components/Resume/ResumeCell'
const ResumePage = () => { const ResumePage = () => {
return ( return <ResumeCell />
<>
<Metadata title="Resume" />
<ResumeCell />
</>
)
} }
export default ResumePage export default ResumePage

View File

@@ -1,243 +0,0 @@
.rw-scaffold {
@apply bg-white text-gray-600;
}
.rw-scaffold h1,
.rw-scaffold h2 {
@apply m-0;
}
.rw-scaffold a {
@apply bg-transparent;
}
.rw-scaffold ul {
@apply m-0 p-0;
}
.rw-scaffold input:-ms-input-placeholder {
@apply text-gray-500;
}
.rw-scaffold input::-ms-input-placeholder {
@apply text-gray-500;
}
.rw-scaffold input::placeholder {
@apply text-gray-500;
}
.rw-header {
@apply flex justify-between px-8 py-4;
}
.rw-main {
@apply mx-4 pb-4;
}
.rw-segment {
@apply w-full overflow-hidden rounded-lg border border-gray-200;
scrollbar-color: theme('colors.zinc.400') transparent;
}
.rw-segment::-webkit-scrollbar {
height: initial;
}
.rw-segment::-webkit-scrollbar-track {
@apply rounded-b-[10px] rounded-t-none border-0 border-t border-solid border-gray-200 bg-transparent p-[2px];
}
.rw-segment::-webkit-scrollbar-thumb {
@apply rounded-full border-[3px] border-solid border-transparent bg-zinc-400 bg-clip-content;
}
.rw-segment-header {
@apply bg-gray-200 px-4 py-3 text-gray-700;
}
.rw-segment-main {
@apply bg-gray-100 p-4;
}
.rw-link {
@apply text-blue-400 underline;
}
.rw-link:hover {
@apply text-blue-500;
}
.rw-forgot-link {
@apply mt-1 text-right text-xs text-gray-400 underline;
}
.rw-forgot-link:hover {
@apply text-blue-500;
}
.rw-heading {
@apply font-semibold;
}
.rw-heading.rw-heading-primary {
@apply text-xl;
}
.rw-heading.rw-heading-secondary {
@apply text-sm;
}
.rw-heading .rw-link {
@apply text-gray-600 no-underline;
}
.rw-heading .rw-link:hover {
@apply text-gray-900 underline;
}
.rw-cell-error {
@apply text-sm font-semibold;
}
.rw-form-wrapper {
@apply -mt-4 text-sm;
}
.rw-cell-error,
.rw-form-error-wrapper {
@apply my-4 rounded border border-red-100 bg-red-50 p-4 text-red-600;
}
.rw-form-error-title {
@apply m-0 font-semibold;
}
.rw-form-error-list {
@apply mt-2 list-inside list-disc;
}
.rw-button {
@apply flex cursor-pointer justify-center rounded border-0 bg-gray-200 px-4 py-1 text-xs font-semibold uppercase leading-loose tracking-wide text-gray-500 no-underline transition duration-100;
}
.rw-button:hover {
@apply bg-gray-500 text-white;
}
.rw-button.rw-button-small {
@apply rounded-sm px-2 py-1 text-xs;
}
.rw-button.rw-button-green {
@apply bg-green-500 text-white;
}
.rw-button.rw-button-green:hover {
@apply bg-green-700;
}
.rw-button.rw-button-blue {
@apply bg-blue-500 text-white;
}
.rw-button.rw-button-blue:hover {
@apply bg-blue-700;
}
.rw-button.rw-button-red {
@apply bg-red-500 text-white;
}
.rw-button.rw-button-red:hover {
@apply bg-red-700 text-white;
}
.rw-button-icon {
@apply mr-1 text-xl leading-5;
}
.rw-button-group {
@apply mx-2 my-3 flex justify-center;
}
.rw-button-group .rw-button {
@apply mx-1;
}
.rw-form-wrapper .rw-button-group {
@apply mt-8;
}
.rw-label {
@apply mt-6 block text-left font-semibold text-gray-600;
}
.rw-label.rw-label-error {
@apply text-red-600;
}
.rw-input {
@apply mt-2 block w-full rounded border border-gray-200 bg-white p-2 outline-none;
}
.rw-check-radio-items {
@apply flex justify-items-center;
}
.rw-check-radio-item-none {
@apply text-gray-600;
}
.rw-input[type='checkbox'],
.rw-input[type='radio'] {
@apply ml-0 mr-1 mt-1 inline w-4;
}
.rw-input:focus {
@apply border-gray-400;
}
.rw-input-error {
@apply border-red-600 text-red-600;
}
.rw-input-error:focus {
@apply border-red-600 outline-none;
box-shadow: 0 0 5px #c53030;
}
.rw-field-error {
@apply mt-1 block text-xs font-semibold uppercase text-red-600;
}
.rw-table-wrapper-responsive {
@apply overflow-x-auto;
}
.rw-table-wrapper-responsive .rw-table {
min-width: 48rem;
}
.rw-table {
@apply w-full text-sm;
}
.rw-table th,
.rw-table td {
@apply p-3;
}
.rw-table td {
@apply bg-white text-gray-900;
}
.rw-table tr:nth-child(odd) td,
.rw-table tr:nth-child(odd) th {
@apply bg-gray-50;
}
.rw-table thead tr {
@apply bg-gray-200 text-gray-600;
}
.rw-table th {
@apply text-left font-semibold;
}
.rw-table thead th {
@apply text-left;
}
.rw-table tbody th {
@apply text-right;
}
@media (min-width: 768px) {
.rw-table tbody th {
@apply w-1/5;
}
}
.rw-table tbody tr {
@apply border-t border-gray-200;
}
.rw-table input {
@apply ml-0;
}
.rw-table-actions {
@apply flex h-4 items-center justify-end pr-1;
}
.rw-table-actions .rw-button {
@apply bg-transparent;
}
.rw-table-actions .rw-button:hover {
@apply bg-gray-500 text-white;
}
.rw-table-actions .rw-button-blue {
@apply text-blue-500;
}
.rw-table-actions .rw-button-blue:hover {
@apply bg-blue-500 text-white;
}
.rw-table-actions .rw-button-red {
@apply text-red-600;
}
.rw-table-actions .rw-button-red:hover {
@apply bg-red-600 text-white;
}
.rw-text-center {
@apply text-center;
}
.rw-login-container {
@apply mx-auto my-16 flex w-96 flex-wrap items-center justify-center;
}
.rw-login-container .rw-form-wrapper {
@apply w-full text-center;
}
.rw-login-link {
@apply mt-4 w-full text-center text-sm text-gray-600;
}
.rw-webauthn-wrapper {
@apply mx-4 mt-6 leading-6;
}
.rw-webauthn-wrapper h2 {
@apply mb-4 text-xl font-bold;
}

1587
yarn.lock

File diff suppressed because it is too large Load Diff