Compare commits
26 Commits
Author | SHA1 | Date | |
---|---|---|---|
d48cfe12f2 | |||
3a9cc20f86 | |||
54a34ef5ee | |||
f097d7761d | |||
03717113f4 | |||
284a4c5520 | |||
f03faabbee | |||
353fb3899e | |||
62ce137bcb | |||
f8987b08da | |||
cbf75acbeb | |||
7973663b2a | |||
6540329f36 | |||
bac5b5fe48 | |||
f3f75d3e57 | |||
f14732cdf0 | |||
77db153fe6 | |||
684d6f88c2 | |||
03f606bbde | |||
1eafaee2c0 | |||
b8063e8692 | |||
738260f7de | |||
82313bef46 | |||
74db2e1034 | |||
e2dfb6f237 | |||
708634fa68 |
@ -18,8 +18,19 @@ PRISMA_HIDE_UPDATE_MESSAGE=true
|
||||
FIRST_NAME=firstname
|
||||
LAST_NAME=lastname
|
||||
|
||||
GMAIL=example@gmail.com
|
||||
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
|
||||
DEFAULT_THEME=light
|
||||
|
||||
COUNTRY=US
|
||||
STATE=New York
|
||||
CITY=Manhattan
|
||||
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=noreply@example.com
|
||||
EMAIL_FROM=noreply@example.com
|
||||
EMAIL_TO=email@example.com
|
||||
SMTP_PASSWORD=password
|
||||
|
||||
DOMAIN=example.com
|
||||
API_DOMAIN=api.example.com
|
||||
|
15
.env.example
15
.env.example
@ -6,8 +6,19 @@
|
||||
FIRST_NAME=firstname
|
||||
LAST_NAME=lastname
|
||||
|
||||
GMAIL=example@gmail.com
|
||||
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
|
||||
DEFAULT_THEME=light
|
||||
|
||||
COUNTRY=US
|
||||
STATE=New York
|
||||
CITY=Manhattan
|
||||
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=noreply@example.com
|
||||
EMAIL_FROM=noreply@example.com
|
||||
EMAIL_TO=email@example.com
|
||||
SMTP_PASSWORD=password
|
||||
|
||||
DOMAIN=example.com
|
||||
API_DOMAIN=api.example.com
|
||||
|
17
Dockerfile
17
Dockerfile
@ -43,8 +43,13 @@ ARG ADDRESS_DEV
|
||||
ARG DOMAIN
|
||||
ARG API_DOMAIN
|
||||
ARG MAX_HTTP_CONNECTIONS_PER_MINUTE
|
||||
ARG GMAIL
|
||||
ARG GMAIL_SMTP_PASSWORD
|
||||
ARG SMTP_HOST
|
||||
ARG SMTP_PORT
|
||||
ARG SMTP_SECURE
|
||||
ARG SMTP_USER
|
||||
ARG SMTP_PASSWORD
|
||||
ARG EMAIL_FROM
|
||||
ARG EMAIL_TO
|
||||
ARG FIRST_NAME
|
||||
ARG LAST_NAME
|
||||
|
||||
@ -57,6 +62,10 @@ FROM api_build as web_build_with_prerender
|
||||
|
||||
ARG FIRST_NAME
|
||||
ARG LAST_NAME
|
||||
ARG COUNTRY
|
||||
ARG STATE
|
||||
ARG CITY
|
||||
ARG DEFAULT_THEME
|
||||
ARG API_ADDRESS_PROD
|
||||
ARG API_ADDRESS_DEV
|
||||
|
||||
@ -69,6 +78,10 @@ FROM base as web_build
|
||||
|
||||
ARG FIRST_NAME
|
||||
ARG LAST_NAME
|
||||
ARG COUNTRY
|
||||
ARG STATE
|
||||
ARG CITY
|
||||
ARG DEFAULT_THEME
|
||||
ARG API_ADDRESS_PROD
|
||||
ARG API_ADDRESS_DEV
|
||||
|
||||
|
191
README.md
191
README.md
@ -1,122 +1,85 @@
|
||||
# README
|
||||
# Portfolio Website
|
||||
## Setup
|
||||
### Domain Records
|
||||
Create two A records, one for the web side of the website and one for the api side of the website. Ideally, the records should look something like `myportfolio.example.com` for the web side and `api.myportfolio.example.com`, but it is not important.
|
||||
### Reverse Proxy
|
||||
- It doesn't matter what reverse proxy you use (Nginx, Apache, Traefik, Caddy, etc)
|
||||
1. Point the web domain to the web port (default: 8910)
|
||||
2. Point the api domain to the api port (default: 8911)
|
||||
### SMTP
|
||||
You will need credentials to authorize sending Email, instructions vary depending on provider (Gmail, Hotmail, etc).
|
||||
### [Docker Compose](./docker-compose.yml)
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
Welcome to [RedwoodJS](https://redwoodjs.com)!
|
||||
services:
|
||||
portfolio:
|
||||
container_name: portfolio
|
||||
image: git.altaiar.dev/ahmed/portfolio:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- API_PROXY_TARGET=http://localhost:8911
|
||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||
- FIRST_NAME=first name # Your first name
|
||||
- LAST_NAME=lastname # Your last name
|
||||
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||
- STATE=New York # Optional, state or province
|
||||
- CITY=Manhattan # Optional
|
||||
- DEFAULT_THEME=light # 'light' or 'dark'
|
||||
- SMTP_HOST=smtp.example.com
|
||||
- SMTP_PORT=465
|
||||
- SMTP_SECURE=true
|
||||
- SMTP_USER=noreply@example.com
|
||||
- EMAIL_FROM=noreply@example.com
|
||||
- EMAIL_TO=email@example.com
|
||||
- SMTP_PASSWORD=password
|
||||
- DOMAIN=portfolio.example.com
|
||||
- API_DOMAIN=api.portfolio.example.com
|
||||
# Careful, addresses below must not end with a '/'
|
||||
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
||||
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
||||
ports:
|
||||
- 8910:8910 # Web
|
||||
- 8911:8911 # API
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- files:/home/node/app/api/files_prod
|
||||
command: >
|
||||
/bin/sh -c "
|
||||
yarn rw build &&
|
||||
yarn rw prisma migrate deploy &&
|
||||
yarn rw prisma db seed &&
|
||||
yarn rw serve"
|
||||
|
||||
> **Prerequisites**
|
||||
>
|
||||
> - Redwood requires [Node.js](https://nodejs.org/en/) (=20.x) and [Yarn](https://yarnpkg.com/)
|
||||
> - Are you on Windows? For best results, follow our [Windows development setup](https://redwoodjs.com/docs/how-to/windows-development-setup) guide
|
||||
|
||||
Start by installing dependencies:
|
||||
db:
|
||||
container_name: portfolio-db
|
||||
image: postgres:16-bookworm
|
||||
environment:
|
||||
- POSTGRES_USER=redwood
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
- POSTGRES_DB=portfolio
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
files: # For persistent file storage across upgrades
|
||||
```
|
||||
yarn install
|
||||
## Fix Files Ownership
|
||||
The `files` volume in Docker is owned by `root`, since the portfolio container runs under the `node` user, file uploads will fail. Run this command to give ownership to the `node` user:
|
||||
```
|
||||
|
||||
Then start the development server:
|
||||
|
||||
```
|
||||
yarn redwood dev
|
||||
sudo docker exec -u root portfolio chown -R node:node /home/node/app/api/files_prod
|
||||
```
|
||||
## Logging In
|
||||
- Once the container is up and running, head to `/login` (`https://portfolio.example.com/login`), default credentials are below
|
||||
- If you would like to change the password, head to `/forgot-password` (`https://portfolio.example.com/forgot-password`), the username is `admin`
|
||||
- If you correctly configured [SMTP](#smtp), you should receive an Email from [`EMAIL_FROM`](#docker-compose) to [`EMAIL_TO`](#docker-compose)
|
||||
- The Email contains the link needed to change your password
|
||||
### Default Credentials
|
||||
**Username:** `admin`
|
||||
|
||||
Your browser should automatically open to [http://localhost:8910](http://localhost:8910) where you'll see the Welcome Page, which links out to many great resources.
|
||||
|
||||
> **The Redwood CLI**
|
||||
>
|
||||
> Congratulations on running your first Redwood CLI command! From dev to deploy, the CLI is with you the whole way. And there's quite a few commands at your disposal:
|
||||
>
|
||||
> ```
|
||||
> yarn redwood --help
|
||||
> ```
|
||||
>
|
||||
> For all the details, see the [CLI reference](https://redwoodjs.com/docs/cli-commands).
|
||||
|
||||
## Prisma and the database
|
||||
|
||||
Redwood wouldn't be a full-stack framework without a database. It all starts with the schema. Open the [`schema.prisma`](api/db/schema.prisma) file in `api/db` and replace the `UserExample` model with the following `Post` model:
|
||||
|
||||
```prisma
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
body String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
Redwood uses [Prisma](https://www.prisma.io/), a next-gen Node.js and TypeScript ORM, to talk to the database. Prisma's schema offers a declarative way of defining your app's data models. And Prisma [Migrate](https://www.prisma.io/migrate) uses that schema to make database migrations hassle-free:
|
||||
|
||||
```
|
||||
yarn rw prisma migrate dev
|
||||
|
||||
# ...
|
||||
|
||||
? Enter a name for the new migration: › create posts
|
||||
```
|
||||
|
||||
> `rw` is short for `redwood`
|
||||
|
||||
You'll be prompted for the name of your migration. `create posts` will do.
|
||||
|
||||
Now let's generate everything we need to perform all the CRUD (Create, Retrieve, Update, Delete) actions on our `Post` model:
|
||||
|
||||
```
|
||||
yarn redwood generate scaffold post
|
||||
```
|
||||
|
||||
Navigate to [http://localhost:8910/posts/new](http://localhost:8910/posts/new), fill in the title and body, and click "Save".
|
||||
|
||||
Did we just create a post in the database? Yup! With `yarn rw generate scaffold <model>`, Redwood created all the pages, components, and services necessary to perform all CRUD actions on our posts table.
|
||||
|
||||
## Frontend first with Storybook
|
||||
|
||||
Don't know what your data models look like? That's more than ok—Redwood integrates Storybook so that you can work on design without worrying about data. Mockup, build, and verify your React components, even in complete isolation from the backend:
|
||||
|
||||
```
|
||||
yarn rw storybook
|
||||
```
|
||||
|
||||
Seeing "Couldn't find any stories"? That's because you need a `*.stories.{tsx,jsx}` file. The Redwood CLI makes getting one easy enough—try generating a [Cell](https://redwoodjs.com/docs/cells), Redwood's data-fetching abstraction:
|
||||
|
||||
```
|
||||
yarn rw generate cell examplePosts
|
||||
```
|
||||
|
||||
The Storybook server should hot reload and now you'll have four stories to work with. They'll probably look a little bland since there's no styling. See if the Redwood CLI's `setup ui` command has your favorite styling library:
|
||||
|
||||
```
|
||||
yarn rw setup ui --help
|
||||
```
|
||||
|
||||
## Testing with Jest
|
||||
|
||||
It'd be hard to scale from side project to startup without a few tests. Redwood fully integrates Jest with both the front- and back-ends, and makes it easy to keep your whole app covered by generating test files with all your components and services:
|
||||
|
||||
```
|
||||
yarn rw test
|
||||
```
|
||||
|
||||
To make the integration even more seamless, Redwood augments Jest with database [scenarios](https://redwoodjs.com/docs/testing#scenarios) and [GraphQL mocking](https://redwoodjs.com/docs/testing#mocking-graphql-calls).
|
||||
|
||||
## Ship it
|
||||
|
||||
Redwood is designed for both serverless deploy targets like Netlify and Vercel and serverful deploy targets like Render and AWS:
|
||||
|
||||
```
|
||||
yarn rw setup deploy --help
|
||||
```
|
||||
|
||||
Don't go live without auth! Lock down your app with Redwood's built-in, database-backed authentication system ([dbAuth](https://redwoodjs.com/docs/authentication#self-hosted-auth-installation-and-setup)), or integrate with nearly a dozen third-party auth providers:
|
||||
|
||||
```
|
||||
yarn rw setup auth --help
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
The best way to learn Redwood is by going through the comprehensive [tutorial](https://redwoodjs.com/docs/tutorial/foreword) and joining the community (via the [Discourse forum](https://community.redwoodjs.com) or the [Discord server](https://discord.gg/redwoodjs)).
|
||||
|
||||
## Quick Links
|
||||
|
||||
- Stay updated: read [Forum announcements](https://community.redwoodjs.com/c/announcements/5), follow us on [Twitter](https://twitter.com/redwoodjs), and subscribe to the [newsletter](https://redwoodjs.com/newsletter)
|
||||
- [Learn how to contribute](https://redwoodjs.com/docs/contributing)
|
||||
**Password:** [`SMTP_PASSWORD`](#docker-compose)
|
||||
|
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
|
||||
twitch
|
||||
linkedin
|
||||
matrix
|
||||
github
|
||||
gitea
|
||||
forgejo
|
||||
|
@ -5,12 +5,13 @@
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@redwoodjs/api": "8.3.0",
|
||||
"@redwoodjs/api-server": "8.3.0",
|
||||
"@redwoodjs/auth-dbauth-api": "8.3.0",
|
||||
"@redwoodjs/graphql-server": "8.3.0",
|
||||
"@redwoodjs/api": "8.4.0",
|
||||
"@redwoodjs/api-server": "8.4.0",
|
||||
"@redwoodjs/auth-dbauth-api": "8.4.0",
|
||||
"@redwoodjs/graphql-server": "8.4.0",
|
||||
"@tus/file-store": "^1.4.0",
|
||||
"@tus/server": "^1.7.0",
|
||||
"countries-list": "^3.1.1",
|
||||
"graphql-scalars": "^1.23.0",
|
||||
"nodemailer": "^6.9.14"
|
||||
},
|
||||
|
@ -111,9 +111,7 @@ ${domain}/reset-password?resetToken=${resetToken}
|
||||
// the database. Returning anything truthy will automatically log the user
|
||||
// in. Return `false` otherwise, and in the Reset Password page redirect the
|
||||
// user to the login page.
|
||||
handler: (_user) => {
|
||||
return true
|
||||
},
|
||||
handler: (_user) => false,
|
||||
|
||||
// If `false` then the new password MUST be different from the current one
|
||||
allowReusedPassword: true,
|
||||
|
@ -17,6 +17,7 @@ export const schema = gql`
|
||||
discord
|
||||
twitch
|
||||
linkedin
|
||||
matrix
|
||||
github
|
||||
gitea
|
||||
forgejo
|
||||
|
@ -8,22 +8,23 @@ interface Options {
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'gmail',
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.GMAIL,
|
||||
pass: process.env.GMAIL_SMTP_PASSWORD,
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
})
|
||||
|
||||
export const sendEmail = async ({ to, subject, text, html }: Options) => {
|
||||
return await transporter.sendMail({
|
||||
from: `"${process.env.FIRST_NAME} ${process.env.LAST_NAME} (noreply)" <${process.env.GMAIL}>`,
|
||||
export const sendEmail = async ({ to, subject, text, html }: Options) =>
|
||||
await transporter.sendMail({
|
||||
from: `${process.env.FIRST_NAME} ${process.env.LAST_NAME} <${process.env.EMAIL_FROM}>`,
|
||||
to: Array.isArray(to) ? to : [to],
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
||||
export const censorEmail = (email: string): string => {
|
||||
const [localPart, domain] = email.split('@')
|
||||
|
@ -9,7 +9,25 @@ import { createServer } from '@redwoodjs/api-server'
|
||||
|
||||
import { logger } from 'src/lib/logger'
|
||||
import { handleTusUpload } from 'src/lib/tus'
|
||||
|
||||
enum Theme {
|
||||
light = 'light',
|
||||
dark = 'dark',
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
const { countries } = await import('countries-list')
|
||||
|
||||
if (!Object.keys(countries).includes(process.env.COUNTRY))
|
||||
throw new Error(
|
||||
'Invalid COUNTRY environment variable, please select a valid ISO-3166-1 alpha-2 country code\n See https://en.wikipedia.org/wiki/ISO_3166-1#Codes'
|
||||
)
|
||||
|
||||
if (!(process.env.DEFAULT_THEME.toLowerCase() in Theme))
|
||||
throw new Error(
|
||||
'Invalid DEFAULT_THEME environment variable, please select either light or dark'
|
||||
)
|
||||
|
||||
const server = await createServer({
|
||||
logger,
|
||||
configureApiServer: async (server) => {
|
||||
|
@ -1,58 +0,0 @@
|
||||
services:
|
||||
redwood:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: base
|
||||
command: yarn rw dev
|
||||
volumes:
|
||||
- .:/home/node/app
|
||||
- node_modules:/home/node/app/node_modules
|
||||
ports:
|
||||
- '8910:8910'
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood
|
||||
- TEST_DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood_test
|
||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||
- CI=
|
||||
- NODE_ENV=development
|
||||
- REDWOOD_API_HOST=0.0.0.0
|
||||
|
||||
db:
|
||||
image: postgres:16-bookworm
|
||||
environment:
|
||||
POSTGRES_USER: redwood
|
||||
POSTGRES_PASSWORD: redwood
|
||||
POSTGRES_DB: redwood
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
# After starting with `docker compose -f ./docker-compose.dev.yml up`,
|
||||
# use the console to run commands in the container:
|
||||
#
|
||||
# ```
|
||||
# docker compose -f ./docker-compose.dev.yml run --rm -it console /bin/bash
|
||||
# root@...:/home/node/app# yarn rw prisma migrate dev
|
||||
# ```
|
||||
console:
|
||||
user: root
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: console
|
||||
tmpfs:
|
||||
- /tmp
|
||||
command: 'true'
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood
|
||||
- TEST_DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood_test
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
node_modules:
|
||||
postgres:
|
@ -1,63 +0,0 @@
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: api_serve
|
||||
# Without a command specified, the Dockerfile's api_serve CMD will be used.
|
||||
# If you are using a custom server file, you should either use the following
|
||||
# command to launch your server or update the Dockerfile to do so.
|
||||
# This is important if you intend to configure GraphQL to use Realtime.
|
||||
# command: "./api/dist/server.js"
|
||||
ports:
|
||||
- '8911:8911'
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood
|
||||
- TEST_DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood_test
|
||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: web_serve
|
||||
ports:
|
||||
- '8910:8910'
|
||||
depends_on:
|
||||
- api
|
||||
environment:
|
||||
- API_PROXY_TARGET=http://api:8911
|
||||
|
||||
db:
|
||||
image: postgres:16-bookworm
|
||||
environment:
|
||||
POSTGRES_USER: redwood
|
||||
POSTGRES_PASSWORD: redwood
|
||||
POSTGRES_DB: redwood
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
|
||||
# After starting with `docker compose -f ./docker-compose.prod.yml up`,
|
||||
# use the console to run commands in the container:
|
||||
#
|
||||
# ```
|
||||
# docker compose -f ./docker-compose.prod.yml run --rm -it console /bin/bash
|
||||
# ```
|
||||
console:
|
||||
user: root
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: console
|
||||
tmpfs:
|
||||
- /tmp
|
||||
command: 'true'
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood
|
||||
- TEST_DATABASE_URL=postgresql://redwood:redwood@db:5432/redwood_test
|
||||
depends_on:
|
||||
- db
|
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
@ -0,0 +1,58 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
portfolio:
|
||||
container_name: portfolio
|
||||
image: git.altaiar.dev/ahmed/portfolio:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- API_PROXY_TARGET=http://localhost:8911
|
||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||
- FIRST_NAME=first name # Your first name
|
||||
- LAST_NAME=lastname # Your last name
|
||||
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||
- STATE=New York # Optional, state or province
|
||||
- CITY=Manhattan # Optional
|
||||
- DEFAULT_THEME=light # 'light' or 'dark'
|
||||
- SMTP_HOST=smtp.example.com
|
||||
- SMTP_PORT=465
|
||||
- SMTP_SECURE=true
|
||||
- SMTP_USER=noreply@example.com
|
||||
- EMAIL_FROM=noreply@example.com
|
||||
- EMAIL_TO=email@example.com
|
||||
- SMTP_PASSWORD=password
|
||||
- DOMAIN=portfolio.example.com
|
||||
- API_DOMAIN=api.portfolio.example.com
|
||||
# Careful, addresses below must not end with a '/'
|
||||
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
||||
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
||||
ports:
|
||||
- 8910:8910 # Web
|
||||
- 8911:8911 # API
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- files:/home/node/app/api/files_prod
|
||||
command: >
|
||||
/bin/sh -c "
|
||||
yarn rw build &&
|
||||
yarn rw prisma migrate deploy &&
|
||||
yarn rw prisma db seed &&
|
||||
yarn rw serve"
|
||||
|
||||
|
||||
db:
|
||||
container_name: portfolio-db
|
||||
image: postgres:16-bookworm
|
||||
environment:
|
||||
- POSTGRES_USER=redwood
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
- POSTGRES_DB=portfolio
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
files: # For persistent file storage across upgrades
|
@ -7,9 +7,9 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redwoodjs/auth-dbauth-setup": "8.3.0",
|
||||
"@redwoodjs/core": "8.3.0",
|
||||
"@redwoodjs/project-config": "8.3.0",
|
||||
"@redwoodjs/auth-dbauth-setup": "8.4.0",
|
||||
"@redwoodjs/core": "8.4.0",
|
||||
"@redwoodjs/project-config": "8.4.0",
|
||||
"prettier-plugin-tailwindcss": "0.4.1"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
@ -9,7 +9,7 @@
|
||||
title = "${FIRST_NAME} ${LAST_NAME}"
|
||||
port = 8910
|
||||
apiUrl = "/api"
|
||||
includeEnvironmentVariables = ["FIRST_NAME", "LAST_NAME", "API_ADDRESS_PROD", "API_ADDRESS_DEV"]
|
||||
includeEnvironmentVariables = ["FIRST_NAME", "LAST_NAME", "COUNTRY", "STATE", "CITY", "DEFAULT_THEME", "API_ADDRESS_PROD", "API_ADDRESS_DEV"]
|
||||
[generate]
|
||||
tests = false
|
||||
stories = false
|
||||
|
@ -3,52 +3,48 @@ import { db } from 'api/src/lib/db'
|
||||
|
||||
import { hashPassword } from '@redwoodjs/auth-dbauth-api'
|
||||
|
||||
const MAX_TITLES = 5
|
||||
|
||||
export default async () => {
|
||||
try {
|
||||
const admin = {
|
||||
username: 'admin',
|
||||
email: process.env.GMAIL,
|
||||
password: process.env.GMAIL_SMTP_PASSWORD,
|
||||
email: process.env.EMAIL_TO,
|
||||
password: process.env.SMTP_PASSWORD,
|
||||
}
|
||||
|
||||
const [hashedPassword, salt] = hashPassword(admin.password)
|
||||
|
||||
await db.user.upsert({
|
||||
const existingAdmin = await db.user.findFirst({
|
||||
where: {
|
||||
email: admin.email,
|
||||
},
|
||||
create: {
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
hashedPassword,
|
||||
salt,
|
||||
},
|
||||
update: {
|
||||
username: admin.username,
|
||||
hashedPassword,
|
||||
salt,
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingAdmin)
|
||||
await db.user.create({
|
||||
data: {
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
hashedPassword,
|
||||
salt,
|
||||
},
|
||||
})
|
||||
else
|
||||
await db.user.update({
|
||||
where: { id: existingAdmin.id },
|
||||
data: {
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
},
|
||||
})
|
||||
|
||||
const titles = await db.titles.findFirst()
|
||||
|
||||
await db.titles.upsert({
|
||||
where: {
|
||||
id: 1,
|
||||
},
|
||||
create: {
|
||||
titles: Array.from({ length: MAX_TITLES }).map(
|
||||
(_, i) => `a title ${i + 1}`
|
||||
),
|
||||
},
|
||||
update: {
|
||||
titles:
|
||||
titles?.titles ||
|
||||
Array.from({ length: MAX_TITLES }).map((_, i) => `a title ${i + 1}`),
|
||||
},
|
||||
})
|
||||
if (!titles)
|
||||
await db.titles.create({
|
||||
data: {
|
||||
titles: Array.from({ length: 3 }).map((_, i) => `title ${i + 1}`),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
SiTiktokHex,
|
||||
SiYoutubeHex,
|
||||
SiLinkedinHex,
|
||||
SiMatrixHex,
|
||||
SiGithubHex,
|
||||
SiGiteaHex,
|
||||
SiLeetcodeHex,
|
||||
@ -112,6 +113,10 @@ export const theme = {
|
||||
light: SiLinkedinHex,
|
||||
dark: SiLinkedinHex,
|
||||
},
|
||||
matrix: {
|
||||
light: SiMatrixHex,
|
||||
dark: invertColor(SiMatrixHex),
|
||||
},
|
||||
github: {
|
||||
light: SiGithubHex,
|
||||
dark: invertColor(SiGithubHex),
|
||||
@ -141,7 +146,7 @@ export const theme = {
|
||||
}
|
||||
|
||||
export const darkMode = ['class', '[data-theme="dark"]']
|
||||
export const plugins = [require('daisyui')]
|
||||
export const plugins = [require('@tailwindcss/typography'), require('daisyui')]
|
||||
export const daisyui = {
|
||||
themes: [
|
||||
'light',
|
||||
|
@ -14,11 +14,18 @@
|
||||
"@icons-pack/react-simple-icons": "^10.0.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@redwoodjs/auth-dbauth-web": "8.3.0",
|
||||
"@redwoodjs/forms": "8.3.0",
|
||||
"@redwoodjs/router": "8.3.0",
|
||||
"@redwoodjs/web": "8.3.0",
|
||||
"@redwoodjs/web-server": "8.3.0",
|
||||
"@redwoodjs/auth-dbauth-web": "8.4.0",
|
||||
"@redwoodjs/forms": "8.4.0",
|
||||
"@redwoodjs/router": "8.4.0",
|
||||
"@redwoodjs/web": "8.4.0",
|
||||
"@redwoodjs/web-server": "8.4.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tiptap/extension-link": "^2.8.0",
|
||||
"@tiptap/extension-text-style": "^2.8.0",
|
||||
"@tiptap/extension-underline": "^2.8.0",
|
||||
"@tiptap/pm": "^2.8.0",
|
||||
"@tiptap/react": "^2.8.0",
|
||||
"@tiptap/starter-kit": "^2.8.0",
|
||||
"@uppy/compressor": "^2.0.1",
|
||||
"@uppy/core": "^4.1.0",
|
||||
"@uppy/dashboard": "^4.0.2",
|
||||
@ -28,17 +35,19 @@
|
||||
"@uppy/react": "^4.0.1",
|
||||
"@uppy/tus": "^4.0.0",
|
||||
"@uppy/webcam": "^4.0.1",
|
||||
"countries-list": "^3.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"humanize-string": "2.1.0",
|
||||
"react": "18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-typed": "^2.0.12"
|
||||
"react-html-parser": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redwoodjs/vite": "8.3.0",
|
||||
"@redwoodjs/vite": "8.4.0",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/react-html-parser": "^2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"daisyui": "^4.12.10",
|
||||
"postcss": "^8.4.41",
|
||||
|
@ -5,7 +5,6 @@ import { AuthProvider, useAuth } from 'src/auth'
|
||||
import FatalErrorPage from 'src/pages/FatalErrorPage'
|
||||
import Routes from 'src/Routes'
|
||||
|
||||
import 'src/scaffold.css'
|
||||
import 'src/index.css'
|
||||
|
||||
const App = () => (
|
||||
|
@ -31,11 +31,11 @@ const Routes = () => {
|
||||
<Route path="/admin/titles" page={TitleTitlesPage} name="titles" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={ScaffoldLayout} title="Resume" titleTo="adminResume">
|
||||
<Set wrap={ScaffoldLayout} title="Résumé" titleTo="adminResume">
|
||||
<Route path="/admin/resume" page={ResumeAdminResumePage} name="adminResume" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={ScaffoldLayout} title="Projects" titleTo="projects" buttonLabel="New Project" buttonTo="newProject">
|
||||
<Set wrap={ScaffoldLayout} title="Projects" titleTo="adminProjects" buttonLabel="New Project" buttonTo="newProject">
|
||||
<Route path="/admin/projects/new" page={ProjectNewProjectPage} name="newProject" />
|
||||
<Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" />
|
||||
<Route path="/admin/projects/{id:Int}" page={ProjectAdminProjectPage} name="adminProject" />
|
||||
|
@ -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
|
@ -9,48 +9,50 @@ interface ColorPickerProps {
|
||||
setColor: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
|
||||
return (
|
||||
<div className="flex flex-col space-y-2 bg-base-100 p-2 rounded-xl w-min">
|
||||
<section className="w-52">
|
||||
<HexColorPicker color={color} onChange={setColor} />
|
||||
</section>
|
||||
<div className="flex space-x-2 w-52">
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label className="input input-bordered flex items-center gap-2 input-sm w-full grow">
|
||||
<Icon path={mdiPound} className="size-4 opacity-70" />
|
||||
<HexColorInput color={color} className="w-16" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(color)
|
||||
toast.success('Copied color to clipboard')
|
||||
} catch {
|
||||
toast.error(`Failed to copy, please try again`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiContentCopy} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm "
|
||||
onClick={async () => {
|
||||
try {
|
||||
setColor(await navigator.clipboard.readText())
|
||||
} catch {
|
||||
toast.error(`Failed to paste, please try again`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiContentPaste} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
const ColorPicker = ({ color, setColor }: ColorPickerProps) => (
|
||||
<div className="flex flex-col space-y-2 bg-base-100 p-2 rounded-xl">
|
||||
<HexColorPicker color={color} onChange={setColor} />
|
||||
<div className="flex space-x-2">
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label className="input input-bordered flex items-center gap-2 input-sm grow">
|
||||
<Icon path={mdiPound} className="size-4 opacity-70" />
|
||||
<HexColorInput color={color} className="w-16" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(color)
|
||||
toast.success('Copied color to clipboard')
|
||||
} catch {
|
||||
toast.error(`Failed to copy, please try again`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiContentCopy} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText()
|
||||
const hexColorRegex =
|
||||
/^#(?:(?:[\da-f]{3}){1,2}|(?:[\da-f]{4}){1,2})$/i
|
||||
|
||||
if (!hexColorRegex.test(clipboardText))
|
||||
toast.error(`Text is not a valid hex color`)
|
||||
else setColor(clipboardText)
|
||||
} catch {
|
||||
toast.error(`Failed to paste, please try again`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiContentPaste} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ColorPicker
|
||||
|
@ -1,14 +1,17 @@
|
||||
import { useState, useRef, useLayoutEffect } from 'react'
|
||||
|
||||
import SocialLinksCell from 'src/components/Social/SocialLinksCell'
|
||||
import { ContactCardPortrait } from 'types/graphql'
|
||||
|
||||
import SocialLinks from 'src/components/Social/SocialLinks/SocialLinks'
|
||||
|
||||
interface ContactCardProps {
|
||||
portraitUrl: string
|
||||
socials: ContactCardPortrait['socials']
|
||||
}
|
||||
|
||||
const ContactCard = ({ portraitUrl }: ContactCardProps) => {
|
||||
const [width, setWidth] = useState()
|
||||
const [height, setHeight] = useState()
|
||||
const ContactCard = ({ portraitUrl, socials }: ContactCardProps) => {
|
||||
const [width, setWidth] = useState<number>(0)
|
||||
const [height, setHeight] = useState<number>(0)
|
||||
|
||||
const observedDiv = useRef(null)
|
||||
|
||||
@ -50,25 +53,19 @@ const ContactCard = ({ portraitUrl }: ContactCardProps) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-[calc(100vh-6rem)] items-center justify-center">
|
||||
<div className="card bg-base-100 shadow-xl md:card-side">
|
||||
<figure>
|
||||
<img
|
||||
className="contact-me-image aspect-portrait object-cover"
|
||||
src={portraitUrl}
|
||||
alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`}
|
||||
/>
|
||||
</figure>
|
||||
<div
|
||||
className="card-body mx-auto h-fit w-fit md:mx-0"
|
||||
ref={observedDiv}
|
||||
>
|
||||
<h2 className="card-title justify-center text-3xl md:justify-start">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="card card-compact bg-base-100 shadow-xl md:card-side">
|
||||
<img
|
||||
className="contact-me-image rounded-box aspect-portrait p-2 object-cover"
|
||||
src={portraitUrl}
|
||||
alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`}
|
||||
/>
|
||||
<div className="card-body mx-auto w-fit md:mx-0" ref={observedDiv}>
|
||||
<h2 className="card-title justify-center text-3xl pb-2 md:justify-start">
|
||||
Contact Me
|
||||
</h2>
|
||||
<p className="p-2"></p>
|
||||
<div className="card-actions">
|
||||
<SocialLinksCell />
|
||||
<div className="card-actions rounded-btn">
|
||||
<SocialLinks socials={socials} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,9 +1,14 @@
|
||||
import type { FindPortrait, FindPortraitVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
TypedDocumentNode,
|
||||
CellFailureProps,
|
||||
CellSuccessProps,
|
||||
ContactCardPortrait,
|
||||
ContactCardPortraitVariables,
|
||||
} from 'types/graphql'
|
||||
|
||||
import { routes } from '@redwoodjs/router'
|
||||
import {
|
||||
type TypedDocumentNode,
|
||||
type CellFailureProps,
|
||||
type CellSuccessProps,
|
||||
Metadata,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||
@ -11,25 +16,49 @@ import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard'
|
||||
|
||||
export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
|
||||
gql`
|
||||
query ContactCardPortrait {
|
||||
portrait: portrait {
|
||||
fileId
|
||||
}
|
||||
export const QUERY: TypedDocumentNode<
|
||||
ContactCardPortrait,
|
||||
ContactCardPortraitVariables
|
||||
> = gql`
|
||||
query ContactCardPortrait {
|
||||
portrait {
|
||||
fileId
|
||||
}
|
||||
`
|
||||
socials {
|
||||
id
|
||||
name
|
||||
type
|
||||
username
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
|
||||
export const Empty = () => <CellEmpty />
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps<FindPortrait>) => (
|
||||
export const Failure = ({ error }: CellFailureProps<ContactCardPortrait>) => (
|
||||
<CellFailure error={error} />
|
||||
)
|
||||
|
||||
export const Success = ({
|
||||
portrait,
|
||||
}: CellSuccessProps<FindPortrait, FindPortraitVariables>) => (
|
||||
<ContactCard portraitUrl={portrait.fileId} />
|
||||
socials,
|
||||
}: CellSuccessProps<ContactCardPortrait, ContactCardPortraitVariables>) => (
|
||||
<>
|
||||
<Metadata
|
||||
title="Contact"
|
||||
og={{
|
||||
title: 'Contact',
|
||||
type: 'website',
|
||||
image: {
|
||||
url: portrait.fileId,
|
||||
type: 'image/webp',
|
||||
alt: `${process.env.FIRST_NAME} ${process.env.LAST_NAME}`,
|
||||
},
|
||||
url: routes.contact(),
|
||||
}}
|
||||
/>
|
||||
<ContactCard portraitUrl={portrait.fileId} socials={socials} />
|
||||
</>
|
||||
)
|
||||
|
37
web/src/components/PDF/PDF.tsx
Normal file
37
web/src/components/PDF/PDF.tsx
Normal 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
|
@ -102,7 +102,7 @@ const PortraitForm = ({ portrait }: PortraitFormProps) => {
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
<img
|
||||
className="aspect-portrait max-w-2xl rounded-xl object-cover"
|
||||
className="aspect-portrait max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl rounded-xl object-cover"
|
||||
src={portrait?.fileId}
|
||||
alt={`${process.env.FIRST_NAME} Portrait`}
|
||||
/>
|
||||
@ -127,14 +127,13 @@ const PortraitForm = ({ portrait }: PortraitFormProps) => {
|
||||
)
|
||||
else
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
<div className="mx-auto max-w-prose space-y-2">
|
||||
{!fileId ? (
|
||||
<>
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="22rem"
|
||||
height="34.5rem"
|
||||
className="flex justify-center"
|
||||
width="auto"
|
||||
height="30rem"
|
||||
/>
|
||||
<p className="text-center">
|
||||
High quality, 4:5 aspect ratio image recommended
|
||||
|
@ -1,3 +1,4 @@
|
||||
import parseHtml from 'react-html-parser'
|
||||
import type {
|
||||
DeleteProjectMutation,
|
||||
DeleteProjectMutationVariables,
|
||||
@ -45,37 +46,40 @@ const AdminProject = ({ project }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="flex justify-center">
|
||||
<div>
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th className="w-0">
|
||||
<th colSpan={2}>
|
||||
Project {project.id}: {project.title}
|
||||
</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th className="text-right">ID</th>
|
||||
<td>{project.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th className="text-right">Title</th>
|
||||
<td>{project.title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<td>{project.description}</td>
|
||||
<th className="text-right">Description</th>
|
||||
<td>
|
||||
<article className="prose">
|
||||
{parseHtml(project.description)}
|
||||
</article>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th className="text-right">Date</th>
|
||||
<td>{timeTag(project.date)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Images</th>
|
||||
<th className="text-right">Images</th>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.images.map((image, i) => (
|
||||
@ -83,7 +87,7 @@ const AdminProject = ({ project }: Props) => {
|
||||
key={i}
|
||||
href={image}
|
||||
target="_blank"
|
||||
className="btn btn-sm btn-square"
|
||||
className={`btn btn-sm btn-square ${i === 0 && 'btn-primary'}`}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{i + 1}
|
||||
@ -93,7 +97,7 @@ const AdminProject = ({ project }: Props) => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Tags</th>
|
||||
<th className="text-right">Tags</th>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag, i) => (
|
||||
@ -115,19 +119,30 @@ const AdminProject = ({ project }: Props) => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Links</th>
|
||||
<th className="text-right">Links</th>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.links.map((link, i) => (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="badge badge-ghost text-nowrap"
|
||||
key={i}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
<>
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="hidden sm:flex badge badge-ghost text-nowrap"
|
||||
key={i}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="btn btn-sm btn-square sm:hidden"
|
||||
key={i}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{i + 1}
|
||||
</a>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
|
@ -73,12 +73,12 @@ export const Success = ({ project }: CellSuccessProps<EditProjectById>) => {
|
||||
) => updateProject({ variables: { id, input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<div className="flex mx-auto max-w-prose justify-center">
|
||||
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th className="w-0">Edit Project {project.id}</th>
|
||||
<th>Edit Project {project.id}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -38,9 +38,9 @@ const NewProject = () => {
|
||||
createProject({ variables: { input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<div className="flex mx-auto max-w-prose justify-center">
|
||||
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th>New Project</th>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { mdiLinkVariant } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import { format, isAfter, startOfToday } from 'date-fns'
|
||||
import parseHtml from 'react-html-parser'
|
||||
import type { FindProjectById } from 'types/graphql'
|
||||
|
||||
import { calculateLuminance } from 'src/lib/color'
|
||||
@ -41,7 +42,9 @@ const Project = ({ project }: Props) => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{project.description && <p>{project.description}</p>}
|
||||
{project.description && (
|
||||
<article className="prose">{parseHtml(project.description)}</article>
|
||||
)}
|
||||
{project.links.length > 0 && (
|
||||
<>
|
||||
<h2 className="font-bold text-3xl w-fit">Links</h2>
|
||||
@ -66,20 +69,23 @@ const Project = ({ project }: Props) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
|
||||
{project.images.length > 0 && (
|
||||
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 items-center pt-8 justify-center">
|
||||
{project.images.map((image, i) => (
|
||||
<a
|
||||
href={image}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
key={i}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<img src={image} alt="" className="rounded-xl" />
|
||||
</a>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-4 pt-8 justify-center h-fit sm:p-8">
|
||||
{project.images.length > 0 &&
|
||||
project.images.map((image, i) => (
|
||||
<a
|
||||
href={image}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
key={i}
|
||||
className="rounded-box"
|
||||
>
|
||||
<img src={image} alt="" className="rounded-xl" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -1,9 +1,11 @@
|
||||
import type { FindProjectById, FindProjectByIdVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
CellSuccessProps,
|
||||
CellFailureProps,
|
||||
TypedDocumentNode,
|
||||
import { routes } from '@redwoodjs/router'
|
||||
import {
|
||||
type CellSuccessProps,
|
||||
type CellFailureProps,
|
||||
type TypedDocumentNode,
|
||||
Metadata,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||
@ -40,5 +42,23 @@ export const Failure = ({
|
||||
export const Success = ({
|
||||
project,
|
||||
}: 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} />
|
||||
</>
|
||||
)
|
||||
|
@ -2,6 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { mdiCalendar, mdiDelete, mdiFormatTitle, mdiLinkVariant } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import { useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Meta, UploadResult } from '@uppy/core'
|
||||
import { format, isAfter, startOfToday } from 'date-fns'
|
||||
import type {
|
||||
@ -11,18 +15,12 @@ import type {
|
||||
} from 'types/graphql'
|
||||
|
||||
import type { RWGqlError } from '@redwoodjs/forms'
|
||||
import {
|
||||
Form,
|
||||
FieldError,
|
||||
Label,
|
||||
TextField,
|
||||
Submit,
|
||||
TextAreaField,
|
||||
} from '@redwoodjs/forms'
|
||||
import { Form, FieldError, Label, TextField, Submit } from '@redwoodjs/forms'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import DatePicker from 'src/components/DatePicker'
|
||||
import FormTextList from 'src/components/FormTextList'
|
||||
import RichTextEditor from 'src/components/RichTextEditor/RichTextEditor'
|
||||
import TagsSelectorCell from 'src/components/Tag/TagsSelectorCell'
|
||||
import Uploader from 'src/components/Uploader'
|
||||
import { batchDelete } from 'src/lib/tus'
|
||||
@ -39,6 +37,20 @@ interface ProjectFormProps {
|
||||
const ProjectForm = (props: ProjectFormProps) => {
|
||||
const today = startOfToday()
|
||||
|
||||
const descEditor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
defaultProtocol: 'https',
|
||||
}),
|
||||
],
|
||||
content: props.project?.description || '',
|
||||
})
|
||||
|
||||
const [links, setLinks] = useState<string[]>(props.project?.links || [])
|
||||
const [linkErrors, setLinkErrors] = useState<boolean[]>([])
|
||||
const [pickerVisible, setPickerVisible] = useState<boolean>(false)
|
||||
@ -87,7 +99,7 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
props.onSave(
|
||||
{
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
description: descEditor.getHTML(),
|
||||
date: date.toISOString(),
|
||||
links: links.filter((link) => link.trim().length > 0),
|
||||
images: fileIds,
|
||||
@ -107,7 +119,7 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
<Form<FormProject>
|
||||
onSubmit={onSubmit}
|
||||
error={props.error}
|
||||
className="space-y-2 w-80"
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label name="title" className="form-control w-full">
|
||||
<Label
|
||||
@ -144,21 +156,7 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label name="description" className="form-control w-full">
|
||||
<TextAreaField
|
||||
name="description"
|
||||
defaultValue={props.project?.description}
|
||||
className="textarea textarea-bordered"
|
||||
errorClassName="textarea textarea-bordered textarea-error"
|
||||
placeholder="Description"
|
||||
/>
|
||||
<div className="label">
|
||||
<FieldError
|
||||
name="description"
|
||||
className="text-xs font-semibold text-error"
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<RichTextEditor editor={descEditor} />
|
||||
|
||||
<div className="form-control w-full">
|
||||
<Label
|
||||
@ -244,7 +242,14 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
<img src={fileId} alt={i.toString()} />
|
||||
</figure>
|
||||
<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
|
||||
type="button"
|
||||
className="btn btn-square btn-sm btn-error shadow-xl"
|
||||
@ -263,9 +268,8 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
{appendUploader && (
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="20rem"
|
||||
width="auto"
|
||||
height="30rem"
|
||||
className="flex justify-center"
|
||||
maxFiles={10}
|
||||
disabled={props.loading}
|
||||
/>
|
||||
@ -274,9 +278,8 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
) : (
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="20rem"
|
||||
width="auto"
|
||||
height="30rem"
|
||||
className="flex justify-center pt-3"
|
||||
maxFiles={10}
|
||||
disabled={props.loading}
|
||||
/>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { mdiDotsVertical } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import { isAfter } from 'date-fns'
|
||||
import parseHtml from 'react-html-parser'
|
||||
import type {
|
||||
DeleteProjectMutation,
|
||||
DeleteProjectMutationVariables,
|
||||
@ -94,7 +95,11 @@ const ProjectsList = ({ projects }: FindProjects) => {
|
||||
return (
|
||||
<tr key={project.id}>
|
||||
<td>{truncate(project.title)}</td>
|
||||
<td className="max-w-72">{truncate(project.description)}</td>
|
||||
<td className="max-w-72">
|
||||
<article className="prose text-sm line-clamp-3">
|
||||
{parseHtml(project.description)}
|
||||
</article>
|
||||
</td>
|
||||
<td className="max-w-36">{timeTag(project.date)}</td>
|
||||
<td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td>
|
||||
<td>
|
||||
|
@ -1,108 +0,0 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { format, isAfter, startOfToday } from 'date-fns'
|
||||
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">{project.description}</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
|
||||
}
|
@ -1,16 +1,17 @@
|
||||
import type { FindProjects, FindProjectsVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
CellSuccessProps,
|
||||
CellFailureProps,
|
||||
TypedDocumentNode,
|
||||
import { routes } from '@redwoodjs/router'
|
||||
import {
|
||||
type CellSuccessProps,
|
||||
type CellFailureProps,
|
||||
type TypedDocumentNode,
|
||||
Metadata,
|
||||
} 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 ProjectsShowcase from '../ProjectsShowcase/ProjectsShowcase'
|
||||
import ProjectsShowcaseList from 'src/components/ProjectsShowcaseList/ProjectsShowcaseList'
|
||||
|
||||
export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> =
|
||||
gql`
|
||||
@ -40,5 +41,16 @@ export const Failure = ({ error }: CellFailureProps<FindProjectsVariables>) => (
|
||||
export const Success = ({
|
||||
projects,
|
||||
}: CellSuccessProps<FindProjects, FindProjectsVariables>) => (
|
||||
<ProjectsShowcase projects={projects} />
|
||||
<>
|
||||
<Metadata
|
||||
title="Projects"
|
||||
og={{
|
||||
title: 'Projects',
|
||||
type: 'website',
|
||||
description: `${projects.length} projects`,
|
||||
url: routes.projects(),
|
||||
}}
|
||||
/>
|
||||
<ProjectsShowcaseList projects={projects} />
|
||||
</>
|
||||
)
|
||||
|
@ -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
|
@ -1,26 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Resume as ResumeType } from 'types/graphql'
|
||||
|
||||
import PDF from 'src/components/PDF/PDF'
|
||||
|
||||
interface ResumeProps {
|
||||
resume?: ResumeType
|
||||
}
|
||||
|
||||
const Resume = ({ resume }: ResumeProps) => {
|
||||
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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
const Resume = ({ resume }: ResumeProps) => <PDF url={resume?.fileId} />
|
||||
|
||||
export default Resume
|
||||
|
@ -1,9 +1,11 @@
|
||||
import type { FindResume, FindResumeVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
CellSuccessProps,
|
||||
CellFailureProps,
|
||||
TypedDocumentNode,
|
||||
import { routes } from '@redwoodjs/router'
|
||||
import {
|
||||
type CellSuccessProps,
|
||||
type CellFailureProps,
|
||||
type TypedDocumentNode,
|
||||
Metadata,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||
@ -29,5 +31,15 @@ export const Failure = ({ error }: CellFailureProps<FindResumeVariables>) => (
|
||||
export const Success = ({
|
||||
resume,
|
||||
}: CellSuccessProps<FindResume, FindResumeVariables>) => (
|
||||
<Resume resume={resume} />
|
||||
<>
|
||||
<Metadata
|
||||
title="Contact"
|
||||
og={{
|
||||
title: 'Resume',
|
||||
type: 'website',
|
||||
url: routes.resume(),
|
||||
}}
|
||||
/>
|
||||
<Resume resume={resume} />
|
||||
</>
|
||||
)
|
||||
|
@ -14,6 +14,7 @@ import type {
|
||||
import { TypedDocumentNode, useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import PDF from 'src/components/PDF/PDF'
|
||||
import Uploader from 'src/components/Uploader/Uploader'
|
||||
import { deleteFile, handleBeforeUnload } from 'src/lib/tus'
|
||||
|
||||
@ -100,16 +101,7 @@ const ResumeForm = ({ resume }: ResumeFormProps) => {
|
||||
if (resume?.fileId)
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
<object
|
||||
data={resume?.fileId}
|
||||
type="application/pdf"
|
||||
aria-label="Resume PDF"
|
||||
style={{
|
||||
width: 'calc(100vw - 1rem)',
|
||||
height: 'calc(100vh - 10rem)',
|
||||
}}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
<PDF form url={resume?.fileId} />
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
@ -131,28 +123,18 @@ const ResumeForm = ({ resume }: ResumeFormProps) => {
|
||||
)
|
||||
else
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
<div className="mx-auto max-w-prose space-y-2">
|
||||
{!fileId ? (
|
||||
<>
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="22rem"
|
||||
height="11.5rem"
|
||||
className="flex justify-center"
|
||||
width="auto"
|
||||
height="30rem"
|
||||
type="pdf"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<object
|
||||
data={fileId}
|
||||
type="application/pdf"
|
||||
aria-label="Resume PDF"
|
||||
style={{
|
||||
width: 'calc(100vw - 1rem)',
|
||||
height: 'calc(100vh - 10rem)',
|
||||
}}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
<PDF form url={fileId} />
|
||||
)}
|
||||
{fileId && (
|
||||
<div className="flex justify-center space-x-2">
|
||||
|
167
web/src/components/RichTextEditor/RichTextEditor.tsx
Normal file
167
web/src/components/RichTextEditor/RichTextEditor.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import {
|
||||
mdiCodeBracesBox,
|
||||
mdiFormatBold,
|
||||
mdiFormatClear,
|
||||
mdiFormatItalic,
|
||||
mdiFormatListBulleted,
|
||||
mdiFormatListNumbered,
|
||||
mdiFormatQuoteClose,
|
||||
mdiFormatStrikethrough,
|
||||
mdiFormatUnderline,
|
||||
mdiLinkVariant,
|
||||
mdiLinkVariantOff,
|
||||
mdiRedoVariant,
|
||||
mdiUndoVariant,
|
||||
mdiXml,
|
||||
} from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import { EditorContent } from '@tiptap/react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
|
||||
interface RichTextEditorProps {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
const RichTextEditor = ({ editor }: RichTextEditorProps) => {
|
||||
const setLink = useCallback(() => {
|
||||
const previousUrl = editor.getAttributes('link').href
|
||||
const url = window.prompt('URL', previousUrl)
|
||||
|
||||
if (url === null) return
|
||||
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
return
|
||||
}
|
||||
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||
}, [editor])
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-center flex-wrap gap-2">
|
||||
<div className="flex gap-2 h-min justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('link') ? 'btn-primary' : ''}`}
|
||||
onClick={setLink}
|
||||
>
|
||||
<Icon path={mdiLinkVariant} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-square"
|
||||
onClick={() => editor.chain().focus().unsetLink().run()}
|
||||
disabled={!editor.isActive('link')}
|
||||
>
|
||||
<Icon path={mdiLinkVariantOff} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('bulletList') ? 'btn-primary' : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<Icon path={mdiFormatListBulleted} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('orderedList') ? 'btn-primary' : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<Icon path={mdiFormatListNumbered} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-square"
|
||||
onClick={() => editor.chain().focus().unsetAllMarks().run()}
|
||||
>
|
||||
<Icon path={mdiFormatClear} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-square"
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().chain().focus().undo().run()}
|
||||
>
|
||||
<Icon path={mdiUndoVariant} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-square"
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().chain().focus().redo().run()}
|
||||
>
|
||||
<Icon path={mdiRedoVariant} className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 h-min justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('bold') ? 'btn-primary' : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||
>
|
||||
<Icon path={mdiFormatBold} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('italic') ? 'btn-primary' : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<Icon path={mdiFormatItalic} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('underline') ? 'btn-primary' : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
disabled={!editor.can().chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<Icon path={mdiFormatUnderline} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('static') ? 'btn-primary' : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
disabled={!editor.can().chain().focus().toggleStrike().run()}
|
||||
>
|
||||
<Icon path={mdiFormatStrikethrough} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('code') ? 'btn-primary' : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
disabled={!editor.can().chain().focus().toggleCode().run()}
|
||||
>
|
||||
<Icon path={mdiXml} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('codeBlock') ? 'btn-primary' : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
disabled={!editor.can().chain().focus().toggleCodeBlock().run()}
|
||||
>
|
||||
<Icon path={mdiCodeBracesBox} className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('blockquote') ? 'btn-primary' : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
<Icon path={mdiFormatQuoteClose} className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="textarea textarea-bordered font-normal prose max-w-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RichTextEditor
|
@ -71,9 +71,9 @@ export const Success = ({ social }: CellSuccessProps<EditSocialById>) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<div className="flex mx-auto max-w-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 w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th className="w-0">Edit Social {social.id}</th>
|
||||
|
@ -38,9 +38,9 @@ const NewSocial = () => {
|
||||
createSocial({ variables: { input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<div className="flex mx-auto max-w-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 w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th>New Social</th>
|
||||
|
@ -44,31 +44,32 @@ const Social = ({ social }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="w-80">
|
||||
<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-full">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<th className="w-0">
|
||||
Social {social.id}: {social.name}
|
||||
</th>
|
||||
<th> </th>
|
||||
<tr>
|
||||
<th colSpan={2}>
|
||||
Social {social.id}: {social.name}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th className="text-right">ID</th>
|
||||
<td>{social.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th className="text-right">Type</th>
|
||||
<td>{getLogoComponent(social.type)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th className="text-right">Name</th>
|
||||
<td>{social.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th className="text-right">Username</th>
|
||||
<td>{social.username}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -33,6 +33,6 @@ export const Failure = ({
|
||||
|
||||
export const Success = ({
|
||||
social,
|
||||
}: CellSuccessProps<FindSocialById, FindSocialByIdVariables>) => {
|
||||
return <Social social={social} />
|
||||
}
|
||||
}: CellSuccessProps<FindSocialById, FindSocialByIdVariables>) => (
|
||||
<Social social={social} />
|
||||
)
|
||||
|
@ -38,6 +38,7 @@ const types: FormSocial['type'][] = [
|
||||
'discord',
|
||||
'twitch',
|
||||
'linkedin',
|
||||
'matrix',
|
||||
'github',
|
||||
'gitea',
|
||||
'forgejo',
|
||||
@ -52,6 +53,15 @@ const types: FormSocial['type'][] = [
|
||||
const urlHandles: FormSocial['type'][] = ['custom', 'gitea', 'forgejo']
|
||||
|
||||
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']>(
|
||||
props.social?.type ?? 'x'
|
||||
)
|
||||
@ -89,7 +99,7 @@ const SocialForm = (props: SocialFormProps) => {
|
||||
<Form<FormSocial>
|
||||
onSubmit={onSubmit}
|
||||
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
|
||||
@ -177,17 +187,20 @@ const SocialForm = (props: SocialFormProps) => {
|
||||
pattern: {
|
||||
value:
|
||||
type == 'email'
|
||||
? /^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/
|
||||
? emailRegex
|
||||
: type == 'phone'
|
||||
? /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/
|
||||
: 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,
|
||||
? phoneRegex
|
||||
: urlHandles.includes(type)
|
||||
? urlRegex
|
||||
: type == 'matrix' && matrixRegex,
|
||||
message: `Invalid ${
|
||||
urlHandles.includes(type)
|
||||
? 'URL'
|
||||
: type == 'phone'
|
||||
? 'Phone Number'
|
||||
: type == 'email' && 'Email'
|
||||
? 'phone number'
|
||||
: type == 'email'
|
||||
? 'Email'
|
||||
: type == 'matrix' && 'Matrix identifier'
|
||||
}`,
|
||||
},
|
||||
}}
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { FindSocials } from 'types/graphql'
|
||||
import { ContactCardPortrait } from 'types/graphql'
|
||||
|
||||
import { baseUrls, getLogoComponent, sortOrder } from 'src/lib/handle'
|
||||
|
||||
const SocialLinks = ({ socials }: FindSocials) => (
|
||||
<div className={`grid max-w-68 grid-cols-[repeat(auto-fit,3rem)] gap-2`}>
|
||||
const SocialLinks = ({ socials }: ContactCardPortrait) => (
|
||||
<div
|
||||
className={`grid max-w-68 grid-cols-[repeat(auto-fit,3rem)] gap-2 rounded-btn`}
|
||||
>
|
||||
{[...socials]
|
||||
.sort((a, b) => sortOrder.indexOf(a.type) - sortOrder.indexOf(b.type))
|
||||
.map((social, i) => (
|
||||
<div key={i} className="tooltip" data-tip={social.name}>
|
||||
<div key={i} className="tooltip rounded-btn" data-tip={social.name}>
|
||||
<a
|
||||
className="btn btn-square"
|
||||
href={`${baseUrls[social.type]}${social.username}`}
|
||||
|
@ -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} />
|
||||
)
|
@ -59,8 +59,8 @@ export const Success = ({ tag }: CellSuccessProps<EditTagById>) => {
|
||||
updateTag({ variables: { id, input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<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 w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
|
@ -34,8 +34,8 @@ const NewTag = () => {
|
||||
const onSave = (input: CreateTagInput) => createTag({ variables: { input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<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 w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
|
@ -42,8 +42,8 @@ const Tag = ({ tag }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="w-80">
|
||||
<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-full">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
@ -54,15 +54,15 @@ const Tag = ({ tag }: Props) => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th className="text-right">ID</th>
|
||||
<td>{tag.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th className="text-right">Tag</th>
|
||||
<td>{tag.tag}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Color</th>
|
||||
<th className="text-right">Color</th>
|
||||
<td>
|
||||
<div
|
||||
className="badge whitespace-nowrap"
|
||||
|
@ -35,7 +35,7 @@ const TagForm = (props: TagFormProps) => {
|
||||
<Form<FormTag>
|
||||
onSubmit={onSubmit}
|
||||
error={props.error}
|
||||
className="max-w-56 space-y-2"
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label name="tag" className="form-control w-full">
|
||||
<Label
|
||||
|
@ -50,7 +50,7 @@ const TagsList = ({ tags }: FindTags) => {
|
||||
<th className="w-0"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="overflow-y-scroll">
|
||||
{tags.map((tag, i) => {
|
||||
const actionButtons = (
|
||||
<>
|
||||
|
@ -26,7 +26,7 @@ const TagsSelector = ({
|
||||
}, [selectedTags, _tags])
|
||||
|
||||
return (
|
||||
<div className="w-80 space-y-2">
|
||||
<div className="space-y-2">
|
||||
{tags.length > 0 && (
|
||||
<>
|
||||
<p className="font-semibold">Tags</p>
|
||||
|
@ -8,7 +8,10 @@ const DARK_THEME = 'dark'
|
||||
|
||||
const ThemeToggle = () => {
|
||||
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>) => {
|
||||
|
@ -29,22 +29,24 @@ export const Failure = ({
|
||||
}: CellFailureProps<AdminTitlesQueryVariables>) => <CellFailure error={error} />
|
||||
|
||||
export const Success = ({ titles }: CellSuccessProps<AdminTitlesQuery>) => (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th className="w-0">Titles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>
|
||||
<TitlesForm titles={titles} />
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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-full">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th className="w-0">Titles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>
|
||||
<TitlesForm titles={titles} />
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -1,38 +1,28 @@
|
||||
import { ReactTyped } from 'react-typed'
|
||||
|
||||
interface TitlesProps {
|
||||
titles: string[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Titles = ({ titles, className }: TitlesProps) => {
|
||||
export const Titles = ({ titles }: TitlesProps) => {
|
||||
const titlesFiltered = titles.filter((title) => title !== '')
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-3xl sm:text-5xl font-bold">
|
||||
Hey 👋, I'm {`${process.env.FIRST_NAME}`}
|
||||
{titlesFiltered.length > 0 && (
|
||||
<>
|
||||
, <br />
|
||||
<ReactTyped
|
||||
className={className}
|
||||
strings={titlesFiltered}
|
||||
typeSpeed={50}
|
||||
backSpeed={40}
|
||||
backDelay={1000}
|
||||
startWhenVisible
|
||||
loop
|
||||
onStringTyped={(pos, self) => {
|
||||
if (pos === 0) {
|
||||
self.stop()
|
||||
setTimeout(() => self.start(), 2500)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
{titlesFiltered.length >= 3 && (
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-0 justify-center items-center">
|
||||
<span className="badge md:badge-lg badge-ghost">
|
||||
{titlesFiltered[0]}
|
||||
</span>
|
||||
<div className="hidden sm:divider sm:divider-horizontal" />
|
||||
<span className="badge md:badge-lg badge-ghost">
|
||||
{titlesFiltered[1]}
|
||||
</span>
|
||||
<div className="hidden sm:divider sm:divider-horizontal" />
|
||||
<span className="badge md:badge-lg badge-ghost">
|
||||
{titlesFiltered[2]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -8,8 +8,6 @@ import { Form, Label, Submit, TextField } from '@redwoodjs/forms'
|
||||
import { TypedDocumentNode, useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
const MAX_TITLES = 5
|
||||
|
||||
interface TitlesFormProps {
|
||||
titles?: Titles
|
||||
}
|
||||
@ -25,14 +23,10 @@ const UPDATE_TITLES_MUTATION: TypedDocumentNode<UpdateTitlesInput> = gql`
|
||||
const TitlesForm = ({ titles }: TitlesFormProps) => {
|
||||
const title1ref = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [preview, setPreview] = useState<boolean>(false)
|
||||
|
||||
const states = [
|
||||
useState<string>(titles?.titles[0]),
|
||||
useState<string>(titles?.titles[1]),
|
||||
useState<string>(titles?.titles[2]),
|
||||
useState<string>(titles?.titles[3]),
|
||||
useState<string>(titles?.titles[4]),
|
||||
]
|
||||
|
||||
useEffect(() => title1ref.current?.focus(), [])
|
||||
@ -55,11 +49,8 @@ const TitlesForm = ({ titles }: TitlesFormProps) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<Form onSubmit={onSubmit} className="max-w-80 space-y-2">
|
||||
<p className="text-center opacity-70">
|
||||
The first one gets displayed for longer
|
||||
</p>
|
||||
{Array.from({ length: MAX_TITLES }).map((_, i) => (
|
||||
<Form onSubmit={onSubmit} className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Label key={i} name={`title${i}`} className="form-control w-full">
|
||||
<Label
|
||||
name={`title${i}`}
|
||||
@ -83,25 +74,24 @@ const TitlesForm = ({ titles }: TitlesFormProps) => {
|
||||
className="w-full"
|
||||
/>
|
||||
</Label>
|
||||
{preview && (
|
||||
<div className="label">
|
||||
<p>
|
||||
Hey 👋, I'm {`${process.env.FIRST_NAME}`},{' '}
|
||||
<span className="text-primary">{states[i][0]}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setPreview(!preview)}
|
||||
>
|
||||
{preview ? 'Hide' : 'Show'} Preview
|
||||
</button>
|
||||
<Submit
|
||||
disabled={updateLoading}
|
||||
className="btn btn-primary btn-sm uppercase"
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { countries } from 'countries-list'
|
||||
import { hydrateRoot, createRoot } from 'react-dom/client'
|
||||
|
||||
import App from 'src/App'
|
||||
@ -7,6 +8,11 @@ import App from 'src/App'
|
||||
* rather than replacing it.
|
||||
* https://react.dev/reference/react-dom/client/hydrateRoot
|
||||
*/
|
||||
enum Theme {
|
||||
light = 'light',
|
||||
dark = 'dark',
|
||||
}
|
||||
|
||||
const redwoodAppElement = document.getElementById('redwood-app')
|
||||
|
||||
if (!redwoodAppElement)
|
||||
@ -15,6 +21,16 @@ if (!redwoodAppElement)
|
||||
"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)
|
||||
hydrateRoot(redwoodAppElement, <App />)
|
||||
else {
|
||||
|
@ -19,3 +19,11 @@
|
||||
.image-full-no-overlay::before {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.w-full .react-colorful {
|
||||
width: auto;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { mdiMenu, mdiLogout, mdiCog } from '@mdi/js'
|
||||
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 ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
|
||||
@ -17,6 +17,9 @@ type NavbarLayoutProps = {
|
||||
}
|
||||
|
||||
const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
const routePaths = useRoutePaths()
|
||||
const routePath = useRoutePath()
|
||||
|
||||
const { isAuthenticated, logOut } = useAuth()
|
||||
|
||||
const navbarRoutes: NavbarRoute[] = [
|
||||
@ -25,7 +28,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
path: routes.projects(),
|
||||
},
|
||||
{
|
||||
name: 'Resume',
|
||||
name: 'Résumé',
|
||||
path: routes.resume(),
|
||||
},
|
||||
{
|
||||
@ -56,7 +59,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
path: routes.titles(),
|
||||
},
|
||||
{
|
||||
name: 'Resume',
|
||||
name: 'Résumé',
|
||||
path: routes.adminResume(),
|
||||
},
|
||||
]
|
||||
@ -80,12 +83,14 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
<ToasterWrapper />
|
||||
<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-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
|
||||
tabIndex={0}
|
||||
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" />
|
||||
</div>
|
||||
@ -94,12 +99,12 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
tabIndex={0}
|
||||
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">
|
||||
Public
|
||||
</p>
|
||||
)}
|
||||
{navbarButtons()}
|
||||
{routePath !== routePaths.home && navbarButtons()}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<p className="btn btn-active no-animation btn-sm btn-block">
|
||||
@ -129,7 +134,9 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
</Link>
|
||||
</div>
|
||||
<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 className="navbar-end space-x-2">
|
||||
{isAuthenticated && (
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
SiTiktok,
|
||||
SiYoutube,
|
||||
SiLinkedin,
|
||||
SiMatrix,
|
||||
SiGithub,
|
||||
SiGitea,
|
||||
SiLeetcode,
|
||||
@ -33,6 +34,7 @@ export const baseUrls: Record<Handle, string> = {
|
||||
discord: 'https://discord.gg/',
|
||||
twitch: 'https://www.twitch.tv/',
|
||||
linkedin: 'https://www.linkedin.com/in/',
|
||||
matrix: 'https://matrix.to/#/',
|
||||
github: 'https://github.com/',
|
||||
gitea: '',
|
||||
forgejo: '',
|
||||
@ -61,6 +63,7 @@ const logoComponents: Record<Handle, ReactElement> = {
|
||||
linkedin: (
|
||||
<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" />,
|
||||
gitea: <SiGitea className="text-gitea-light dark:text-gitea-dark" />,
|
||||
forgejo: <SiForgejo className="text-forgejo-light dark:text-forgejo-dark" />,
|
||||
@ -80,6 +83,7 @@ export const sortOrder: Social['type'][] = [
|
||||
'phone',
|
||||
'email',
|
||||
'custom',
|
||||
'matrix',
|
||||
'linkedin',
|
||||
'leetcode',
|
||||
'github',
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ContactCardCell from 'src/components/ContactCard/ContactCardCell'
|
||||
|
||||
const ContactPage = () => {
|
||||
@ -29,13 +27,9 @@ const ContactPage = () => {
|
||||
}, [width, height])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Contact" />
|
||||
|
||||
<div className="flex min-h-[calc(100vh-6rem)] items-center justify-center">
|
||||
<ContactCardCell />
|
||||
</div>
|
||||
</>
|
||||
<div className="flex min-h-[calc(100dvh-6rem)] items-center justify-center">
|
||||
<ContactCardCell />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -13,45 +13,6 @@ import { DevFatalErrorPage } from '@redwoodjs/web/dist/components/DevFatalErrorP
|
||||
export default DevFatalErrorPage ||
|
||||
(() => (
|
||||
<main>
|
||||
<style
|
||||
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>
|
||||
<span>Something went wrong</span>
|
||||
</main>
|
||||
))
|
||||
|
@ -72,7 +72,9 @@ const ForgotPasswordPage = () => {
|
||||
<FieldError name="username" className="text-sm text-error" />
|
||||
|
||||
<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>
|
||||
</Form>
|
||||
</div>
|
||||
|
@ -1,31 +1,62 @@
|
||||
import { mdiCompass, mdiContacts } from '@mdi/js'
|
||||
import { mdiCodeBraces, mdiContacts, mdiFileDocument } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import type { TCountryCode } from 'countries-list'
|
||||
import { getCountryData } from 'countries-list'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import TitlesCell from 'src/components/Title/TitlesCell'
|
||||
import { getLogoComponent } from 'src/lib/handle'
|
||||
|
||||
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="text-center">
|
||||
<TitlesCell className="text-primary" />
|
||||
<div className="flex flex-col text-center gap-8">
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold">
|
||||
Hey 👋, I'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 className="flex gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Link
|
||||
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" />
|
||||
Explore
|
||||
<Icon path={mdiCodeBraces} className="size-6" />
|
||||
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
|
||||
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" />
|
||||
Contact
|
||||
@ -33,6 +64,16 @@ const HomePage = () => (
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
|
||||
|
@ -103,7 +103,9 @@ const LoginPage = () => {
|
||||
<FieldError name="password" className="text-sm text-error" />
|
||||
|
||||
<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>
|
||||
</Form>
|
||||
</div>
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ProjectCell from 'src/components/Project/ProjectCell'
|
||||
|
||||
interface ProjectPageProps {
|
||||
@ -7,13 +5,7 @@ interface ProjectPageProps {
|
||||
}
|
||||
|
||||
const ProjectPage = ({ id }: ProjectPageProps) => {
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Project" />
|
||||
|
||||
<ProjectCell id={id} />
|
||||
</>
|
||||
)
|
||||
return <ProjectCell id={id} />
|
||||
}
|
||||
|
||||
export default ProjectPage
|
||||
|
@ -1,19 +1,15 @@
|
||||
import mobile from 'is-mobile'
|
||||
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ProjectsShowcaseCell from 'src/components/Project/ProjectsShowcaseCell'
|
||||
|
||||
const ProjectsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Projects" />
|
||||
|
||||
<div className="hero min-h-64">
|
||||
<div className="hero-content">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-5xl font-bold">Projects</h1>
|
||||
<p className="py-6">
|
||||
<p className="mt-8">
|
||||
{mobile({
|
||||
tablet: true,
|
||||
})
|
||||
@ -24,7 +20,6 @@ const ProjectsPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectsShowcaseCell />
|
||||
</>
|
||||
)
|
||||
|
@ -102,7 +102,7 @@ const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
|
||||
|
||||
<div className="flex w-full">
|
||||
<Submit
|
||||
className={`btn btn-primary btn-sm mx-auto ${
|
||||
className={`btn btn-primary btn-sm uppercase mx-auto ${
|
||||
!enabled ? 'btn-disabled' : ''
|
||||
}`}
|
||||
disabled={!enabled}
|
||||
|
@ -5,7 +5,7 @@ import AdminResumeCell from 'src/components/Resume/AdminResumeCell/AdminResumeCe
|
||||
const ResumePage = () => {
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Resume" />
|
||||
<Metadata title="Résumé" />
|
||||
<AdminResumeCell />
|
||||
</>
|
||||
)
|
||||
|
@ -1,15 +1,7 @@
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ResumeCell from 'src/components/Resume/ResumeCell'
|
||||
|
||||
const ResumePage = () => {
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Resume" />
|
||||
|
||||
<ResumeCell />
|
||||
</>
|
||||
)
|
||||
return <ResumeCell />
|
||||
}
|
||||
|
||||
export default ResumePage
|
||||
|
@ -1,243 +0,0 @@
|
||||
.rw-scaffold {
|
||||
@apply bg-white text-gray-600;
|
||||
}
|
||||
.rw-scaffold h1,
|
||||
.rw-scaffold h2 {
|
||||
@apply m-0;
|
||||
}
|
||||
.rw-scaffold a {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
.rw-scaffold ul {
|
||||
@apply m-0 p-0;
|
||||
}
|
||||
.rw-scaffold input:-ms-input-placeholder {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
.rw-scaffold input::-ms-input-placeholder {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
.rw-scaffold input::placeholder {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
.rw-header {
|
||||
@apply flex justify-between px-8 py-4;
|
||||
}
|
||||
.rw-main {
|
||||
@apply mx-4 pb-4;
|
||||
}
|
||||
.rw-segment {
|
||||
@apply w-full overflow-hidden rounded-lg border border-gray-200;
|
||||
scrollbar-color: theme('colors.zinc.400') transparent;
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar {
|
||||
height: initial;
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar-track {
|
||||
@apply rounded-b-[10px] rounded-t-none border-0 border-t border-solid border-gray-200 bg-transparent p-[2px];
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full border-[3px] border-solid border-transparent bg-zinc-400 bg-clip-content;
|
||||
}
|
||||
.rw-segment-header {
|
||||
@apply bg-gray-200 px-4 py-3 text-gray-700;
|
||||
}
|
||||
.rw-segment-main {
|
||||
@apply bg-gray-100 p-4;
|
||||
}
|
||||
.rw-link {
|
||||
@apply text-blue-400 underline;
|
||||
}
|
||||
.rw-link:hover {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
.rw-forgot-link {
|
||||
@apply mt-1 text-right text-xs text-gray-400 underline;
|
||||
}
|
||||
.rw-forgot-link:hover {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
.rw-heading {
|
||||
@apply font-semibold;
|
||||
}
|
||||
.rw-heading.rw-heading-primary {
|
||||
@apply text-xl;
|
||||
}
|
||||
.rw-heading.rw-heading-secondary {
|
||||
@apply text-sm;
|
||||
}
|
||||
.rw-heading .rw-link {
|
||||
@apply text-gray-600 no-underline;
|
||||
}
|
||||
.rw-heading .rw-link:hover {
|
||||
@apply text-gray-900 underline;
|
||||
}
|
||||
.rw-cell-error {
|
||||
@apply text-sm font-semibold;
|
||||
}
|
||||
.rw-form-wrapper {
|
||||
@apply -mt-4 text-sm;
|
||||
}
|
||||
.rw-cell-error,
|
||||
.rw-form-error-wrapper {
|
||||
@apply my-4 rounded border border-red-100 bg-red-50 p-4 text-red-600;
|
||||
}
|
||||
.rw-form-error-title {
|
||||
@apply m-0 font-semibold;
|
||||
}
|
||||
.rw-form-error-list {
|
||||
@apply mt-2 list-inside list-disc;
|
||||
}
|
||||
.rw-button {
|
||||
@apply flex cursor-pointer justify-center rounded border-0 bg-gray-200 px-4 py-1 text-xs font-semibold uppercase leading-loose tracking-wide text-gray-500 no-underline transition duration-100;
|
||||
}
|
||||
.rw-button:hover {
|
||||
@apply bg-gray-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-small {
|
||||
@apply rounded-sm px-2 py-1 text-xs;
|
||||
}
|
||||
.rw-button.rw-button-green {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-green:hover {
|
||||
@apply bg-green-700;
|
||||
}
|
||||
.rw-button.rw-button-blue {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-blue:hover {
|
||||
@apply bg-blue-700;
|
||||
}
|
||||
.rw-button.rw-button-red {
|
||||
@apply bg-red-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-red:hover {
|
||||
@apply bg-red-700 text-white;
|
||||
}
|
||||
.rw-button-icon {
|
||||
@apply mr-1 text-xl leading-5;
|
||||
}
|
||||
.rw-button-group {
|
||||
@apply mx-2 my-3 flex justify-center;
|
||||
}
|
||||
.rw-button-group .rw-button {
|
||||
@apply mx-1;
|
||||
}
|
||||
.rw-form-wrapper .rw-button-group {
|
||||
@apply mt-8;
|
||||
}
|
||||
.rw-label {
|
||||
@apply mt-6 block text-left font-semibold text-gray-600;
|
||||
}
|
||||
.rw-label.rw-label-error {
|
||||
@apply text-red-600;
|
||||
}
|
||||
.rw-input {
|
||||
@apply mt-2 block w-full rounded border border-gray-200 bg-white p-2 outline-none;
|
||||
}
|
||||
.rw-check-radio-items {
|
||||
@apply flex justify-items-center;
|
||||
}
|
||||
.rw-check-radio-item-none {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
.rw-input[type='checkbox'],
|
||||
.rw-input[type='radio'] {
|
||||
@apply ml-0 mr-1 mt-1 inline w-4;
|
||||
}
|
||||
.rw-input:focus {
|
||||
@apply border-gray-400;
|
||||
}
|
||||
.rw-input-error {
|
||||
@apply border-red-600 text-red-600;
|
||||
}
|
||||
.rw-input-error:focus {
|
||||
@apply border-red-600 outline-none;
|
||||
box-shadow: 0 0 5px #c53030;
|
||||
}
|
||||
.rw-field-error {
|
||||
@apply mt-1 block text-xs font-semibold uppercase text-red-600;
|
||||
}
|
||||
.rw-table-wrapper-responsive {
|
||||
@apply overflow-x-auto;
|
||||
}
|
||||
.rw-table-wrapper-responsive .rw-table {
|
||||
min-width: 48rem;
|
||||
}
|
||||
.rw-table {
|
||||
@apply w-full text-sm;
|
||||
}
|
||||
.rw-table th,
|
||||
.rw-table td {
|
||||
@apply p-3;
|
||||
}
|
||||
.rw-table td {
|
||||
@apply bg-white text-gray-900;
|
||||
}
|
||||
.rw-table tr:nth-child(odd) td,
|
||||
.rw-table tr:nth-child(odd) th {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
.rw-table thead tr {
|
||||
@apply bg-gray-200 text-gray-600;
|
||||
}
|
||||
.rw-table th {
|
||||
@apply text-left font-semibold;
|
||||
}
|
||||
.rw-table thead th {
|
||||
@apply text-left;
|
||||
}
|
||||
.rw-table tbody th {
|
||||
@apply text-right;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.rw-table tbody th {
|
||||
@apply w-1/5;
|
||||
}
|
||||
}
|
||||
.rw-table tbody tr {
|
||||
@apply border-t border-gray-200;
|
||||
}
|
||||
.rw-table input {
|
||||
@apply ml-0;
|
||||
}
|
||||
.rw-table-actions {
|
||||
@apply flex h-4 items-center justify-end pr-1;
|
||||
}
|
||||
.rw-table-actions .rw-button {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
.rw-table-actions .rw-button:hover {
|
||||
@apply bg-gray-500 text-white;
|
||||
}
|
||||
.rw-table-actions .rw-button-blue {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
.rw-table-actions .rw-button-blue:hover {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
.rw-table-actions .rw-button-red {
|
||||
@apply text-red-600;
|
||||
}
|
||||
.rw-table-actions .rw-button-red:hover {
|
||||
@apply bg-red-600 text-white;
|
||||
}
|
||||
.rw-text-center {
|
||||
@apply text-center;
|
||||
}
|
||||
.rw-login-container {
|
||||
@apply mx-auto my-16 flex w-96 flex-wrap items-center justify-center;
|
||||
}
|
||||
.rw-login-container .rw-form-wrapper {
|
||||
@apply w-full text-center;
|
||||
}
|
||||
.rw-login-link {
|
||||
@apply mt-4 w-full text-center text-sm text-gray-600;
|
||||
}
|
||||
.rw-webauthn-wrapper {
|
||||
@apply mx-4 mt-6 leading-6;
|
||||
}
|
||||
.rw-webauthn-wrapper h2 {
|
||||
@apply mb-4 text-xl font-bold;
|
||||
}
|
Reference in New Issue
Block a user