Compare commits
14 Commits
Author | SHA1 | Date | |
---|---|---|---|
f8987b08da | |||
cbf75acbeb | |||
7973663b2a | |||
6540329f36 | |||
bac5b5fe48 | |||
f3f75d3e57 | |||
f14732cdf0 | |||
77db153fe6 | |||
684d6f88c2 | |||
03f606bbde | |||
1eafaee2c0 | |||
b8063e8692 | |||
738260f7de | |||
82313bef46 |
@ -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
|
||||||
|
11
.env.example
11
.env.example
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
44
README.md
44
README.md
@ -6,10 +6,8 @@ Create two A records, one for the web side of the website and one for the api si
|
|||||||
- It doesn't matter what reverse proxy you use (Nginx, Apache, Traefik, Caddy, etc)
|
- It doesn't matter what reverse proxy you use (Nginx, Apache, Traefik, Caddy, etc)
|
||||||
1. Point the web domain to the web port (default: 8910)
|
1. Point the web domain to the web port (default: 8910)
|
||||||
2. Point the api domain to the api port (default: 8911)
|
2. Point the api domain to the api port (default: 8911)
|
||||||
### Gmail App Password
|
### SMTP
|
||||||
1. Go to your Google [account dashboard](https://myaccount.google.com)
|
You will need credentials to authorize sending Email, instructions vary depending on provider (Gmail, Hotmail, etc).
|
||||||
2. Go to Security > 2-Step Verification > App Passwords > Create a new app password
|
|
||||||
3. Copy the 16 character password
|
|
||||||
### [Docker Compose](./docker-compose.yml)
|
### [Docker Compose](./docker-compose.yml)
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
@ -23,21 +21,29 @@ services:
|
|||||||
- API_PROXY_TARGET=http://localhost:8911
|
- API_PROXY_TARGET=http://localhost:8911
|
||||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||||
- DATABASE_URL=postgresql://redwood:redwood@db:5432/portfolio
|
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||||
- FIRST_NAME=first name # Your first name
|
- FIRST_NAME=first name # Your first name
|
||||||
- LAST_NAME=lastname # Your last name
|
- LAST_NAME=lastname # Your last name
|
||||||
- GMAIL=example@gmail.com # The Gmail address associated with the app password created earlier
|
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||||
- GMAIL_SMTP_PASSWORD=aaaa bbbb cccc dddd # The app password created earlier
|
- SMTP_HOST=smtp.example.com
|
||||||
|
- SMTP_PORT=465
|
||||||
|
- SMTP_SECURE=true
|
||||||
|
- SMTP_USER=noreply@example.com
|
||||||
|
- EMAIL_FROM=noreply@example.com
|
||||||
|
- EMAIL_TO=email@example.com
|
||||||
|
- SMTP_PASSWORD=password
|
||||||
- DOMAIN=portfolio.example.com
|
- DOMAIN=portfolio.example.com
|
||||||
- API_DOMAIN=api.portfolio.example.com
|
- API_DOMAIN=api.portfolio.example.com
|
||||||
# Careful, addresses below must not end with a '/'
|
# Careful, addresses below must not end with a '/'
|
||||||
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
||||||
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
||||||
ports:
|
ports:
|
||||||
- '8910:8910' # Web
|
- 8910:8910 # Web
|
||||||
- '8911:8911' # API
|
- 8911:8911 # API
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
volumes:
|
||||||
|
- files:/home/node/app/api/files_prod
|
||||||
command: >
|
command: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
yarn rw build &&
|
yarn rw build &&
|
||||||
@ -50,23 +56,27 @@ services:
|
|||||||
container_name: portfolio-db
|
container_name: portfolio-db
|
||||||
image: postgres:16-bookworm
|
image: postgres:16-bookworm
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: redwood
|
- POSTGRES_USER=redwood
|
||||||
POSTGRES_PASSWORD: redwood
|
- POSTGRES_PASSWORD=changeme
|
||||||
POSTGRES_DB: portfolio
|
- POSTGRES_DB=portfolio
|
||||||
ports:
|
|
||||||
- '5432:5432'
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres:/var/lib/postgresql/data
|
- postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres:
|
postgres:
|
||||||
|
files: # For persistent file storage across upgrades
|
||||||
|
```
|
||||||
|
## 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
|
||||||
```
|
```
|
||||||
## Logging In
|
## Logging In
|
||||||
- Once the container is up and running, head to `/login` (`https://portfolio.example.com/login`), default credentials are below
|
- Once the container is up and running, head to `/login` (`https://portfolio.example.com/login`), default credentials are below
|
||||||
- If you would like to change the password, head to `/forgot-password` (`https://portfolio.example.com/forgot-password`), the username is `admin`
|
- If you would like to change the password, head to `/forgot-password` (`https://portfolio.example.com/forgot-password`), the username is `admin`
|
||||||
- If you correctly set up the Gmail app password, you should receive an email from yourself
|
- If you correctly configured [SMTP](#smtp), you should receive an Email from [`EMAIL_FROM`](#docker-compose) to [`EMAIL_TO`](#docker-compose)
|
||||||
- It contains the link needed to change your password
|
- The Email contains the link needed to change your password
|
||||||
### Default Credentials
|
### Default Credentials
|
||||||
**Username:** `admin`
|
**Username:** `admin`
|
||||||
|
|
||||||
**Password:** [`GMAIL_SMTP_PASSWORD`](#gmail-app-password)
|
**Password:** [`SMTP_PASSWORD`](#docker-compose)
|
||||||
|
2
api/db/migrations/20241015183037_matrix/migration.sql
Normal file
2
api/db/migrations/20241015183037_matrix/migration.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "Handle" ADD VALUE 'matrix';
|
@ -25,6 +25,7 @@ enum Handle {
|
|||||||
discord
|
discord
|
||||||
twitch
|
twitch
|
||||||
linkedin
|
linkedin
|
||||||
|
matrix
|
||||||
github
|
github
|
||||||
gitea
|
gitea
|
||||||
forgejo
|
forgejo
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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,
|
||||||
|
@ -17,6 +17,7 @@ export const schema = gql`
|
|||||||
discord
|
discord
|
||||||
twitch
|
twitch
|
||||||
linkedin
|
linkedin
|
||||||
|
matrix
|
||||||
github
|
github
|
||||||
gitea
|
gitea
|
||||||
forgejo
|
forgejo
|
||||||
|
@ -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('@')
|
||||||
|
@ -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) => {
|
||||||
|
@ -9,21 +9,29 @@ services:
|
|||||||
- API_PROXY_TARGET=http://localhost:8911
|
- API_PROXY_TARGET=http://localhost:8911
|
||||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||||
- DATABASE_URL=postgresql://redwood:redwood@db:5432/portfolio
|
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||||
- FIRST_NAME=first name # Your first name
|
- FIRST_NAME=first name # Your first name
|
||||||
- LAST_NAME=lastname # Your last name
|
- LAST_NAME=lastname # Your last name
|
||||||
- GMAIL=example@gmail.com # The Gmail address associated with the app password created earlier
|
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||||
- GMAIL_SMTP_PASSWORD=aaaa bbbb cccc dddd # The app password created earlier
|
- SMTP_HOST=smtp.example.com
|
||||||
|
- SMTP_PORT=465
|
||||||
|
- SMTP_SECURE=true
|
||||||
|
- SMTP_USER=noreply@example.com
|
||||||
|
- EMAIL_FROM=noreply@example.com
|
||||||
|
- EMAIL_TO=email@example.com
|
||||||
|
- SMTP_PASSWORD=password
|
||||||
- DOMAIN=portfolio.example.com
|
- DOMAIN=portfolio.example.com
|
||||||
- API_DOMAIN=api.portfolio.example.com
|
- API_DOMAIN=api.portfolio.example.com
|
||||||
# Careful, addresses below must not end with a '/'
|
# Careful, addresses below must not end with a '/'
|
||||||
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
||||||
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
||||||
ports:
|
ports:
|
||||||
- '8910:8910' # Web
|
- 8910:8910 # Web
|
||||||
- '8911:8911' # API
|
- 8911:8911 # API
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
volumes:
|
||||||
|
- files:/home/node/app/api/files_prod
|
||||||
command: >
|
command: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
yarn rw build &&
|
yarn rw build &&
|
||||||
@ -36,13 +44,12 @@ services:
|
|||||||
container_name: portfolio-db
|
container_name: portfolio-db
|
||||||
image: postgres:16-bookworm
|
image: postgres:16-bookworm
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: redwood
|
- POSTGRES_USER=redwood
|
||||||
POSTGRES_PASSWORD: redwood
|
- POSTGRES_PASSWORD=changeme
|
||||||
POSTGRES_DB: portfolio
|
- POSTGRES_DB=portfolio
|
||||||
ports:
|
|
||||||
- '5432:5432'
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres:/var/lib/postgresql/data
|
- postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres:
|
postgres:
|
||||||
|
files: # For persistent file storage across upgrades
|
||||||
|
@ -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": {
|
||||||
|
@ -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
|
||||||
|
@ -9,15 +9,15 @@ export default async () => {
|
|||||||
try {
|
try {
|
||||||
const admin = {
|
const admin = {
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
email: process.env.GMAIL,
|
email: process.env.EMAIL_TO,
|
||||||
password: process.env.GMAIL_SMTP_PASSWORD,
|
password: process.env.SMTP_PASSWORD,
|
||||||
}
|
}
|
||||||
|
|
||||||
const [hashedPassword, salt] = hashPassword(admin.password)
|
const [hashedPassword, salt] = hashPassword(admin.password)
|
||||||
|
|
||||||
const existingAdmin = await db.user.findFirst({
|
const existingAdmin = await db.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email: admin.email,
|
username: admin.username,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -30,6 +30,14 @@ export default async () => {
|
|||||||
salt,
|
salt,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
else
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: existingAdmin.id },
|
||||||
|
data: {
|
||||||
|
username: admin.username,
|
||||||
|
email: admin.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const titles = await db.titles.findFirst()
|
const titles = await db.titles.findFirst()
|
||||||
|
|
||||||
|
@ -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),
|
||||||
|
@ -14,11 +14,11 @@
|
|||||||
"@icons-pack/react-simple-icons": "^10.0.0",
|
"@icons-pack/react-simple-icons": "^10.0.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@redwoodjs/auth-dbauth-web": "8.3.0",
|
"@redwoodjs/auth-dbauth-web": "8.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",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tiptap/extension-link": "^2.8.0",
|
"@tiptap/extension-link": "^2.8.0",
|
||||||
"@tiptap/extension-text-style": "^2.8.0",
|
"@tiptap/extension-text-style": "^2.8.0",
|
||||||
@ -35,6 +35,7 @@
|
|||||||
"@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",
|
||||||
@ -44,7 +45,7 @@
|
|||||||
"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",
|
"@types/react-html-parser": "^2",
|
||||||
|
@ -40,7 +40,13 @@ const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
|
|||||||
className="btn btn-square btn-sm "
|
className="btn btn-square btn-sm "
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
setColor(await navigator.clipboard.readText())
|
const clipboardText = await navigator.clipboard.readText()
|
||||||
|
const hexColorRegex =
|
||||||
|
/^#(?:(?:[\da-f]{3}){1,2}|(?:[\da-f]{4}){1,2})$/i
|
||||||
|
|
||||||
|
if (!hexColorRegex.test(clipboardText))
|
||||||
|
toast.error(`Text is not a valid hex color`)
|
||||||
|
else setColor(clipboardText)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`Failed to paste, please try again`)
|
toast.error(`Failed to paste, please try again`)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
import type { FindPortrait, FindPortraitVariables } from 'types/graphql'
|
import type {
|
||||||
|
ContactCardPortrait,
|
||||||
|
ContactCardPortraitVariables,
|
||||||
|
} from 'types/graphql'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
TypedDocumentNode,
|
TypedDocumentNode,
|
||||||
@ -11,25 +14,34 @@ 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>) => (
|
||||||
|
<ContactCard portraitUrl={portrait.fileId} socials={socials} />
|
||||||
)
|
)
|
||||||
|
19
web/src/components/PDF/PDF.tsx
Normal file
19
web/src/components/PDF/PDF.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
interface PDFProps {
|
||||||
|
url: string
|
||||||
|
form?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDF = ({ url, form = false }: PDFProps) => (
|
||||||
|
<embed
|
||||||
|
src={url}
|
||||||
|
title="PDF"
|
||||||
|
type="application/pdf"
|
||||||
|
style={{
|
||||||
|
width: 'calc(100vw - 1rem)',
|
||||||
|
height: `calc(100vh - ${form ? '8.5rem' : '6rem'})`,
|
||||||
|
}}
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default PDF
|
@ -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`}
|
||||||
/>
|
/>
|
||||||
|
@ -69,20 +69,23 @@ const Project = ({ project }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
|
{project.images.length > 0 && (
|
||||||
|
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-4 items-center pt-8 justify-center">
|
<div className="flex flex-wrap gap-4 pt-8 justify-center h-fit">
|
||||||
{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>
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
@ -143,16 +135,7 @@ const ResumeForm = ({ resume }: ResumeFormProps) => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
||||||
|
@ -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'
|
||||||
)
|
)
|
||||||
@ -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'
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
@ -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))
|
||||||
|
@ -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} />
|
|
||||||
)
|
|
@ -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
|
||||||
|
@ -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 {
|
||||||
|
@ -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',
|
||||||
|
@ -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>
|
||||||
))
|
))
|
||||||
|
@ -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>
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
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 = () => (
|
||||||
<>
|
<>
|
||||||
@ -33,6 +35,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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
|
Reference in New Issue
Block a user