Compare commits
16 Commits
e5f9bbd462
...
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| b8063e8692 | |||
| 738260f7de | |||
| 82313bef46 | |||
| 74db2e1034 | |||
| e2dfb6f237 | |||
| 708634fa68 | |||
| 6873c5c026 | |||
| 1b7e79c765 | |||
| 6e401cf2b3 | |||
| b89a5ee1b8 | |||
| 3c2b944bf4 | |||
| 11783069a8 | |||
| 835d895fc0 | |||
| 73ec75c167 | |||
| 49c943c9f3 | |||
| fb542bb5b5 |
+4
-4
@@ -15,8 +15,8 @@ PRISMA_HIDE_UPDATE_MESSAGE=true
|
||||
# Ordered by how verbose they are: trace | debug | info | warn | error | silent
|
||||
# LOG_LEVEL=debug
|
||||
|
||||
FIRST_NAME=Ahmed
|
||||
LAST_NAME=Al-Taiar
|
||||
FIRST_NAME=firstname
|
||||
LAST_NAME=lastname
|
||||
|
||||
GMAIL=example@gmail.com
|
||||
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
|
||||
@@ -25,9 +25,9 @@ DOMAIN=example.com
|
||||
API_DOMAIN=api.example.com
|
||||
|
||||
# Must not end with "/"
|
||||
ADDRESS_PROD=https://example.com
|
||||
ADDRESS_PROD=https://portfolio.example.com
|
||||
ADDRESS_DEV=http://localhost:8910
|
||||
API_ADDRESS_PROD=https://api.example.com
|
||||
API_ADDRESS_PROD=https://api-portfolio.example.com
|
||||
API_ADDRESS_DEV=http://localhost:8911
|
||||
|
||||
MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
|
||||
+2
-2
@@ -13,9 +13,9 @@ DOMAIN=example.com
|
||||
API_DOMAIN=api.example.com
|
||||
|
||||
# Must not end with "/"
|
||||
ADDRESS_PROD=https://example.com
|
||||
ADDRESS_PROD=https://portfolio.example.com
|
||||
ADDRESS_DEV=http://localhost:8910
|
||||
API_ADDRESS_PROD=https://api.example.com
|
||||
API_ADDRESS_PROD=https://api-portfolio.example.com
|
||||
API_ADDRESS_DEV=http://localhost:8911
|
||||
|
||||
MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
version: "1"
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Publish Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to Registry
|
||||
run: echo "${{ secrets.ACCESS_TOKEN }}" | docker login git.altaiar.dev -u "${{ secrets.USERNAME }}" --password-stdin
|
||||
|
||||
- name: Build & Tag Image
|
||||
run: |
|
||||
docker build -t git.altaiar.dev/${{ gitea.repository }}:${{ gitea.ref_name }} .
|
||||
docker tag git.altaiar.dev/${{ gitea.repository }}:${{ gitea.ref_name }} git.altaiar.dev/${{ gitea.repository }}:latest
|
||||
|
||||
- name: Push Images
|
||||
run: |
|
||||
docker push git.altaiar.dev/${{ gitea.repository }}:${{ gitea.ref_name }}
|
||||
docker push git.altaiar.dev/${{ gitea.repository }}:latest
|
||||
+13
-5
@@ -37,8 +37,16 @@ FROM base as api_build
|
||||
|
||||
# If your api side build relies on build-time environment variables,
|
||||
# specify them here as ARGs. (But don't put secrets in your Dockerfile!)
|
||||
#
|
||||
# ARG MY_BUILD_TIME_ENV_VAR
|
||||
|
||||
ARG ADDRESS_PROD
|
||||
ARG ADDRESS_DEV
|
||||
ARG DOMAIN
|
||||
ARG API_DOMAIN
|
||||
ARG MAX_HTTP_CONNECTIONS_PER_MINUTE
|
||||
ARG GMAIL
|
||||
ARG GMAIL_SMTP_PASSWORD
|
||||
ARG FIRST_NAME
|
||||
ARG LAST_NAME
|
||||
|
||||
COPY --chown=node:node api api
|
||||
RUN yarn rw build api
|
||||
@@ -107,9 +115,9 @@ ENV NODE_ENV=production
|
||||
# If you are using a custom server file, you must use the following
|
||||
# command to launch your server instead of the default api-server below.
|
||||
# This is important if you intend to configure GraphQL to use Realtime.
|
||||
#
|
||||
# CMD [ "./api/dist/server.js" ]
|
||||
CMD [ "node_modules/.bin/rw-server", "api" ]
|
||||
|
||||
CMD [ "./api/dist/server.js" ]
|
||||
# CMD [ "node_modules/.bin/rw-server", "api" ]
|
||||
|
||||
# web serve
|
||||
# ---------
|
||||
|
||||
@@ -1,122 +1,72 @@
|
||||
# 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)
|
||||
### Gmail App Password
|
||||
1. Go to your Google [account dashboard](https://myaccount.google.com)
|
||||
2. Go to Security > 2-Step Verification > App Passwords > Create a new app password
|
||||
3. Copy the 16 character password
|
||||
### [Docker Compose](./docker-compose.yml)
|
||||
```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:redwood@db:5432/portfolio
|
||||
- FIRST_NAME=first name # Your first name
|
||||
- LAST_NAME=lastname # Your last name
|
||||
- GMAIL=example@gmail.com # The Gmail address associated with the app password created earlier
|
||||
- GMAIL_SMTP_PASSWORD=aaaa bbbb cccc dddd # The app password created earlier
|
||||
- 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
|
||||
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: redwood
|
||||
POSTGRES_DB: portfolio
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
```
|
||||
yarn install
|
||||
```
|
||||
## Logging In
|
||||
- Once the container is up and running, head to `/login` (`https://portfolio.example.com/login`), default credentials are below
|
||||
- If you would like to change the password, head to `/forgot-password` (`https://portfolio.example.com/forgot-password`), the username is `admin`
|
||||
- If you correctly set up the Gmail app password, you should receive an email from yourself
|
||||
- It contains the link needed to change your password
|
||||
### Default Credentials
|
||||
**Username:** `admin`
|
||||
|
||||
Then start the development server:
|
||||
|
||||
```
|
||||
yarn redwood dev
|
||||
```
|
||||
|
||||
Your browser should automatically open to [http://localhost:8910](http://localhost:8910) where you'll see the Welcome Page, which links out to many great resources.
|
||||
|
||||
> **The Redwood CLI**
|
||||
>
|
||||
> Congratulations on running your first Redwood CLI command! From dev to deploy, the CLI is with you the whole way. And there's quite a few commands at your disposal:
|
||||
>
|
||||
> ```
|
||||
> yarn redwood --help
|
||||
> ```
|
||||
>
|
||||
> For all the details, see the [CLI reference](https://redwoodjs.com/docs/cli-commands).
|
||||
|
||||
## Prisma and the database
|
||||
|
||||
Redwood wouldn't be a full-stack framework without a database. It all starts with the schema. Open the [`schema.prisma`](api/db/schema.prisma) file in `api/db` and replace the `UserExample` model with the following `Post` model:
|
||||
|
||||
```prisma
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
body String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
Redwood uses [Prisma](https://www.prisma.io/), a next-gen Node.js and TypeScript ORM, to talk to the database. Prisma's schema offers a declarative way of defining your app's data models. And Prisma [Migrate](https://www.prisma.io/migrate) uses that schema to make database migrations hassle-free:
|
||||
|
||||
```
|
||||
yarn rw prisma migrate dev
|
||||
|
||||
# ...
|
||||
|
||||
? Enter a name for the new migration: › create posts
|
||||
```
|
||||
|
||||
> `rw` is short for `redwood`
|
||||
|
||||
You'll be prompted for the name of your migration. `create posts` will do.
|
||||
|
||||
Now let's generate everything we need to perform all the CRUD (Create, Retrieve, Update, Delete) actions on our `Post` model:
|
||||
|
||||
```
|
||||
yarn redwood generate scaffold post
|
||||
```
|
||||
|
||||
Navigate to [http://localhost:8910/posts/new](http://localhost:8910/posts/new), fill in the title and body, and click "Save".
|
||||
|
||||
Did we just create a post in the database? Yup! With `yarn rw generate scaffold <model>`, Redwood created all the pages, components, and services necessary to perform all CRUD actions on our posts table.
|
||||
|
||||
## Frontend first with Storybook
|
||||
|
||||
Don't know what your data models look like? That's more than ok—Redwood integrates Storybook so that you can work on design without worrying about data. Mockup, build, and verify your React components, even in complete isolation from the backend:
|
||||
|
||||
```
|
||||
yarn rw storybook
|
||||
```
|
||||
|
||||
Seeing "Couldn't find any stories"? That's because you need a `*.stories.{tsx,jsx}` file. The Redwood CLI makes getting one easy enough—try generating a [Cell](https://redwoodjs.com/docs/cells), Redwood's data-fetching abstraction:
|
||||
|
||||
```
|
||||
yarn rw generate cell examplePosts
|
||||
```
|
||||
|
||||
The Storybook server should hot reload and now you'll have four stories to work with. They'll probably look a little bland since there's no styling. See if the Redwood CLI's `setup ui` command has your favorite styling library:
|
||||
|
||||
```
|
||||
yarn rw setup ui --help
|
||||
```
|
||||
|
||||
## Testing with Jest
|
||||
|
||||
It'd be hard to scale from side project to startup without a few tests. Redwood fully integrates Jest with both the front- and back-ends, and makes it easy to keep your whole app covered by generating test files with all your components and services:
|
||||
|
||||
```
|
||||
yarn rw test
|
||||
```
|
||||
|
||||
To make the integration even more seamless, Redwood augments Jest with database [scenarios](https://redwoodjs.com/docs/testing#scenarios) and [GraphQL mocking](https://redwoodjs.com/docs/testing#mocking-graphql-calls).
|
||||
|
||||
## Ship it
|
||||
|
||||
Redwood is designed for both serverless deploy targets like Netlify and Vercel and serverful deploy targets like Render and AWS:
|
||||
|
||||
```
|
||||
yarn rw setup deploy --help
|
||||
```
|
||||
|
||||
Don't go live without auth! Lock down your app with Redwood's built-in, database-backed authentication system ([dbAuth](https://redwoodjs.com/docs/authentication#self-hosted-auth-installation-and-setup)), or integrate with nearly a dozen third-party auth providers:
|
||||
|
||||
```
|
||||
yarn rw setup auth --help
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
The best way to learn Redwood is by going through the comprehensive [tutorial](https://redwoodjs.com/docs/tutorial/foreword) and joining the community (via the [Discourse forum](https://community.redwoodjs.com) or the [Discord server](https://discord.gg/redwoodjs)).
|
||||
|
||||
## Quick Links
|
||||
|
||||
- Stay updated: read [Forum announcements](https://community.redwoodjs.com/c/announcements/5), follow us on [Twitter](https://twitter.com/redwoodjs), and subscribe to the [newsletter](https://redwoodjs.com/newsletter)
|
||||
- [Learn how to contribute](https://redwoodjs.com/docs/contributing)
|
||||
**Password:** [`GMAIL_SMTP_PASSWORD`](#gmail-app-password)
|
||||
|
||||
@@ -11,6 +11,22 @@ import { cookieName } from 'src/lib/auth'
|
||||
import { db } from 'src/lib/db'
|
||||
import { censorEmail, sendEmail } from 'src/lib/email'
|
||||
|
||||
function getCommonCookieDomain(domain: string, apiDomain: string): string {
|
||||
const splitDomain1 = domain.split('.').reverse()
|
||||
const splitDomain2 = apiDomain.split('.').reverse()
|
||||
const commonParts: string[] = []
|
||||
|
||||
for (let i = 0; i < Math.min(splitDomain1.length, splitDomain2.length); i++) {
|
||||
if (splitDomain1[i] === splitDomain2[i]) commonParts.push(splitDomain1[i])
|
||||
else break
|
||||
}
|
||||
|
||||
if (commonParts.length < 2)
|
||||
throw new Error('Domains do not share the same TLD')
|
||||
|
||||
return commonParts.reverse().join('.')
|
||||
}
|
||||
|
||||
export const handler = async (
|
||||
event: APIGatewayProxyEvent,
|
||||
context: Context
|
||||
@@ -95,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,
|
||||
@@ -197,10 +211,8 @@ ${domain}/reset-password?resetToken=${resetToken}
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: isProduction
|
||||
? [process.env.ADDRESS_PROD, process.env.API_ADDRESS_PROD]
|
||||
: [process.env.ADDRESS_DEV, process.env.API_ADDRESS_DEV],
|
||||
credentials: true,
|
||||
origin: isProduction ? process.env.ADDRESS_PROD : process.env.ADDRESS_DEV,
|
||||
credentials: isProduction,
|
||||
methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'],
|
||||
},
|
||||
|
||||
@@ -218,8 +230,9 @@ ${domain}/reset-password?resetToken=${resetToken}
|
||||
Path: '/',
|
||||
SameSite: isProduction ? 'None' : 'Strict',
|
||||
Secure: isProduction,
|
||||
Domain: isProduction ? 'localhost' : 'localhost',
|
||||
// Domain: isProduction ? process.env.DOMAIN : 'localhost',
|
||||
Domain: isProduction
|
||||
? getCommonCookieDomain(process.env.DOMAIN, process.env.API_DOMAIN)
|
||||
: 'localhost',
|
||||
},
|
||||
name: cookieName,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HexColorCodeResolver,
|
||||
} from 'graphql-scalars'
|
||||
|
||||
import { isProduction } from '@redwoodjs/api/logger'
|
||||
import { createAuthDecoder } from '@redwoodjs/auth-dbauth-api'
|
||||
import { createGraphQLHandler } from '@redwoodjs/graphql-server'
|
||||
|
||||
@@ -32,5 +33,9 @@ export const handler = createGraphQLHandler({
|
||||
HexColorCode: HexColorCodeResolver,
|
||||
},
|
||||
},
|
||||
cors: {
|
||||
origin: isProduction ? process.env.ADDRESS_PROD : process.env.ADDRESS_DEV,
|
||||
credentials: isProduction,
|
||||
},
|
||||
onException: () => db.$disconnect(),
|
||||
})
|
||||
|
||||
@@ -15,15 +15,14 @@ const transporter = nodemailer.createTransport({
|
||||
},
|
||||
})
|
||||
|
||||
export const sendEmail = async ({ to, subject, text, html }: Options) => {
|
||||
return await transporter.sendMail({
|
||||
export const sendEmail = async ({ to, subject, text, html }: Options) =>
|
||||
await transporter.sendMail({
|
||||
from: `"${process.env.FIRST_NAME} ${process.env.LAST_NAME} (noreply)" <${process.env.GMAIL}>`,
|
||||
to: Array.isArray(to) ? to : [to],
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
||||
export const censorEmail = (email: string): string => {
|
||||
const [localPart, domain] = email.split('@')
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@ import { handleTusUpload } from 'src/lib/tus'
|
||||
configureApiServer: async (server) => {
|
||||
await server.register(Cors, {
|
||||
origin: isProduction
|
||||
? [process.env.ADDRESS_PROD, process.env.API_ADDRESS_PROD]
|
||||
: [process.env.ADDRESS_DEV, process.env.API_ADDRESS_DEV],
|
||||
? process.env.ADDRESS_PROD
|
||||
: process.env.ADDRESS_DEV,
|
||||
methods: ['GET', 'POST', 'OPTIONS', 'PATCH', 'HEAD'],
|
||||
credentials: isProduction ? true : false,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,48 @@
|
||||
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:redwood@db:5432/portfolio
|
||||
- FIRST_NAME=first name # Your first name
|
||||
- LAST_NAME=lastname # Your last name
|
||||
- GMAIL=example@gmail.com # The Gmail address associated with the app password created earlier
|
||||
- GMAIL_SMTP_PASSWORD=aaaa bbbb cccc dddd # The app password created earlier
|
||||
- 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
|
||||
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: redwood
|
||||
POSTGRES_DB: portfolio
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
+19
-27
@@ -15,40 +15,32 @@ export default async () => {
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
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: MAX_TITLES }).map(
|
||||
(_, i) => `a title ${i + 1}`
|
||||
),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
@@ -141,5 +141,17 @@ export const theme = {
|
||||
}
|
||||
|
||||
export const darkMode = ['class', '[data-theme="dark"]']
|
||||
export const plugins = [require('daisyui')]
|
||||
export const daisyui = { themes: ['light', 'dark'] }
|
||||
export const plugins = [require('@tailwindcss/typography'), require('daisyui')]
|
||||
export const daisyui = {
|
||||
themes: [
|
||||
'light',
|
||||
{
|
||||
dark: {
|
||||
...require('daisyui/src/theming/themes')['dark'],
|
||||
'base-100': '#212121',
|
||||
'base-200': '#1d1d1d',
|
||||
'base-300': '#191919',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
+10
-1
@@ -19,6 +19,13 @@
|
||||
"@redwoodjs/router": "8.3.0",
|
||||
"@redwoodjs/web": "8.3.0",
|
||||
"@redwoodjs/web-server": "8.3.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",
|
||||
@@ -27,18 +34,20 @@
|
||||
"@uppy/progress-bar": "^4.0.0",
|
||||
"@uppy/react": "^4.0.1",
|
||||
"@uppy/tus": "^4.0.0",
|
||||
"@uppy/webcam": "^4.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"humanize-string": "2.1.0",
|
||||
"react": "18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "18.3.1",
|
||||
"react-html-parser": "^2.0.2",
|
||||
"react-typed": "^2.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redwoodjs/vite": "8.3.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",
|
||||
|
||||
+8
-2
@@ -5,14 +5,20 @@ 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 = () => (
|
||||
<FatalErrorBoundary page={FatalErrorPage}>
|
||||
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
|
||||
<AuthProvider>
|
||||
<RedwoodApolloProvider useAuth={useAuth}>
|
||||
<RedwoodApolloProvider
|
||||
useAuth={useAuth}
|
||||
graphQLClientConfig={{
|
||||
httpLinkConfig: {
|
||||
credentials: 'include',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Routes />
|
||||
</RedwoodApolloProvider>
|
||||
</AuthProvider>
|
||||
|
||||
+5
-1
@@ -1,5 +1,9 @@
|
||||
import { createDbAuthClient, createAuth } from '@redwoodjs/auth-dbauth-web'
|
||||
|
||||
const dbAuthClient = createDbAuthClient()
|
||||
const dbAuthClient = createDbAuthClient({
|
||||
fetchConfig: {
|
||||
credentials: 'include',
|
||||
},
|
||||
})
|
||||
|
||||
export const { AuthProvider, useAuth } = createAuth(dbAuthClient)
|
||||
|
||||
@@ -22,6 +22,7 @@ const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
|
||||
<HexColorInput color={color} className="w-16" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -35,6 +36,7 @@ const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
|
||||
<Icon path={mdiContentCopy} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm "
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useLayoutEffect } from 'react'
|
||||
|
||||
import SocialLinksCell from 'src/components/Social/SocialLinksCell'
|
||||
|
||||
@@ -12,7 +12,7 @@ const ContactCard = ({ portraitUrl }: ContactCardProps) => {
|
||||
|
||||
const observedDiv = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!observedDiv.current) return
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import Icon from '@mdi/react'
|
||||
|
||||
interface FormTextListProps {
|
||||
name: string
|
||||
hint?: string
|
||||
itemPlaceholder: string
|
||||
icon?: string
|
||||
list: string[]
|
||||
@@ -13,6 +14,7 @@ interface FormTextListProps {
|
||||
|
||||
const FormTextList = ({
|
||||
name,
|
||||
hint,
|
||||
itemPlaceholder,
|
||||
icon,
|
||||
list,
|
||||
@@ -23,15 +25,20 @@ const FormTextList = ({
|
||||
<div className="flex flex-col space-y-2 bg-base-100 rounded-xl">
|
||||
<div className="flex space-x-2 justify-between">
|
||||
<div className="flex items-center">
|
||||
<p className="font-semibold">{name}</p>
|
||||
<p className="font-semibold text-center">{name}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{hint && (
|
||||
<p className="opacity-70 text-xs font-light text-center">{hint}</p>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-square btn-sm"
|
||||
type="button"
|
||||
onClick={() => setList([...list, ''])}
|
||||
>
|
||||
<Icon path={mdiPlus} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-square btn-sm"
|
||||
type="button"
|
||||
onClick={() => setList([...list, ''])}
|
||||
>
|
||||
<Icon path={mdiPlus} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{list.map((item, i) => (
|
||||
<label
|
||||
@@ -53,11 +60,11 @@ const FormTextList = ({
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-square btn-sm flex-none"
|
||||
className="btn btn-square btn-error btn-sm flex-none"
|
||||
type="button"
|
||||
onClick={() => setList(list.filter((_, j) => j !== i))}
|
||||
>
|
||||
<Icon path={mdiDelete} className="size-4 text-error" />
|
||||
<Icon path={mdiDelete} className="size-4" />
|
||||
</button>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
interface PDFProps {
|
||||
url: string
|
||||
form?: boolean
|
||||
}
|
||||
|
||||
const PDF = ({ url, form = false }: PDFProps) => (
|
||||
<embed
|
||||
src={url}
|
||||
title="PDF"
|
||||
type="application/pdf"
|
||||
style={{
|
||||
width: 'calc(100vw - 1rem)',
|
||||
height: `calc(100vh - ${form ? '8.5rem' : '6rem'})`,
|
||||
}}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
)
|
||||
|
||||
export default PDF
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Meta, UploadResult } from '@uppy/core'
|
||||
import type {
|
||||
@@ -56,13 +56,7 @@ const CREATE_PORTRAIT_MUTATION: TypedDocumentNode<
|
||||
`
|
||||
|
||||
const PortraitForm = ({ portrait }: PortraitFormProps) => {
|
||||
const [fileId, _setFileId] = useState<string>(portrait?.fileId)
|
||||
const fileIdRef = useRef<string>(fileId)
|
||||
|
||||
const setFileId = (fileId: string) => {
|
||||
_setFileId(fileId)
|
||||
fileIdRef.current = fileId
|
||||
}
|
||||
const [fileId, setFileId] = useState<string>(portrait?.fileId)
|
||||
|
||||
const unloadAbortController = new AbortController()
|
||||
|
||||
@@ -96,7 +90,7 @@ const PortraitForm = ({ portrait }: PortraitFormProps) => {
|
||||
setFileId(result.successful[0]?.uploadURL)
|
||||
window.addEventListener(
|
||||
'beforeunload',
|
||||
(e) => handleBeforeUnload(e, [fileIdRef.current]),
|
||||
(e) => handleBeforeUnload(e, [fileId]),
|
||||
{
|
||||
once: true,
|
||||
signal: unloadAbortController.signal,
|
||||
@@ -108,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`}
|
||||
/>
|
||||
@@ -139,7 +133,7 @@ const PortraitForm = ({ portrait }: PortraitFormProps) => {
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="22rem"
|
||||
height="11.5rem"
|
||||
height="34.5rem"
|
||||
className="flex justify-center"
|
||||
/>
|
||||
<p className="text-center">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import parseHtml from 'react-html-parser'
|
||||
import type {
|
||||
DeleteProjectMutation,
|
||||
DeleteProjectMutationVariables,
|
||||
@@ -68,7 +69,11 @@ const AdminProject = ({ project }: Props) => {
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<td>{project.description}</td>
|
||||
<td>
|
||||
<article className="prose">
|
||||
{parseHtml(project.description)}
|
||||
</article>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Date</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,23 +42,30 @@ 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>
|
||||
<div className="flex flex-col gap-2 w-fit">
|
||||
{project.links.map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="btn btn-wide"
|
||||
>
|
||||
<Icon path={mdiLinkVariant} className="size-5" />
|
||||
{link}
|
||||
</a>
|
||||
))}
|
||||
<ul className="list-none">
|
||||
{project.links.map((link, i) => (
|
||||
<li key={i}>
|
||||
<div className="flex gap-2 items-center justify-start">
|
||||
<Icon path={mdiLinkVariant} className="size-4" />
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="list-item link link-hover"
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -198,6 +196,7 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
<div className={`${!pickerVisible && 'pt-2'}`}>
|
||||
<FormTextList
|
||||
name="Links"
|
||||
hint="Short links are recommended"
|
||||
itemPlaceholder="URL"
|
||||
icon={mdiLinkVariant}
|
||||
list={links}
|
||||
@@ -246,16 +245,13 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
<div className="card-actions rounded-md justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm shadow-xl"
|
||||
className="btn btn-square btn-sm btn-error shadow-xl"
|
||||
onClick={() => {
|
||||
setToDelete([...toDelete, fileId])
|
||||
setFileIds(fileIds.filter((id) => id !== fileId))
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
path={mdiDelete}
|
||||
className="size-4 text-error"
|
||||
/>
|
||||
<Icon path={mdiDelete} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { format, isAfter, startOfToday } from 'date-fns'
|
||||
import parseHtml from 'react-html-parser'
|
||||
import { FindProjects } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
@@ -56,7 +57,11 @@ const ProjectsShowcase = ({ projects }: FindProjects) => {
|
||||
<div className="card-title overflow-auto">
|
||||
<p className="whitespace-nowrap">{project.title}</p>
|
||||
</div>
|
||||
<div className="line-clamp-5">{project.description}</div>
|
||||
<div className="line-clamp-5">
|
||||
<article className="prose text-sm">
|
||||
{parseHtml(project.description)}
|
||||
</article>
|
||||
</div>
|
||||
<div className="card-actions justify-between">
|
||||
<div className="flex gap-2">
|
||||
{isAfter(new Date(project.date), startOfToday()) && (
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Meta, UploadResult } from '@uppy/core'
|
||||
import type {
|
||||
@@ -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'
|
||||
|
||||
@@ -55,13 +56,7 @@ const CREATE_RESUME_MUTATION: TypedDocumentNode<
|
||||
`
|
||||
|
||||
const ResumeForm = ({ resume }: ResumeFormProps) => {
|
||||
const [fileId, _setFileId] = useState<string>(resume?.fileId)
|
||||
const fileIdRef = useRef<string>(fileId)
|
||||
|
||||
const setFileId = (fileId: string) => {
|
||||
_setFileId(fileId)
|
||||
fileIdRef.current = fileId
|
||||
}
|
||||
const [fileId, setFileId] = useState<string>(resume?.fileId)
|
||||
|
||||
const unloadAbortController = new AbortController()
|
||||
|
||||
@@ -95,7 +90,7 @@ const ResumeForm = ({ resume }: ResumeFormProps) => {
|
||||
setFileId(result.successful[0]?.uploadURL)
|
||||
window.addEventListener(
|
||||
'beforeunload',
|
||||
(e) => handleBeforeUnload(e, [fileIdRef.current]),
|
||||
(e) => handleBeforeUnload(e, [fileId]),
|
||||
{
|
||||
once: true,
|
||||
signal: unloadAbortController.signal,
|
||||
@@ -106,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"
|
||||
@@ -149,16 +135,7 @@ const ResumeForm = ({ resume }: ResumeFormProps) => {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
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 flex-wrap gap-2 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 flex-wrap gap-2 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>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="textarea textarea-bordered font-normal prose"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RichTextEditor
|
||||
@@ -16,7 +16,7 @@ import type { EditSocialById, UpdateSocialInput } from 'types/graphql'
|
||||
import type { RWGqlError } from '@redwoodjs/forms'
|
||||
import { Form, FieldError, Label, TextField, Submit } from '@redwoodjs/forms'
|
||||
|
||||
import { baseUrls, getLogoComponent } from 'src/lib/handle'
|
||||
import { baseUrls, getLogoComponent, sortOrder } from 'src/lib/handle'
|
||||
|
||||
type FormSocial = NonNullable<EditSocialById['social']>
|
||||
|
||||
@@ -233,19 +233,21 @@ const SocialForm = (props: SocialFormProps) => {
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content z-10 mt-2 grid w-72 grid-cols-5 grid-rows-2 gap-2 rounded-box bg-base-100 shadow-xl"
|
||||
>
|
||||
{types.map((type, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
className="btn btn-square btn-ghost"
|
||||
onClick={() => {
|
||||
setType(type)
|
||||
setTypesDropdownOpen(false)
|
||||
}}
|
||||
>
|
||||
{getLogoComponent(type)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{types
|
||||
.sort((a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b))
|
||||
.map((type, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
className="btn btn-square btn-ghost"
|
||||
onClick={() => {
|
||||
setType(type)
|
||||
setTypesDropdownOpen(false)
|
||||
}}
|
||||
>
|
||||
{getLogoComponent(type)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import { FindSocials } from 'types/graphql'
|
||||
|
||||
import { baseUrls, getLogoComponent } from 'src/lib/handle'
|
||||
import { baseUrls, getLogoComponent, sortOrder } from 'src/lib/handle'
|
||||
|
||||
const SocialLinks = ({ socials }: FindSocials) => {
|
||||
return (
|
||||
<div className={`grid max-w-68 grid-cols-[repeat(auto-fit,3rem)] gap-2`}>
|
||||
{[...socials]
|
||||
.sort((a, b) => (a.type > b.type ? 1 : -1))
|
||||
.map((social, i) => (
|
||||
<div key={i} className="tooltip" data-tip={social.name}>
|
||||
<a
|
||||
className="btn btn-square"
|
||||
href={`${baseUrls[social.type]}${social.username}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{getLogoComponent(social.type)}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const SocialLinks = ({ socials }: FindSocials) => (
|
||||
<div className={`grid max-w-68 grid-cols-[repeat(auto-fit,3rem)] gap-2`}>
|
||||
{[...socials]
|
||||
.sort((a, b) => sortOrder.indexOf(a.type) - sortOrder.indexOf(b.type))
|
||||
.map((social, i) => (
|
||||
<div key={i} className="tooltip" data-tip={social.name}>
|
||||
<a
|
||||
className="btn btn-square"
|
||||
href={`${baseUrls[social.type]}${social.username}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{getLogoComponent(social.type)}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default SocialLinks
|
||||
|
||||
@@ -13,7 +13,7 @@ import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { QUERY } from 'src/components/Social/SocialsCell'
|
||||
import { truncate } from 'src/lib/formatters'
|
||||
import { getLogoComponent } from 'src/lib/handle'
|
||||
import { getLogoComponent, sortOrder } from 'src/lib/handle'
|
||||
|
||||
const DELETE_SOCIAL_MUTATION: TypedDocumentNode<
|
||||
DeleteSocialMutation,
|
||||
@@ -58,66 +58,70 @@ const SocialsList = ({ socials }: FindSocials) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{socials.map((social) => {
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Link
|
||||
to={routes.social({ id: social.id })}
|
||||
title={'Show social ' + social.id + ' detail'}
|
||||
className="btn btn-xs uppercase"
|
||||
>
|
||||
Show
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.editSocial({ id: social.id })}
|
||||
title={'Edit social ' + social.id}
|
||||
className="btn btn-primary btn-xs uppercase"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title={'Delete social ' + social.id}
|
||||
className="btn btn-error btn-xs uppercase"
|
||||
onClick={() => onDeleteClick(social.name, social.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
{[...socials]
|
||||
.sort(
|
||||
(a, b) => sortOrder.indexOf(a.type) - sortOrder.indexOf(b.type)
|
||||
)
|
||||
.map((social) => {
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Link
|
||||
to={routes.social({ id: social.id })}
|
||||
title={'Show social ' + social.id + ' detail'}
|
||||
className="btn btn-xs uppercase"
|
||||
>
|
||||
Show
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.editSocial({ id: social.id })}
|
||||
title={'Edit social ' + social.id}
|
||||
className="btn btn-primary btn-xs uppercase"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title={'Delete social ' + social.id}
|
||||
className="btn btn-error btn-xs uppercase"
|
||||
onClick={() => onDeleteClick(social.name, social.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<tr key={social.id}>
|
||||
<th>{getLogoComponent(social.type)}</th>
|
||||
<td>{truncate(social.name)}</td>
|
||||
<td>{truncate(social.username)}</td>
|
||||
<td>
|
||||
<nav className="hidden justify-end space-x-2 sm:flex">
|
||||
{actionButtons}
|
||||
</nav>
|
||||
<div className="dropdown dropdown-end flex justify-end sm:hidden">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="btn btn-square btn-ghost btn-sm lg:hidden"
|
||||
>
|
||||
<Icon
|
||||
path={mdiDotsVertical}
|
||||
className="text-base-content-100 size-6"
|
||||
/>
|
||||
return (
|
||||
<tr key={social.id}>
|
||||
<th>{getLogoComponent(social.type)}</th>
|
||||
<td>{truncate(social.name)}</td>
|
||||
<td>{truncate(social.username)}</td>
|
||||
<td>
|
||||
<nav className="hidden justify-end space-x-2 sm:flex">
|
||||
{actionButtons}
|
||||
</nav>
|
||||
<div className="dropdown dropdown-end flex justify-end sm:hidden">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="btn btn-square btn-ghost btn-sm lg:hidden"
|
||||
>
|
||||
<Icon
|
||||
path={mdiDotsVertical}
|
||||
className="text-base-content-100 size-6"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content z-10 -mt-1 mr-10 inline rounded-box bg-base-100 shadow-xl"
|
||||
>
|
||||
<nav className="w-46 space-x-2">{actionButtons}</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content z-10 -mt-1 mr-10 inline rounded-box bg-base-100 shadow-xl"
|
||||
>
|
||||
<nav className="w-46 space-x-2">{actionButtons}</nav>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ const DARK_THEME = 'dark'
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const [theme, setTheme] = useState(
|
||||
localStorage.getItem('theme') ? localStorage.getItem('theme') : LIGHT_THEME
|
||||
localStorage.getItem('theme') ?? LIGHT_THEME
|
||||
)
|
||||
|
||||
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -37,13 +37,8 @@ const ThemeToggle = () => {
|
||||
checked={theme === DARK_THEME}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
|
||||
<Icon
|
||||
path={mdiWeatherSunny}
|
||||
className="swap-off size-8 text-yellow-500"
|
||||
/>
|
||||
|
||||
<Icon path={mdiWeatherNight} className="swap-on size-8 text-blue-500" />
|
||||
<Icon path={mdiWeatherSunny} className="swap-off size-8 text-warning" />
|
||||
<Icon path={mdiWeatherNight} className="swap-on size-8 text-primary" />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ export const Titles = ({ titles, className }: TitlesProps) => {
|
||||
backDelay={1000}
|
||||
startWhenVisible
|
||||
loop
|
||||
onStringTyped={(pos, self) => {
|
||||
if (pos === 0) {
|
||||
self.stop()
|
||||
setTimeout(() => self.start(), 2500)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -56,6 +56,9 @@ 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) => (
|
||||
<Label key={i} name={`title${i}`} className="form-control w-full">
|
||||
<Label
|
||||
@@ -94,7 +97,7 @@ const TitlesForm = ({ titles }: TitlesFormProps) => {
|
||||
<nav className="my-2 flex justify-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
className="btn btn-sm uppercase"
|
||||
onClick={() => setPreview(!preview)}
|
||||
>
|
||||
{preview ? 'Hide' : 'Show'} Preview
|
||||
|
||||
@@ -5,11 +5,13 @@ import Uppy from '@uppy/core'
|
||||
import type { UploadResult, Meta } from '@uppy/core'
|
||||
import { Dashboard } from '@uppy/react'
|
||||
import Tus from '@uppy/tus'
|
||||
import Webcam from '@uppy/webcam'
|
||||
|
||||
import { isProduction } from '@redwoodjs/api/logger'
|
||||
|
||||
import '@uppy/core/dist/style.min.css'
|
||||
import '@uppy/dashboard/dist/style.min.css'
|
||||
import '@uppy/webcam/dist/style.min.css'
|
||||
|
||||
type FileType = 'image' | 'pdf'
|
||||
|
||||
@@ -69,6 +71,11 @@ const Uploader = ({
|
||||
mimeType: 'image/webp',
|
||||
})
|
||||
|
||||
if (type === 'image')
|
||||
instance.use(Webcam, {
|
||||
modes: ['picture'],
|
||||
})
|
||||
|
||||
return instance.on('complete', onComplete)
|
||||
})
|
||||
|
||||
|
||||
@@ -19,3 +19,7 @@
|
||||
.image-full-no-overlay::before {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const AccountbarLayout = ({ title, children }: AccountbarLayoutProps) => {
|
||||
<>
|
||||
<ToasterWrapper />
|
||||
<div className="sticky top-0 z-50 p-2">
|
||||
<div className="navbar rounded-xl bg-base-300 shadow-xl">
|
||||
<div className="navbar rounded-xl bg-base-300 backdrop-blur bg-opacity-90 shadow-xl">
|
||||
<div className="navbar-start">
|
||||
<p className="btn btn-ghost font-syne text-xl sm:hidden">{title}</p>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
<>
|
||||
<ToasterWrapper />
|
||||
<div className="sticky top-0 z-50 p-2">
|
||||
<div className="navbar rounded-xl bg-base-300 shadow-xl">
|
||||
<div className="navbar rounded-xl bg-base-300 backdrop-blur bg-opacity-90 shadow-xl">
|
||||
<div className="navbar-start space-x-2 lg:first:space-x-0">
|
||||
<div className="dropdown">
|
||||
<div
|
||||
|
||||
@@ -25,7 +25,7 @@ const ScaffoldLayout = ({
|
||||
<>
|
||||
<ToasterWrapper />
|
||||
<div className="sticky top-0 z-50 p-2">
|
||||
<div className="navbar rounded-xl bg-base-300 font-syne shadow-xl">
|
||||
<div className="navbar rounded-xl bg-base-300 font-syne backdrop-blur bg-opacity-90 shadow-xl">
|
||||
<div className="navbar-start space-x-2">
|
||||
<Link to={routes.home()} className="btn btn-square btn-ghost">
|
||||
<Icon className="size-8" path={mdiHome} />
|
||||
|
||||
+23
-1
@@ -20,7 +20,7 @@ import {
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
import { mdiEmail, mdiLink, mdiPhone } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import type { Handle } from 'types/graphql'
|
||||
import type { Handle, Social } from 'types/graphql'
|
||||
|
||||
export const baseUrls: Record<Handle, string> = {
|
||||
x: 'https://x.com/',
|
||||
@@ -76,4 +76,26 @@ const logoComponents: Record<Handle, ReactElement> = {
|
||||
custom: <Icon path={mdiLink} className="size-7" />,
|
||||
}
|
||||
|
||||
export const sortOrder: Social['type'][] = [
|
||||
'phone',
|
||||
'email',
|
||||
'custom',
|
||||
'linkedin',
|
||||
'leetcode',
|
||||
'github',
|
||||
'gitea',
|
||||
'forgejo',
|
||||
'gitlab',
|
||||
'bitbucket',
|
||||
'youtube',
|
||||
'x',
|
||||
'instagram',
|
||||
'tiktok',
|
||||
'facebook',
|
||||
'threads',
|
||||
'twitch',
|
||||
'discord',
|
||||
'steam',
|
||||
]
|
||||
|
||||
export const getLogoComponent = (type: Handle) => logoComponents[type]
|
||||
|
||||
+1
-2
@@ -8,9 +8,8 @@ export const deleteFile = async (fileId: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const handleBeforeUnload = (_e: BeforeUnloadEvent, files: string[]) => {
|
||||
export const handleBeforeUnload = (_e: BeforeUnloadEvent, files: string[]) =>
|
||||
batchDelete(files)
|
||||
}
|
||||
|
||||
export const batchDelete = (files: string[]) => {
|
||||
for (const file of files) deleteFile(file)
|
||||
|
||||
@@ -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,21 +1,50 @@
|
||||
import { mdiCompass, mdiContacts } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
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 = () => {
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Home" />
|
||||
const HomePage = () => (
|
||||
<>
|
||||
<Metadata title="Home" />
|
||||
|
||||
<div className="hero min-h-64">
|
||||
<div className="hero-content">
|
||||
<div className="max-w-xl text-center">
|
||||
<TitlesCell className="text-primary" />
|
||||
</div>
|
||||
<div className="hero min-h-[calc(100vh-6rem)]">
|
||||
<div className="hero-content flex flex-col gap-8">
|
||||
<div className="text-center">
|
||||
<TitlesCell className="text-primary" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={routes.projects()}
|
||||
className="btn btn-primary btn-outline sm:btn-wide btn-lg"
|
||||
>
|
||||
<Icon path={mdiCompass} className="size-6" />
|
||||
Explore
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.contact()}
|
||||
className="btn btn-primary btn-outline sm:btn-wide btn-lg"
|
||||
>
|
||||
<Icon path={mdiContacts} className="size-6" />
|
||||
Contact
|
||||
</Link>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
|
||||
export default HomePage
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { isMobile, isBrowser } from 'react-device-detect'
|
||||
import mobile from 'is-mobile'
|
||||
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
@@ -14,8 +14,12 @@ const ProjectsPage = () => {
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-5xl font-bold">Projects</h1>
|
||||
<p className="py-6">
|
||||
{isBrowser && !isMobile ? 'Click' : 'Tap'} on a project for
|
||||
details
|
||||
{mobile({
|
||||
tablet: true,
|
||||
})
|
||||
? 'Tap'
|
||||
: 'Click'}{' '}
|
||||
on a project for details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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