23 Commits

Author SHA1 Message Date
d48cfe12f2 [#1] Cleaner homepage
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 1m4s
2024-10-25 18:35:23 -04:00
3a9cc20f86 [#1] Less cluttered projects page 2024-10-25 17:34:29 -04:00
54a34ef5ee [#1] No animated text 2024-10-24 21:22:02 -04:00
f097d7761d [#1] Clearer location 2024-10-24 20:06:09 -04:00
03717113f4 [#1] Add default theme env variable 2024-10-24 18:38:07 -04:00
284a4c5520 [#1] Remove carousel 2024-10-24 18:14:19 -04:00
f03faabbee UI tweaks on admin side
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 11s
2024-10-23 16:52:10 -04:00
353fb3899e PDF error handling 2024-10-23 12:26:21 -04:00
62ce137bcb Add og metatags to public facing pages
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 11s
2024-10-22 16:48:48 -04:00
f8987b08da Add persistent flag of origin
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 1m12s
2024-10-17 21:36:50 -04:00
cbf75acbeb Fix empty images section if no images + Remove postgres port 2024-10-17 20:46:34 -04:00
7973663b2a Allow separate from and to emails
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 10s
2024-10-16 21:45:55 -04:00
6540329f36 Use SMTP credentials instead of Gmail auth for Email 2024-10-16 21:36:57 -04:00
bac5b5fe48 Revert prefers-color-scheme 2024-10-16 21:21:58 -04:00
f3f75d3e57 Fix file uploads clearing on upgrade 2024-10-15 22:51:41 -04:00
f14732cdf0 Upgrade RedwoodJS
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 1m6s
2024-10-15 14:52:01 -04:00
77db153fe6 Add Matrix social 2024-10-15 14:51:43 -04:00
684d6f88c2 Combine GraphQL queries for contact card (2 -> 1) 2024-10-15 14:30:05 -04:00
03f606bbde Improve color picker paste logic 2024-10-15 14:21:52 -04:00
1eafaee2c0 Follow system color scheme by default instead of light 2024-10-10 14:09:09 -04:00
b8063e8692 Auth tweaks
All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 26s
2024-10-09 20:44:12 -04:00
738260f7de Watermark 2024-10-09 20:37:27 -04:00
82313bef46 Simplify PDF embed 2024-10-09 20:32:39 -04:00
69 changed files with 1365 additions and 1064 deletions

View File

@ -18,8 +18,19 @@ PRISMA_HIDE_UPDATE_MESSAGE=true
FIRST_NAME=firstname FIRST_NAME=firstname
LAST_NAME=lastname LAST_NAME=lastname
GMAIL=example@gmail.com DEFAULT_THEME=light
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
COUNTRY=US
STATE=New York
CITY=Manhattan
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=noreply@example.com
EMAIL_FROM=noreply@example.com
EMAIL_TO=email@example.com
SMTP_PASSWORD=password
DOMAIN=example.com DOMAIN=example.com
API_DOMAIN=api.example.com API_DOMAIN=api.example.com

View File

@ -6,8 +6,19 @@
FIRST_NAME=firstname FIRST_NAME=firstname
LAST_NAME=lastname LAST_NAME=lastname
GMAIL=example@gmail.com DEFAULT_THEME=light
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
COUNTRY=US
STATE=New York
CITY=Manhattan
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=noreply@example.com
EMAIL_FROM=noreply@example.com
EMAIL_TO=email@example.com
SMTP_PASSWORD=password
DOMAIN=example.com DOMAIN=example.com
API_DOMAIN=api.example.com API_DOMAIN=api.example.com

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
@ -57,6 +62,10 @@ FROM api_build as web_build_with_prerender
ARG FIRST_NAME ARG FIRST_NAME
ARG LAST_NAME ARG LAST_NAME
ARG COUNTRY
ARG STATE
ARG CITY
ARG DEFAULT_THEME
ARG API_ADDRESS_PROD ARG API_ADDRESS_PROD
ARG API_ADDRESS_DEV ARG API_ADDRESS_DEV
@ -69,6 +78,10 @@ FROM base as web_build
ARG FIRST_NAME ARG FIRST_NAME
ARG LAST_NAME ARG LAST_NAME
ARG COUNTRY
ARG STATE
ARG CITY
ARG DEFAULT_THEME
ARG API_ADDRESS_PROD ARG API_ADDRESS_PROD
ARG API_ADDRESS_DEV ARG API_ADDRESS_DEV

View File

@ -6,10 +6,8 @@ Create two A records, one for the web side of the website and one for the api si
- It doesn't matter what reverse proxy you use (Nginx, Apache, Traefik, Caddy, etc) - It doesn't matter what reverse proxy you use (Nginx, Apache, Traefik, Caddy, etc)
1. Point the web domain to the web port (default: 8910) 1. Point the web domain to the web port (default: 8910)
2. Point the api domain to the api port (default: 8911) 2. Point the api domain to the api port (default: 8911)
### Gmail App Password ### SMTP
1. Go to your Google [account dashboard](https://myaccount.google.com) You will need credentials to authorize sending Email, instructions vary depending on provider (Gmail, Hotmail, etc).
2. Go to Security > 2-Step Verification > App Passwords > Create a new app password
3. Copy the 16 character password
### [Docker Compose](./docker-compose.yml) ### [Docker Compose](./docker-compose.yml)
```yaml ```yaml
version: '3.8' version: '3.8'
@ -23,21 +21,32 @@ 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 - STATE=New York # Optional, state or province
- CITY=Manhattan # Optional
- DEFAULT_THEME=light # 'light' or 'dark'
- SMTP_HOST=smtp.example.com
- SMTP_PORT=465
- SMTP_SECURE=true
- SMTP_USER=noreply@example.com
- EMAIL_FROM=noreply@example.com
- EMAIL_TO=email@example.com
- SMTP_PASSWORD=password
- DOMAIN=portfolio.example.com - DOMAIN=portfolio.example.com
- API_DOMAIN=api.portfolio.example.com - API_DOMAIN=api.portfolio.example.com
# Careful, addresses below must not end with a '/' # Careful, addresses below must not end with a '/'
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN - ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN - API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
ports: ports:
- '8910:8910' # Web - 8910:8910 # Web
- '8911:8911' # API - 8911:8911 # API
depends_on: depends_on:
- db - db
volumes:
- files:/home/node/app/api/files_prod
command: > command: >
/bin/sh -c " /bin/sh -c "
yarn rw build && yarn rw build &&
@ -50,23 +59,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)

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",
"countries-list": "^3.1.1",
"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

@ -9,7 +9,25 @@ import { createServer } from '@redwoodjs/api-server'
import { logger } from 'src/lib/logger' import { logger } from 'src/lib/logger'
import { handleTusUpload } from 'src/lib/tus' import { handleTusUpload } from 'src/lib/tus'
enum Theme {
light = 'light',
dark = 'dark',
}
;(async () => { ;(async () => {
const { countries } = await import('countries-list')
if (!Object.keys(countries).includes(process.env.COUNTRY))
throw new Error(
'Invalid COUNTRY environment variable, please select a valid ISO-3166-1 alpha-2 country code\n See https://en.wikipedia.org/wiki/ISO_3166-1#Codes'
)
if (!(process.env.DEFAULT_THEME.toLowerCase() in Theme))
throw new Error(
'Invalid DEFAULT_THEME environment variable, please select either light or dark'
)
const server = await createServer({ const server = await createServer({
logger, logger,
configureApiServer: async (server) => { configureApiServer: async (server) => {

View File

@ -9,21 +9,32 @@ 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 - STATE=New York # Optional, state or province
- CITY=Manhattan # Optional
- DEFAULT_THEME=light # 'light' or 'dark'
- SMTP_HOST=smtp.example.com
- SMTP_PORT=465
- SMTP_SECURE=true
- SMTP_USER=noreply@example.com
- EMAIL_FROM=noreply@example.com
- EMAIL_TO=email@example.com
- SMTP_PASSWORD=password
- DOMAIN=portfolio.example.com - DOMAIN=portfolio.example.com
- API_DOMAIN=api.portfolio.example.com - API_DOMAIN=api.portfolio.example.com
# Careful, addresses below must not end with a '/' # Careful, addresses below must not end with a '/'
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN - ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN - API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
ports: ports:
- '8910:8910' # Web - 8910:8910 # Web
- '8911:8911' # API - 8911:8911 # API
depends_on: depends_on:
- db - db
volumes:
- files:/home/node/app/api/files_prod
command: > command: >
/bin/sh -c " /bin/sh -c "
yarn rw build && yarn rw build &&
@ -36,13 +47,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

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

View File

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

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),

View File

@ -14,11 +14,11 @@
"@icons-pack/react-simple-icons": "^10.0.0", "@icons-pack/react-simple-icons": "^10.0.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@redwoodjs/auth-dbauth-web": "8.3.0", "@redwoodjs/auth-dbauth-web": "8.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,16 +35,16 @@
"@uppy/react": "^4.0.1", "@uppy/react": "^4.0.1",
"@uppy/tus": "^4.0.0", "@uppy/tus": "^4.0.0",
"@uppy/webcam": "^4.0.1", "@uppy/webcam": "^4.0.1",
"countries-list": "^3.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"humanize-string": "2.1.0", "humanize-string": "2.1.0",
"react": "18.3.1", "react": "18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-html-parser": "^2.0.2", "react-html-parser": "^2.0.2"
"react-typed": "^2.0.12"
}, },
"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",

View File

@ -31,11 +31,11 @@ const Routes = () => {
<Route path="/admin/titles" page={TitleTitlesPage} name="titles" /> <Route path="/admin/titles" page={TitleTitlesPage} name="titles" />
</Set> </Set>
<Set wrap={ScaffoldLayout} title="Resume" titleTo="adminResume"> <Set wrap={ScaffoldLayout} title="Résumé" titleTo="adminResume">
<Route path="/admin/resume" page={ResumeAdminResumePage} name="adminResume" /> <Route path="/admin/resume" page={ResumeAdminResumePage} name="adminResume" />
</Set> </Set>
<Set wrap={ScaffoldLayout} title="Projects" titleTo="projects" buttonLabel="New Project" buttonTo="newProject"> <Set wrap={ScaffoldLayout} title="Projects" titleTo="adminProjects" buttonLabel="New Project" buttonTo="newProject">
<Route path="/admin/projects/new" page={ProjectNewProjectPage} name="newProject" /> <Route path="/admin/projects/new" page={ProjectNewProjectPage} name="newProject" />
<Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" /> <Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" />
<Route path="/admin/projects/{id:Int}" page={ProjectAdminProjectPage} name="adminProject" /> <Route path="/admin/projects/{id:Int}" page={ProjectAdminProjectPage} name="adminProject" />

View File

@ -1,56 +0,0 @@
import { useState, useRef, useCallback, useEffect } from 'react'
const SCROLL_INTERVAL_SECONDS = 3
interface AutoCarouselProps {
images: string[]
}
const AutoCarousel = ({ images }: AutoCarouselProps) => {
const [activeItem, setActiveItem] = useState<number>(0)
const ref = useRef<HTMLDivElement>(null)
const scroll = useCallback(() => {
setActiveItem((prev) => {
if (images.length - 1 > prev) return prev + 1
else return 0
})
}, [images.length])
const autoScroll = useCallback(
() => setInterval(scroll, SCROLL_INTERVAL_SECONDS * 1000),
[scroll]
)
useEffect(() => {
const play = autoScroll()
return () => clearInterval(play)
}, [autoScroll])
useEffect(() => {
const width = ref.current?.getBoundingClientRect().width
ref.current?.scroll({ left: activeItem * (width || 0) })
}, [activeItem])
return (
<div
ref={ref}
className="carousel carousel-center p-2 space-x-2 rounded-box"
>
{images.map((image, i) => (
<div
key={i}
className="carousel-item w-full h-fit my-auto justify-center"
>
<img
src={image}
alt={`${i}`}
className="object-contain rounded-xl size-fit"
/>
</div>
))}
</div>
)
}
export default AutoCarousel

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

View File

@ -1,9 +1,14 @@
import type { FindPortrait, FindPortraitVariables } from 'types/graphql'
import type { import type {
TypedDocumentNode, ContactCardPortrait,
CellFailureProps, ContactCardPortraitVariables,
CellSuccessProps, } from 'types/graphql'
import { routes } from '@redwoodjs/router'
import {
type TypedDocumentNode,
type CellFailureProps,
type CellSuccessProps,
Metadata,
} from '@redwoodjs/web' } from '@redwoodjs/web'
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty' import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
@ -11,25 +16,49 @@ import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
import CellLoading from 'src/components/Cell/CellLoading/CellLoading' import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard' import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard'
export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> = export const QUERY: TypedDocumentNode<
gql` ContactCardPortrait,
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

@ -46,29 +46,28 @@ const AdminProject = ({ project }: Props) => {
} }
return ( return (
<div className="flex w-full justify-center"> <div className="flex justify-center">
<div> <div>
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100"> <div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="table"> <table className="table">
<thead className="bg-base-200 font-syne"> <thead className="bg-base-200 font-syne">
<tr> <tr>
<th className="w-0"> <th colSpan={2}>
Project {project.id}: {project.title} Project {project.id}: {project.title}
</th> </th>
<th>&nbsp;</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<th>ID</th> <th className="text-right">ID</th>
<td>{project.id}</td> <td>{project.id}</td>
</tr> </tr>
<tr> <tr>
<th>Title</th> <th className="text-right">Title</th>
<td>{project.title}</td> <td>{project.title}</td>
</tr> </tr>
<tr> <tr>
<th>Description</th> <th className="text-right">Description</th>
<td> <td>
<article className="prose"> <article className="prose">
{parseHtml(project.description)} {parseHtml(project.description)}
@ -76,11 +75,11 @@ const AdminProject = ({ project }: Props) => {
</td> </td>
</tr> </tr>
<tr> <tr>
<th>Date</th> <th className="text-right">Date</th>
<td>{timeTag(project.date)}</td> <td>{timeTag(project.date)}</td>
</tr> </tr>
<tr> <tr>
<th>Images</th> <th className="text-right">Images</th>
<td> <td>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{project.images.map((image, i) => ( {project.images.map((image, i) => (
@ -88,7 +87,7 @@ const AdminProject = ({ project }: Props) => {
key={i} key={i}
href={image} href={image}
target="_blank" target="_blank"
className="btn btn-sm btn-square" className={`btn btn-sm btn-square ${i === 0 && 'btn-primary'}`}
rel="noreferrer" rel="noreferrer"
> >
{i + 1} {i + 1}
@ -98,7 +97,7 @@ const AdminProject = ({ project }: Props) => {
</td> </td>
</tr> </tr>
<tr> <tr>
<th>Tags</th> <th className="text-right">Tags</th>
<td> <td>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{project.tags.map((tag, i) => ( {project.tags.map((tag, i) => (
@ -120,19 +119,30 @@ const AdminProject = ({ project }: Props) => {
</td> </td>
</tr> </tr>
<tr> <tr>
<th>Links</th> <th className="text-right">Links</th>
<td> <td>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{project.links.map((link, i) => ( {project.links.map((link, i) => (
<a <>
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

@ -69,20 +69,23 @@ const Project = ({ project }: Props) => {
</div> </div>
</> </>
)} )}
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2> {project.images.length > 0 && (
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
)}
</div> </div>
<div className="flex flex-wrap gap-4 items-center pt-8 justify-center"> <div className="flex flex-wrap gap-4 pt-8 justify-center h-fit sm:p-8">
{project.images.map((image, i) => ( {project.images.length > 0 &&
<a project.images.map((image, i) => (
href={image} <a
target="_blank" href={image}
rel="noreferrer" target="_blank"
key={i} rel="noreferrer"
className="rounded-xl" key={i}
> className="rounded-box"
<img src={image} alt="" className="rounded-xl" /> >
</a> <img src={image} alt="" className="rounded-xl" />
))} </a>
))}
</div> </div>
</div> </div>
) )

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

@ -119,7 +119,7 @@ const ProjectForm = (props: ProjectFormProps) => {
<Form<FormProject> <Form<FormProject>
onSubmit={onSubmit} onSubmit={onSubmit}
error={props.error} error={props.error}
className="space-y-2 w-80" className="space-y-2"
> >
<Label name="title" className="form-control w-full"> <Label name="title" className="form-control w-full">
<Label <Label
@ -242,7 +242,14 @@ const ProjectForm = (props: ProjectFormProps) => {
<img src={fileId} alt={i.toString()} /> <img src={fileId} alt={i.toString()} />
</figure> </figure>
<div className="card-body p-2 rounded-xl"> <div className="card-body p-2 rounded-xl">
<div className="card-actions rounded-md justify-end"> <div
className={`card-actions rounded-md ${i === 0 ? 'justify-between' : 'justify-end'}`}
>
{i === 0 && (
<div className="btn btn-sm shadow-xl no-animation">
Cover Image
</div>
)}
<button <button
type="button" type="button"
className="btn btn-square btn-sm btn-error shadow-xl" className="btn btn-square btn-sm btn-error shadow-xl"
@ -261,9 +268,8 @@ const ProjectForm = (props: ProjectFormProps) => {
{appendUploader && ( {appendUploader && (
<Uploader <Uploader
onComplete={onUploadComplete} onComplete={onUploadComplete}
width="20rem" width="auto"
height="30rem" height="30rem"
className="flex justify-center"
maxFiles={10} maxFiles={10}
disabled={props.loading} disabled={props.loading}
/> />
@ -272,9 +278,8 @@ const ProjectForm = (props: ProjectFormProps) => {
) : ( ) : (
<Uploader <Uploader
onComplete={onUploadComplete} onComplete={onUploadComplete}
width="20rem" width="auto"
height="30rem" height="30rem"
className="flex justify-center pt-3"
maxFiles={10} maxFiles={10}
disabled={props.loading} disabled={props.loading}
/> />

View File

@ -1,113 +0,0 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { format, isAfter, startOfToday } from 'date-fns'
import parseHtml from 'react-html-parser'
import { FindProjects } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import AutoCarousel from 'src/components/AutoCarousel/AutoCarousel'
import { calculateLuminance } from 'src/lib/color'
const CARD_WIDTH = 384
const ProjectsShowcase = ({ projects }: FindProjects) => {
const ref = useRef<HTMLDivElement>(null)
const [columns, setColumns] = useState<number>(
Math.max(
Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
1
)
)
useLayoutEffect(() => {
const handleResize = () =>
setColumns(
Math.max(
Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
1
)
)
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return (
<div ref={ref} className="flex flex-wrap justify-center gap-2">
{split(
projects
.slice()
.sort((a, b) =>
isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1
),
columns
).map((projectChunk, i) => (
<div className="flex flex-col gap-2" key={i}>
{projectChunk.map((project, j) => (
<Link key={`${i}-${j}`} to={routes.project({ id: project.id })}>
<div className="card card-compact bg-base-100 w-96 h-fit shadow-xl transition-all hover:-translate-y-1 hover:shadow-2xl">
{project.images.length > 0 && (
<AutoCarousel images={project.images} />
)}
<div className="card-body">
<div className="card-title overflow-auto">
<p className="whitespace-nowrap">{project.title}</p>
</div>
<div className="line-clamp-5">
<article className="prose text-sm">
{parseHtml(project.description)}
</article>
</div>
<div className="card-actions justify-between">
<div className="flex gap-2">
{isAfter(new Date(project.date), startOfToday()) && (
<div className="badge badge-info">planned</div>
)}
<div className="badge badge-ghost">
{format(project.date, 'yyyy-MM-dd')}
</div>
</div>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag, i) => (
<div
key={i}
className="badge whitespace-nowrap"
style={{
backgroundColor: tag.color,
color:
calculateLuminance(tag.color) > 0.5
? 'black'
: 'white',
}}
>
{tag.tag}
</div>
))}
</div>
</div>
</div>
</div>
</Link>
))}
</div>
))}
</div>
)
}
export default ProjectsShowcase
function split<T>(arr: T[], chunks: number): T[][] {
const result: T[][] = []
const chunkSize = Math.ceil(arr.length / chunks)
for (let i = 0; i < arr.length; i += chunkSize) {
result.push(arr.slice(i, i + chunkSize))
}
return result
}

View File

@ -1,16 +1,17 @@
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'
import CellFailure from 'src/components/Cell/CellFailure/CellFailure' 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 ProjectsShowcaseList from 'src/components/ProjectsShowcaseList/ProjectsShowcaseList'
import ProjectsShowcase from '../ProjectsShowcase/ProjectsShowcase'
export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> = export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> =
gql` gql`
@ -40,5 +41,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(),
}}
/>
<ProjectsShowcaseList projects={projects} />
</>
) )

View File

@ -0,0 +1,72 @@
import { format, isAfter, startOfToday } from 'date-fns'
import parseHtml from 'react-html-parser'
import { FindProjects } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import { calculateLuminance } from 'src/lib/color'
const ProjectsShowcaseList = ({ projects }: FindProjects) => (
<div className="flex flex-col gap-4 w-fit mx-auto">
{projects
.slice()
.sort((a, b) => (isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1))
.map((project, i) => (
<Link
key={i}
to={routes.project({ id: project.id })}
className="bg-base-100 flex flex-col sm:flex-row p-2 gap-2 shadow-xl rounded-box hover:shadow-2xl transition-all hover:-translate-y-1 sm:max-h-64 sm:max-w-5xl"
>
{project.images.length > 0 && (
<img
src={project.images[0]}
alt={`${i}`}
className="object-cover rounded-lg sm:max-w-[33.33%]"
/>
)}
<div className="flex flex-col gap-2 p-4">
<div className="card-title overflow-auto">
<p className="whitespace-nowrap">{project.title}</p>
</div>
<div className="line-clamp-5 mb-auto">
<article className="prose text-sm max-w-none">
{parseHtml(project.description)}
</article>
</div>
<div className="card-actions justify-between">
<div className="flex gap-2">
{isAfter(new Date(project.date), startOfToday()) && (
<div className="badge badge-info">planned</div>
)}
<div className="badge badge-ghost">
{format(project.date, 'yyyy-MM-dd')}
</div>
</div>
<div className="flex flex-wrap gap-2">
{project.tags.slice(0, 3).map((tag, i) => (
<div
key={i}
className="badge whitespace-nowrap"
style={{
backgroundColor: tag.color,
color:
calculateLuminance(tag.color) > 0.5 ? 'black' : 'white',
}}
>
{tag.tag}
</div>
))}
{project.tags.length > 3 && (
<div key={i} className="badge badge-ghost whitespace-nowrap">
+{project.tags.length - 3}
</div>
)}
</div>
</div>
</div>
</Link>
))}
</div>
)
export default ProjectsShowcaseList

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

@ -43,120 +43,122 @@ const RichTextEditor = ({ editor }: RichTextEditorProps) => {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2 justify-center"> <div className="flex justify-center flex-wrap gap-2">
<button <div className="flex gap-2 h-min justify-center">
type="button" <button
className={`btn btn-sm btn-square ${editor.isActive('link') ? 'btn-primary' : ''}`} type="button"
onClick={setLink} className={`btn btn-sm btn-square ${editor.isActive('link') ? 'btn-primary' : ''}`}
> onClick={setLink}
<Icon path={mdiLinkVariant} className="size-5" /> >
</button> <Icon path={mdiLinkVariant} className="size-5" />
<button </button>
type="button" <button
className="btn btn-sm btn-square" type="button"
onClick={() => editor.chain().focus().unsetLink().run()} className="btn btn-sm btn-square"
disabled={!editor.isActive('link')} onClick={() => editor.chain().focus().unsetLink().run()}
> disabled={!editor.isActive('link')}
<Icon path={mdiLinkVariantOff} className="size-5" /> >
</button> <Icon path={mdiLinkVariantOff} className="size-5" />
<button </button>
type="button" <button
className={`btn btn-sm btn-square ${editor.isActive('bulletList') ? 'btn-primary' : ''}`} type="button"
onClick={() => editor.chain().focus().toggleBulletList().run()} className={`btn btn-sm btn-square ${editor.isActive('bulletList') ? 'btn-primary' : ''}`}
> onClick={() => editor.chain().focus().toggleBulletList().run()}
<Icon path={mdiFormatListBulleted} className="size-5" /> >
</button> <Icon path={mdiFormatListBulleted} className="size-5" />
<button </button>
type="button" <button
className={`btn btn-sm btn-square ${editor.isActive('orderedList') ? 'btn-primary' : ''}`} type="button"
onClick={() => editor.chain().focus().toggleOrderedList().run()} className={`btn btn-sm btn-square ${editor.isActive('orderedList') ? 'btn-primary' : ''}`}
> onClick={() => editor.chain().focus().toggleOrderedList().run()}
<Icon path={mdiFormatListNumbered} className="size-5" /> >
</button> <Icon path={mdiFormatListNumbered} className="size-5" />
<button </button>
type="button" <button
className="btn btn-sm btn-square" type="button"
onClick={() => editor.chain().focus().unsetAllMarks().run()} className="btn btn-sm btn-square"
> onClick={() => editor.chain().focus().unsetAllMarks().run()}
<Icon path={mdiFormatClear} className="size-5" /> >
</button> <Icon path={mdiFormatClear} className="size-5" />
<button </button>
type="button" <button
className="btn btn-sm btn-square" type="button"
onClick={() => editor.chain().focus().undo().run()} className="btn btn-sm btn-square"
disabled={!editor.can().chain().focus().undo().run()} onClick={() => editor.chain().focus().undo().run()}
> disabled={!editor.can().chain().focus().undo().run()}
<Icon path={mdiUndoVariant} className="size-5" /> >
</button> <Icon path={mdiUndoVariant} className="size-5" />
<button </button>
type="button" <button
className="btn btn-sm btn-square" type="button"
onClick={() => editor.chain().focus().redo().run()} className="btn btn-sm btn-square"
disabled={!editor.can().chain().focus().redo().run()} onClick={() => editor.chain().focus().redo().run()}
> disabled={!editor.can().chain().focus().redo().run()}
<Icon path={mdiRedoVariant} className="size-5" /> >
</button> <Icon path={mdiRedoVariant} className="size-5" />
</div> </button>
<div className="flex flex-wrap gap-2 justify-center"> </div>
<button <div className="flex gap-2 h-min justify-center">
type="button" <button
className={`btn btn-sm btn-square ${editor.isActive('bold') ? 'btn-primary' : ''}`} type="button"
onClick={() => editor.chain().focus().toggleBold().run()} className={`btn btn-sm btn-square ${editor.isActive('bold') ? 'btn-primary' : ''}`}
disabled={!editor.can().chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
> disabled={!editor.can().chain().focus().toggleBold().run()}
<Icon path={mdiFormatBold} className="size-5" /> >
</button> <Icon path={mdiFormatBold} className="size-5" />
<button </button>
type="button" <button
className={`btn btn-sm btn-square ${editor.isActive('italic') ? 'btn-primary' : ''}`} type="button"
onClick={() => editor.chain().focus().toggleItalic().run()} className={`btn btn-sm btn-square ${editor.isActive('italic') ? 'btn-primary' : ''}`}
disabled={!editor.can().chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
> disabled={!editor.can().chain().focus().toggleItalic().run()}
<Icon path={mdiFormatItalic} className="size-5" /> >
</button> <Icon path={mdiFormatItalic} className="size-5" />
<button </button>
type="button" <button
className={`btn btn-sm btn-square ${editor.isActive('underline') ? 'btn-primary' : ''}`} type="button"
onClick={() => editor.chain().focus().toggleUnderline().run()} className={`btn btn-sm btn-square ${editor.isActive('underline') ? 'btn-primary' : ''}`}
disabled={!editor.can().chain().focus().toggleUnderline().run()} onClick={() => editor.chain().focus().toggleUnderline().run()}
> disabled={!editor.can().chain().focus().toggleUnderline().run()}
<Icon path={mdiFormatUnderline} className="size-5" /> >
</button> <Icon path={mdiFormatUnderline} className="size-5" />
<button </button>
type="button" <button
className={`btn btn-sm btn-square ${editor.isActive('static') ? 'btn-primary' : ''}`} type="button"
onClick={() => editor.chain().focus().toggleStrike().run()} className={`btn btn-sm btn-square ${editor.isActive('static') ? 'btn-primary' : ''}`}
disabled={!editor.can().chain().focus().toggleStrike().run()} onClick={() => editor.chain().focus().toggleStrike().run()}
> disabled={!editor.can().chain().focus().toggleStrike().run()}
<Icon path={mdiFormatStrikethrough} className="size-5" /> >
</button> <Icon path={mdiFormatStrikethrough} className="size-5" />
<button </button>
type="button" <button
className={`btn btn-sm btn-square ${editor.isActive('code') ? 'btn-primary' : ''}`} type="button"
onClick={() => editor.chain().focus().toggleCode().run()} className={`btn btn-sm btn-square ${editor.isActive('code') ? 'btn-primary' : ''}`}
disabled={!editor.can().chain().focus().toggleCode().run()} onClick={() => editor.chain().focus().toggleCode().run()}
> disabled={!editor.can().chain().focus().toggleCode().run()}
<Icon path={mdiXml} className="size-5" /> >
</button> <Icon path={mdiXml} className="size-5" />
<button </button>
type="button" <button
className={`btn btn-sm btn-square ${editor.isActive('codeBlock') ? 'btn-primary' : ''}`} type="button"
onClick={() => editor.chain().focus().toggleCodeBlock().run()} className={`btn btn-sm btn-square ${editor.isActive('codeBlock') ? 'btn-primary' : ''}`}
disabled={!editor.can().chain().focus().toggleCodeBlock().run()} onClick={() => editor.chain().focus().toggleCodeBlock().run()}
> disabled={!editor.can().chain().focus().toggleCodeBlock().run()}
<Icon path={mdiCodeBracesBox} className="size-5" /> >
</button> <Icon path={mdiCodeBracesBox} className="size-5" />
<button </button>
type="button" <button
className={`btn btn-sm btn-square ${editor.isActive('blockquote') ? 'btn-primary' : ''}`} type="button"
onClick={() => editor.chain().focus().toggleBlockquote().run()} className={`btn btn-sm btn-square ${editor.isActive('blockquote') ? 'btn-primary' : ''}`}
> onClick={() => editor.chain().focus().toggleBlockquote().run()}
<Icon path={mdiFormatQuoteClose} className="size-5" /> >
</button> <Icon path={mdiFormatQuoteClose} className="size-5" />
</button>
</div>
</div> </div>
<EditorContent <EditorContent
editor={editor} editor={editor}
className="textarea textarea-bordered font-normal prose" className="textarea textarea-bordered font-normal prose max-w-full"
/> />
</div> </div>
) )

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,13 +1,15 @@
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 rounded-btn`}
>
{[...socials] {[...socials]
.sort((a, b) => sortOrder.indexOf(a.type) - sortOrder.indexOf(b.type)) .sort((a, b) => sortOrder.indexOf(a.type) - sortOrder.indexOf(b.type))
.map((social, i) => ( .map((social, i) => (
<div key={i} className="tooltip" data-tip={social.name}> <div key={i} className="tooltip rounded-btn" data-tip={social.name}>
<a <a
className="btn btn-square" className="btn btn-square"
href={`${baseUrls[social.type]}${social.username}`} href={`${baseUrls[social.type]}${social.username}`}

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

@ -8,7 +8,10 @@ const DARK_THEME = 'dark'
const ThemeToggle = () => { const ThemeToggle = () => {
const [theme, setTheme] = useState( const [theme, setTheme] = useState(
localStorage.getItem('theme') ?? LIGHT_THEME localStorage.getItem('theme') ||
([LIGHT_THEME, DARK_THEME].includes(process.env.DEFAULT_THEME)
? process.env.DEFAULT_THEME
: LIGHT_THEME)
) )
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => { const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {

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

@ -1,38 +1,28 @@
import { ReactTyped } from 'react-typed'
interface TitlesProps { interface TitlesProps {
titles: string[] titles: string[]
className?: string className?: string
} }
export const Titles = ({ titles, className }: TitlesProps) => { export const Titles = ({ titles }: TitlesProps) => {
const titlesFiltered = titles.filter((title) => title !== '') const titlesFiltered = titles.filter((title) => title !== '')
return ( return (
<> <>
<h1 className="text-3xl sm:text-5xl font-bold"> {titlesFiltered.length >= 3 && (
Hey 👋, I&apos;m {`${process.env.FIRST_NAME}`} <div className="flex flex-col sm:flex-row gap-4 sm:gap-0 justify-center items-center">
{titlesFiltered.length > 0 && ( <span className="badge md:badge-lg badge-ghost">
<> {titlesFiltered[0]}
, <br /> </span>
<ReactTyped <div className="hidden sm:divider sm:divider-horizontal" />
className={className} <span className="badge md:badge-lg badge-ghost">
strings={titlesFiltered} {titlesFiltered[1]}
typeSpeed={50} </span>
backSpeed={40} <div className="hidden sm:divider sm:divider-horizontal" />
backDelay={1000} <span className="badge md:badge-lg badge-ghost">
startWhenVisible {titlesFiltered[2]}
loop </span>
onStringTyped={(pos, self) => { </div>
if (pos === 0) { )}
self.stop()
setTimeout(() => self.start(), 2500)
}
}}
/>
</>
)}
</h1>
</> </>
) )
} }

View File

@ -8,8 +8,6 @@ import { Form, Label, Submit, TextField } from '@redwoodjs/forms'
import { TypedDocumentNode, useMutation } from '@redwoodjs/web' import { TypedDocumentNode, useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast' import { toast } from '@redwoodjs/web/toast'
const MAX_TITLES = 5
interface TitlesFormProps { interface TitlesFormProps {
titles?: Titles titles?: Titles
} }
@ -25,14 +23,10 @@ const UPDATE_TITLES_MUTATION: TypedDocumentNode<UpdateTitlesInput> = gql`
const TitlesForm = ({ titles }: TitlesFormProps) => { const TitlesForm = ({ titles }: TitlesFormProps) => {
const title1ref = useRef<HTMLInputElement>(null) const title1ref = useRef<HTMLInputElement>(null)
const [preview, setPreview] = useState<boolean>(false)
const states = [ const states = [
useState<string>(titles?.titles[0]), useState<string>(titles?.titles[0]),
useState<string>(titles?.titles[1]), useState<string>(titles?.titles[1]),
useState<string>(titles?.titles[2]), useState<string>(titles?.titles[2]),
useState<string>(titles?.titles[3]),
useState<string>(titles?.titles[4]),
] ]
useEffect(() => title1ref.current?.focus(), []) useEffect(() => title1ref.current?.focus(), [])
@ -55,11 +49,8 @@ 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"> {Array.from({ length: 3 }).map((_, i) => (
The first one gets displayed for longer
</p>
{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">
<Label <Label
name={`title${i}`} name={`title${i}`}
@ -83,25 +74,24 @@ const TitlesForm = ({ titles }: TitlesFormProps) => {
className="w-full" className="w-full"
/> />
</Label> </Label>
{preview && (
<div className="label">
<p>
Hey 👋, I&apos;m {`${process.env.FIRST_NAME}`},{' '}
<span className="text-primary">{states[i][0]}</span>
</p>
</div>
)}
</Label> </Label>
))} ))}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-0 justify-center items-center py-2">
<span className="badge badge-lg badge-ghost whitespace-nowrap">
{states[0][0]}
</span>
<div className="hidden sm:divider sm:divider-horizontal" />
<span className="badge badge-lg badge-ghost whitespace-nowrap">
{states[1][0]}
</span>
<div className="hidden sm:divider sm:divider-horizontal" />
<span className="badge badge-lg badge-ghost whitespace-nowrap">
{states[2][0]}
</span>
</div>
<nav className="my-2 flex justify-center space-x-2"> <nav className="my-2 flex justify-center space-x-2">
<button
type="button"
className="btn btn-sm"
onClick={() => setPreview(!preview)}
>
{preview ? 'Hide' : 'Show'} Preview
</button>
<Submit <Submit
disabled={updateLoading} disabled={updateLoading}
className="btn btn-primary btn-sm uppercase" className="btn btn-primary btn-sm uppercase"

View File

@ -1,3 +1,4 @@
import { countries } from 'countries-list'
import { hydrateRoot, createRoot } from 'react-dom/client' import { hydrateRoot, createRoot } from 'react-dom/client'
import App from 'src/App' import App from 'src/App'
@ -7,6 +8,11 @@ import App from 'src/App'
* rather than replacing it. * rather than replacing it.
* https://react.dev/reference/react-dom/client/hydrateRoot * https://react.dev/reference/react-dom/client/hydrateRoot
*/ */
enum Theme {
light = 'light',
dark = 'dark',
}
const redwoodAppElement = document.getElementById('redwood-app') const redwoodAppElement = document.getElementById('redwood-app')
if (!redwoodAppElement) if (!redwoodAppElement)
@ -15,6 +21,16 @@ if (!redwoodAppElement)
"exists in your 'web/src/index.html' file." "exists in your 'web/src/index.html' file."
) )
if (!Object.keys(countries).includes(process.env.COUNTRY))
throw new Error(
'Invalid COUNTRY environment variable, please select a valid ISO-3166-1 alpha-2 country code\n See https://en.wikipedia.org/wiki/ISO_3166-1#Codes'
)
if (!(process.env.DEFAULT_THEME.toLowerCase() in Theme))
throw new Error(
'Invalid DEFAULT_THEME environment variable, please select either light or dark'
)
if (redwoodAppElement.children?.length > 0) if (redwoodAppElement.children?.length > 0)
hydrateRoot(redwoodAppElement, <App />) hydrateRoot(redwoodAppElement, <App />)
else { else {

View File

@ -23,3 +23,7 @@
.ProseMirror:focus { .ProseMirror:focus {
outline: none; outline: none;
} }
.w-full .react-colorful {
width: auto;
}

View File

@ -1,7 +1,7 @@
import { mdiMenu, mdiLogout, mdiCog } from '@mdi/js' import { mdiMenu, mdiLogout, mdiCog } from '@mdi/js'
import Icon from '@mdi/react' import Icon from '@mdi/react'
import { Link, routes } from '@redwoodjs/router' import { Link, routes, useRoutePath, useRoutePaths } from '@redwoodjs/router'
import { useAuth } from 'src/auth' import { useAuth } from 'src/auth'
import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle' import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
@ -17,6 +17,9 @@ type NavbarLayoutProps = {
} }
const NavbarLayout = ({ children }: NavbarLayoutProps) => { const NavbarLayout = ({ children }: NavbarLayoutProps) => {
const routePaths = useRoutePaths()
const routePath = useRoutePath()
const { isAuthenticated, logOut } = useAuth() const { isAuthenticated, logOut } = useAuth()
const navbarRoutes: NavbarRoute[] = [ const navbarRoutes: NavbarRoute[] = [
@ -25,7 +28,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
path: routes.projects(), path: routes.projects(),
}, },
{ {
name: 'Resume', name: 'Résumé',
path: routes.resume(), path: routes.resume(),
}, },
{ {
@ -56,7 +59,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
path: routes.titles(), path: routes.titles(),
}, },
{ {
name: 'Resume', name: 'Résumé',
path: routes.adminResume(), path: routes.adminResume(),
}, },
] ]
@ -80,12 +83,14 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
<ToasterWrapper /> <ToasterWrapper />
<div className="sticky top-0 z-50 p-2"> <div className="sticky top-0 z-50 p-2">
<div className="navbar rounded-xl bg-base-300 backdrop-blur bg-opacity-90 shadow-xl"> <div className="navbar rounded-xl bg-base-300 backdrop-blur bg-opacity-90 shadow-xl">
<div className="navbar-start space-x-2 lg:first:space-x-0"> <div
className={`navbar-start space-x-2 ${!isAuthenticated && routePath === routePaths.home ? 'first:space-x-0' : 'lg:first:space-x-0'}`}
>
<div className="dropdown"> <div className="dropdown">
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
className="btn btn-square btn-ghost lg:hidden" className={`btn btn-square btn-ghost ${!isAuthenticated && routePath === routePaths.home ? 'hidden' : 'lg:hidden'}`}
> >
<Icon path={mdiMenu} className="text-base-content-100 size-8" /> <Icon path={mdiMenu} className="text-base-content-100 size-8" />
</div> </div>
@ -94,12 +99,12 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
tabIndex={0} tabIndex={0}
className="menu dropdown-content -ml-2 mt-4 w-36 gap-2 rounded-box bg-base-200 shadow-xl" className="menu dropdown-content -ml-2 mt-4 w-36 gap-2 rounded-box bg-base-200 shadow-xl"
> >
{isAuthenticated && ( {isAuthenticated && routePath !== routePaths.home && (
<p className="btn btn-active no-animation btn-sm btn-block"> <p className="btn btn-active no-animation btn-sm btn-block">
Public Public
</p> </p>
)} )}
{navbarButtons()} {routePath !== routePaths.home && navbarButtons()}
{isAuthenticated && ( {isAuthenticated && (
<> <>
<p className="btn btn-active no-animation btn-sm btn-block"> <p className="btn btn-active no-animation btn-sm btn-block">
@ -129,7 +134,9 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
</Link> </Link>
</div> </div>
<div className="navbar-center hidden lg:flex"> <div className="navbar-center hidden lg:flex">
<div className="space-x-2 font-syne">{navbarButtons()}</div> <div className="space-x-2 font-syne">
{routePath !== routePaths.home && navbarButtons()}
</div>
</div> </div>
<div className="navbar-end space-x-2"> <div className="navbar-end space-x-2">
{isAuthenticated && ( {isAuthenticated && (

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(100dvh-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,31 +1,62 @@
import { mdiCompass, mdiContacts } from '@mdi/js' import { mdiCodeBraces, mdiContacts, mdiFileDocument } from '@mdi/js'
import Icon from '@mdi/react' import Icon from '@mdi/react'
import type { TCountryCode } from 'countries-list'
import { getCountryData } from 'countries-list'
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(100dvh-6rem)]">
<div className="hero-content flex flex-col gap-8"> <div className="hero-content flex flex-col gap-8">
<div className="text-center"> <div className="flex flex-col text-center gap-8">
<TitlesCell className="text-primary" /> <h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold">
Hey 👋, I&apos;m {`${process.env.FIRST_NAME}`}
</h1>
<TitlesCell />
<h3 className="text-xl">
📍{' '}
{[
process.env.CITY,
process.env.STATE,
getCountryData(process.env.COUNTRY as TCountryCode).name,
]
.filter((s) => s && s !== '')
.join(', ')}
</h3>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<Link <Link
to={routes.projects()} to={routes.projects()}
className="btn btn-primary btn-outline sm:btn-wide btn-lg" className="btn btn-primary btn-outline sm:w-40 sm:btn-lg"
> >
<Icon path={mdiCompass} className="size-6" /> <Icon path={mdiCodeBraces} className="size-6" />
Explore Projects
</Link>
<Link
to={routes.resume()}
className="btn btn-primary btn-outline sm:w-40 sm:btn-lg"
>
<Icon path={mdiFileDocument} className="size-6" />
Résumé
</Link> </Link>
<Link <Link
to={routes.contact()} to={routes.contact()}
className="btn btn-primary btn-outline sm:btn-wide btn-lg" className="btn btn-primary btn-outline sm:w-40 sm:btn-lg"
> >
<Icon path={mdiContacts} className="size-6" /> <Icon path={mdiContacts} className="size-6" />
Contact Contact
@ -33,6 +64,16 @@ 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>
</> </>
) )

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,19 +1,15 @@
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">
<h1 className="text-5xl font-bold">Projects</h1> <h1 className="text-5xl font-bold">Projects</h1>
<p className="py-6"> <p className="mt-8">
{mobile({ {mobile({
tablet: true, tablet: true,
}) })
@ -24,7 +20,6 @@ const ProjectsPage = () => {
</div> </div>
</div> </div>
</div> </div>
<ProjectsShowcaseCell /> <ProjectsShowcaseCell />
</> </>
) )

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

@ -5,7 +5,7 @@ import AdminResumeCell from 'src/components/Resume/AdminResumeCell/AdminResumeCe
const ResumePage = () => { const ResumePage = () => {
return ( return (
<> <>
<Metadata title="Resume" /> <Metadata title="Résumé" />
<AdminResumeCell /> <AdminResumeCell />
</> </>
) )

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

810
yarn.lock

File diff suppressed because it is too large Load Diff