Compare commits
37 Commits
22b2e25875
..
v1.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 62ce137bcb | |||
| f8987b08da | |||
| cbf75acbeb | |||
| 7973663b2a | |||
| 6540329f36 | |||
| bac5b5fe48 | |||
| f3f75d3e57 | |||
| f14732cdf0 | |||
| 77db153fe6 | |||
| 684d6f88c2 | |||
| 03f606bbde | |||
| 1eafaee2c0 | |||
| b8063e8692 | |||
| 738260f7de | |||
| 82313bef46 | |||
| 74db2e1034 | |||
| e2dfb6f237 | |||
| 708634fa68 | |||
| 6873c5c026 | |||
| 1b7e79c765 | |||
| 6e401cf2b3 | |||
| b89a5ee1b8 | |||
| 3c2b944bf4 | |||
| 11783069a8 | |||
| 835d895fc0 | |||
| 73ec75c167 | |||
| 49c943c9f3 | |||
| fb542bb5b5 | |||
| e5f9bbd462 | |||
| 8671f47e91 | |||
| 4a94b6807e | |||
| 9c0dee7d54 | |||
| 38168db452 | |||
| c9227cf9b9 | |||
| 5c41588249 | |||
| 430a2da835 | |||
| 43be1abf96 |
+13
-5
@@ -15,18 +15,26 @@ PRISMA_HIDE_UPDATE_MESSAGE=true
|
||||
# Ordered by how verbose they are: trace | debug | info | warn | error | silent
|
||||
# LOG_LEVEL=debug
|
||||
|
||||
NAME=Ahmed Al-Taiar
|
||||
FIRST_NAME=firstname
|
||||
LAST_NAME=lastname
|
||||
|
||||
GMAIL=example@gmail.com
|
||||
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
|
||||
COUNTRY=US
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
+13
-5
@@ -3,18 +3,26 @@
|
||||
# PRISMA_HIDE_UPDATE_MESSAGE=true
|
||||
# LOG_LEVEL=trace
|
||||
|
||||
NAME=Firstname Lastname
|
||||
FIRST_NAME=firstname
|
||||
LAST_NAME=lastname
|
||||
|
||||
GMAIL=example@gmail.com
|
||||
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
|
||||
COUNTRY=US
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
+22
-7
@@ -37,8 +37,21 @@ 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 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
|
||||
|
||||
COPY --chown=node:node api api
|
||||
RUN yarn rw build api
|
||||
@@ -47,7 +60,8 @@ RUN yarn rw build api
|
||||
# -------------------
|
||||
FROM api_build as web_build_with_prerender
|
||||
|
||||
ARG NAME
|
||||
ARG FIRST_NAME
|
||||
ARG LAST_NAME
|
||||
ARG API_ADDRESS_PROD
|
||||
ARG API_ADDRESS_DEV
|
||||
|
||||
@@ -58,7 +72,8 @@ RUN yarn rw build web
|
||||
# ---------
|
||||
FROM base as web_build
|
||||
|
||||
ARG NAME
|
||||
ARG FIRST_NAME
|
||||
ARG LAST_NAME
|
||||
ARG API_ADDRESS_PROD
|
||||
ARG API_ADDRESS_DEV
|
||||
|
||||
@@ -105,9 +120,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,82 @@
|
||||
# README
|
||||
# Portfolio Website
|
||||
## Setup
|
||||
### Domain Records
|
||||
Create two A records, one for the web side of the website and one for the api side of the website. Ideally, the records should look something like `myportfolio.example.com` for the web side and `api.myportfolio.example.com`, but it is not important.
|
||||
### Reverse Proxy
|
||||
- It doesn't matter what reverse proxy you use (Nginx, Apache, Traefik, Caddy, etc)
|
||||
1. Point the web domain to the web port (default: 8910)
|
||||
2. Point the api domain to the api port (default: 8911)
|
||||
### SMTP
|
||||
You will need credentials to authorize sending Email, instructions vary depending on provider (Gmail, Hotmail, etc).
|
||||
### [Docker Compose](./docker-compose.yml)
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
Welcome to [RedwoodJS](https://redwoodjs.com)!
|
||||
services:
|
||||
portfolio:
|
||||
container_name: portfolio
|
||||
image: git.altaiar.dev/ahmed/portfolio:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- API_PROXY_TARGET=http://localhost:8911
|
||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||
- FIRST_NAME=first name # Your first name
|
||||
- LAST_NAME=lastname # Your last name
|
||||
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||
- SMTP_HOST=smtp.example.com
|
||||
- SMTP_PORT=465
|
||||
- SMTP_SECURE=true
|
||||
- SMTP_USER=noreply@example.com
|
||||
- EMAIL_FROM=noreply@example.com
|
||||
- EMAIL_TO=email@example.com
|
||||
- SMTP_PASSWORD=password
|
||||
- DOMAIN=portfolio.example.com
|
||||
- API_DOMAIN=api.portfolio.example.com
|
||||
# Careful, addresses below must not end with a '/'
|
||||
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
||||
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
||||
ports:
|
||||
- 8910:8910 # Web
|
||||
- 8911:8911 # API
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- files:/home/node/app/api/files_prod
|
||||
command: >
|
||||
/bin/sh -c "
|
||||
yarn rw build &&
|
||||
yarn rw prisma migrate deploy &&
|
||||
yarn rw prisma db seed &&
|
||||
yarn rw serve"
|
||||
|
||||
> **Prerequisites**
|
||||
>
|
||||
> - Redwood requires [Node.js](https://nodejs.org/en/) (=20.x) and [Yarn](https://yarnpkg.com/)
|
||||
> - Are you on Windows? For best results, follow our [Windows development setup](https://redwoodjs.com/docs/how-to/windows-development-setup) guide
|
||||
|
||||
Start by installing dependencies:
|
||||
db:
|
||||
container_name: portfolio-db
|
||||
image: postgres:16-bookworm
|
||||
environment:
|
||||
- POSTGRES_USER=redwood
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
- POSTGRES_DB=portfolio
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
files: # For persistent file storage across upgrades
|
||||
```
|
||||
yarn install
|
||||
## Fix Files Ownership
|
||||
The `files` volume in Docker is owned by `root`, since the portfolio container runs under the `node` user, file uploads will fail. Run this command to give ownership to the `node` user:
|
||||
```
|
||||
|
||||
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
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ALTER COLUMN "date" DROP DEFAULT;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "Handle" ADD VALUE 'gitea';
|
||||
ALTER TYPE "Handle" ADD VALUE 'leetcode';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "Handle" ADD VALUE 'steam';
|
||||
ALTER TYPE "Handle" ADD VALUE 'discord';
|
||||
ALTER TYPE "Handle" ADD VALUE 'twitch';
|
||||
ALTER TYPE "Handle" ADD VALUE 'forgejo';
|
||||
ALTER TYPE "Handle" ADD VALUE 'gitlab';
|
||||
ALTER TYPE "Handle" ADD VALUE 'bitbucket';
|
||||
ALTER TYPE "Handle" ADD VALUE 'phone';
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `ProjectImage` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ProjectImage" DROP CONSTRAINT "ProjectImage_projectId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "images" TEXT[];
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "ProjectImage";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ALTER COLUMN "images" SET DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Resume" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"fileId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Resume_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Title" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Title_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `Title` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropTable
|
||||
DROP TABLE "Title";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Titles" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"titles" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
|
||||
CONSTRAINT "Titles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "Handle" ADD VALUE 'matrix';
|
||||
+25
-13
@@ -21,9 +21,19 @@ enum Handle {
|
||||
facebook
|
||||
tiktok
|
||||
youtube
|
||||
steam
|
||||
discord
|
||||
twitch
|
||||
linkedin
|
||||
matrix
|
||||
github
|
||||
gitea
|
||||
forgejo
|
||||
gitlab
|
||||
bitbucket
|
||||
leetcode
|
||||
email
|
||||
phone
|
||||
custom
|
||||
}
|
||||
|
||||
@@ -49,6 +59,16 @@ model Portrait {
|
||||
fileId String
|
||||
}
|
||||
|
||||
model Resume {
|
||||
id Int @id @default(autoincrement())
|
||||
fileId String
|
||||
}
|
||||
|
||||
model Titles {
|
||||
id Int @id @default(autoincrement())
|
||||
titles String[] @default([])
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id Int @id @default(autoincrement())
|
||||
tag String
|
||||
@@ -56,20 +76,12 @@ model Tag {
|
||||
projects Project[]
|
||||
}
|
||||
|
||||
model ProjectImage {
|
||||
id Int @id @default(autoincrement())
|
||||
fileId String
|
||||
|
||||
Project Project? @relation(fields: [projectId], references: [id])
|
||||
projectId Int?
|
||||
}
|
||||
|
||||
model Project {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
description String @default("No description provided")
|
||||
images ProjectImage[]
|
||||
date DateTime @default(now())
|
||||
links String[] @default([])
|
||||
description String @default("No description provided")
|
||||
images String[] @default([])
|
||||
date DateTime
|
||||
links String[] @default([])
|
||||
tags Tag[]
|
||||
}
|
||||
|
||||
+5
-4
@@ -5,12 +5,13 @@
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@redwoodjs/api": "8.0.0",
|
||||
"@redwoodjs/api-server": "8.0.0",
|
||||
"@redwoodjs/auth-dbauth-api": "8.0.0",
|
||||
"@redwoodjs/graphql-server": "8.0.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",
|
||||
"country-flag-icons": "^1.5.13",
|
||||
"graphql-scalars": "^1.23.0",
|
||||
"nodemailer": "^6.9.14"
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
export const schema = gql`
|
||||
type ProjectImage {
|
||||
id: Int!
|
||||
fileId: URL!
|
||||
Project: Project
|
||||
projectId: Int
|
||||
}
|
||||
|
||||
type Query {
|
||||
projectImages: [ProjectImage!]! @requireAuth
|
||||
projectImage(id: Int!): ProjectImage @requireAuth
|
||||
}
|
||||
|
||||
input CreateProjectImageInput {
|
||||
fileId: URL!
|
||||
projectId: Int
|
||||
}
|
||||
|
||||
input UpdateProjectImageInput {
|
||||
fileId: URL
|
||||
projectId: Int
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createProjectImage(input: CreateProjectImageInput!): ProjectImage!
|
||||
@requireAuth
|
||||
updateProjectImage(
|
||||
id: Int!
|
||||
input: UpdateProjectImageInput!
|
||||
): ProjectImage! @requireAuth
|
||||
deleteProjectImage(id: Int!): ProjectImage! @requireAuth
|
||||
}
|
||||
`
|
||||
@@ -3,15 +3,15 @@ export const schema = gql`
|
||||
id: Int!
|
||||
title: String!
|
||||
description: String!
|
||||
images: [ProjectImage]!
|
||||
images: [String]!
|
||||
date: DateTime!
|
||||
links: [URL]!
|
||||
tags: [Tag]!
|
||||
}
|
||||
|
||||
type Query {
|
||||
projects: [Project!]! @requireAuth
|
||||
project(id: Int!): Project @requireAuth
|
||||
projects: [Project!]! @skipAuth
|
||||
project(id: Int!): Project @skipAuth
|
||||
}
|
||||
|
||||
input CreateProjectInput {
|
||||
@@ -19,6 +19,8 @@ export const schema = gql`
|
||||
description: String!
|
||||
date: DateTime!
|
||||
links: [URL]!
|
||||
images: [URL]!
|
||||
tags: [Int!]
|
||||
}
|
||||
|
||||
input UpdateProjectInput {
|
||||
@@ -26,6 +28,9 @@ export const schema = gql`
|
||||
description: String
|
||||
date: DateTime
|
||||
links: [URL]!
|
||||
images: [URL]!
|
||||
tags: [Int!]
|
||||
removeTags: [Int!]
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const schema = gql`
|
||||
type Resume {
|
||||
id: Int!
|
||||
fileId: URL!
|
||||
}
|
||||
|
||||
type Query {
|
||||
resume: Resume @skipAuth
|
||||
}
|
||||
|
||||
input CreateResumeInput {
|
||||
fileId: URL!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createResume(input: CreateResumeInput!): Resume! @requireAuth
|
||||
deleteResume: Resume! @requireAuth
|
||||
}
|
||||
`
|
||||
@@ -13,9 +13,19 @@ export const schema = gql`
|
||||
facebook
|
||||
tiktok
|
||||
youtube
|
||||
steam
|
||||
discord
|
||||
twitch
|
||||
linkedin
|
||||
matrix
|
||||
github
|
||||
gitea
|
||||
forgejo
|
||||
gitlab
|
||||
bitbucket
|
||||
leetcode
|
||||
email
|
||||
phone
|
||||
custom
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ export const schema = gql`
|
||||
}
|
||||
|
||||
type Query {
|
||||
tags: [Tag!]! @requireAuth
|
||||
tag(id: Int!): Tag @requireAuth
|
||||
tags: [Tag!]! @skipAuth
|
||||
tag(id: Int!): Tag @skipAuth
|
||||
}
|
||||
|
||||
input CreateTagInput {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const schema = gql`
|
||||
type Titles {
|
||||
id: Int!
|
||||
titles: [String]!
|
||||
}
|
||||
|
||||
type Query {
|
||||
titles: Titles! @skipAuth
|
||||
}
|
||||
|
||||
input UpdateTitlesInput {
|
||||
titles: [String]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
updateTitles(input: UpdateTitlesInput!): Titles! @requireAuth
|
||||
}
|
||||
`
|
||||
@@ -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.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
-2
@@ -10,13 +10,20 @@ import { createServer } from '@redwoodjs/api-server'
|
||||
import { logger } from 'src/lib/logger'
|
||||
import { handleTusUpload } from 'src/lib/tus'
|
||||
;(async () => {
|
||||
const { hasFlag } = await import('country-flag-icons')
|
||||
|
||||
if (!hasFlag(process.env.COUNTRY))
|
||||
throw new Error(
|
||||
'Invalid COUNTRY environment variable, please select a valid ISO-3166-1 alpha-2 country code\n See https://en.wikipedia.org/wiki/ISO_3166-1#Codes'
|
||||
)
|
||||
|
||||
const server = await createServer({
|
||||
logger,
|
||||
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,49 +0,0 @@
|
||||
import type {
|
||||
QueryResolvers,
|
||||
MutationResolvers,
|
||||
ProjectImageRelationResolvers,
|
||||
} from 'types/graphql'
|
||||
|
||||
import { db } from 'src/lib/db'
|
||||
|
||||
export const projectImages: QueryResolvers['projectImages'] = () => {
|
||||
return db.projectImage.findMany()
|
||||
}
|
||||
|
||||
export const projectImage: QueryResolvers['projectImage'] = ({ id }) => {
|
||||
return db.projectImage.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export const createProjectImage: MutationResolvers['createProjectImage'] = ({
|
||||
input,
|
||||
}) => {
|
||||
return db.projectImage.create({
|
||||
data: input,
|
||||
})
|
||||
}
|
||||
|
||||
export const updateProjectImage: MutationResolvers['updateProjectImage'] = ({
|
||||
id,
|
||||
input,
|
||||
}) => {
|
||||
return db.projectImage.update({
|
||||
data: input,
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteProjectImage: MutationResolvers['deleteProjectImage'] = ({
|
||||
id,
|
||||
}) => {
|
||||
return db.projectImage.delete({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export const ProjectImage: ProjectImageRelationResolvers = {
|
||||
Project: (_obj, { root }) => {
|
||||
return db.projectImage.findUnique({ where: { id: root?.id } }).Project()
|
||||
},
|
||||
}
|
||||
@@ -15,15 +15,34 @@ export const project: QueryResolvers['project'] = ({ id }) =>
|
||||
|
||||
export const createProject: MutationResolvers['createProject'] = ({ input }) =>
|
||||
db.project.create({
|
||||
data: input,
|
||||
data: {
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
date: input.date,
|
||||
links: input.links,
|
||||
images: input.images,
|
||||
tags: {
|
||||
connect: input.tags.map((tagId) => ({ id: tagId })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const updateProject: MutationResolvers['updateProject'] = ({
|
||||
export const updateProject: MutationResolvers['updateProject'] = async ({
|
||||
id,
|
||||
input,
|
||||
}) =>
|
||||
db.project.update({
|
||||
data: input,
|
||||
data: {
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
date: input.date,
|
||||
links: input.links,
|
||||
images: input.images,
|
||||
tags: {
|
||||
disconnect: input.removeTags?.map((tagId) => ({ id: tagId })),
|
||||
connect: input.tags?.map((tagId) => ({ id: tagId })),
|
||||
},
|
||||
},
|
||||
where: { id },
|
||||
})
|
||||
|
||||
@@ -33,8 +52,6 @@ export const deleteProject: MutationResolvers['deleteProject'] = ({ id }) =>
|
||||
})
|
||||
|
||||
export const Project: ProjectRelationResolvers = {
|
||||
images: (_obj, { root }) =>
|
||||
db.project.findUnique({ where: { id: root?.id } }).images(),
|
||||
tags: (_obj, { root }) =>
|
||||
db.project.findUnique({ where: { id: root?.id } }).tags(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { QueryResolvers, MutationResolvers } from 'types/graphql'
|
||||
|
||||
import { isProduction } from '@redwoodjs/api/logger'
|
||||
import { ValidationError } from '@redwoodjs/graphql-server'
|
||||
|
||||
import { db } from 'src/lib/db'
|
||||
|
||||
const address = isProduction
|
||||
? process.env.ADDRESS_PROD
|
||||
: process.env.ADDRESS_DEV
|
||||
|
||||
export const resume: QueryResolvers['resume'] = async () => {
|
||||
const resume = await db.resume.findFirst()
|
||||
|
||||
if (resume) return resume
|
||||
else
|
||||
return {
|
||||
id: -1,
|
||||
fileId: `${address}/no_resume.pdf`,
|
||||
}
|
||||
}
|
||||
|
||||
export const createResume: MutationResolvers['createResume'] = async ({
|
||||
input,
|
||||
}) => {
|
||||
if (await db.resume.findFirst())
|
||||
throw new ValidationError('Resume already exists')
|
||||
else
|
||||
return db.resume.create({
|
||||
data: input,
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteResume: MutationResolvers['deleteResume'] = async () => {
|
||||
const resume = await db.resume.findFirst()
|
||||
|
||||
if (!resume) throw new ValidationError('Resume does not exist')
|
||||
else
|
||||
return db.resume.delete({
|
||||
where: { id: resume.id },
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
MutationResolvers,
|
||||
CreateSocialInput,
|
||||
UpdateSocialInput,
|
||||
Handle,
|
||||
} from 'types/graphql'
|
||||
|
||||
import { ValidationError } from '@redwoodjs/graphql-server'
|
||||
@@ -12,6 +13,8 @@ import { db } from 'src/lib/db'
|
||||
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 phoneRegex = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/
|
||||
|
||||
const emailRegex =
|
||||
/^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/
|
||||
|
||||
@@ -50,8 +53,12 @@ const validateInput = (input: CreateSocialInput | UpdateSocialInput) => {
|
||||
throw new ValidationError('Name is required')
|
||||
if (!input.type) throw new ValidationError('Type is required')
|
||||
|
||||
if (input.type === 'custom' && !urlRegex.test(input.username))
|
||||
const urlHandles: Handle[] = ['custom', 'gitea', 'forgejo']
|
||||
|
||||
if (urlHandles.includes(input.type) && !urlRegex.test(input.username))
|
||||
throw new ValidationError('Invalid URL')
|
||||
else if (input.type === 'phone' && !phoneRegex.test(input.username))
|
||||
throw new ValidationError('Invalid Phone Number')
|
||||
else if (input.type === 'email' && !emailRegex.test(input.username))
|
||||
throw new ValidationError('Invalid Email')
|
||||
else if (input.username.trim().length === 0)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { QueryResolvers, MutationResolvers } from 'types/graphql'
|
||||
|
||||
import { db } from 'src/lib/db'
|
||||
|
||||
export const titles: QueryResolvers['titles'] = () => db.titles.findFirst()
|
||||
|
||||
export const updateTitles: MutationResolvers['updateTitles'] = ({ input }) =>
|
||||
db.titles.update({
|
||||
data: input,
|
||||
where: { id: 1 },
|
||||
})
|
||||
@@ -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,55 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
portfolio:
|
||||
container_name: portfolio
|
||||
image: git.altaiar.dev/ahmed/portfolio:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- API_PROXY_TARGET=http://localhost:8911
|
||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||
- FIRST_NAME=first name # Your first name
|
||||
- LAST_NAME=lastname # Your last name
|
||||
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||
- SMTP_HOST=smtp.example.com
|
||||
- SMTP_PORT=465
|
||||
- SMTP_SECURE=true
|
||||
- SMTP_USER=noreply@example.com
|
||||
- EMAIL_FROM=noreply@example.com
|
||||
- EMAIL_TO=email@example.com
|
||||
- SMTP_PASSWORD=password
|
||||
- DOMAIN=portfolio.example.com
|
||||
- API_DOMAIN=api.portfolio.example.com
|
||||
# Careful, addresses below must not end with a '/'
|
||||
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
||||
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
||||
ports:
|
||||
- 8910:8910 # Web
|
||||
- 8911:8911 # API
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- files:/home/node/app/api/files_prod
|
||||
command: >
|
||||
/bin/sh -c "
|
||||
yarn rw build &&
|
||||
yarn rw prisma migrate deploy &&
|
||||
yarn rw prisma db seed &&
|
||||
yarn rw serve"
|
||||
|
||||
|
||||
db:
|
||||
container_name: portfolio-db
|
||||
image: postgres:16-bookworm
|
||||
environment:
|
||||
- POSTGRES_USER=redwood
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
- POSTGRES_DB=portfolio
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
files: # For persistent file storage across upgrades
|
||||
+3
-3
@@ -7,9 +7,9 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redwoodjs/auth-dbauth-setup": "8.0.0",
|
||||
"@redwoodjs/core": "8.0.0",
|
||||
"@redwoodjs/project-config": "8.0.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": {
|
||||
|
||||
+2
-2
@@ -6,10 +6,10 @@
|
||||
# https://redwoodjs.com/docs/app-configuration-redwood-toml
|
||||
|
||||
[web]
|
||||
title = "${NAME}"
|
||||
title = "${FIRST_NAME} ${LAST_NAME}"
|
||||
port = 8910
|
||||
apiUrl = "/api"
|
||||
includeEnvironmentVariables = ["NAME", "API_ADDRESS_PROD", "API_ADDRESS_DEV"]
|
||||
includeEnvironmentVariables = ["FIRST_NAME", "LAST_NAME", "COUNTRY", "API_ADDRESS_PROD", "API_ADDRESS_DEV"]
|
||||
[generate]
|
||||
tests = false
|
||||
stories = false
|
||||
|
||||
+34
-14
@@ -3,32 +3,52 @@ 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()
|
||||
|
||||
if (!titles)
|
||||
await db.titles.create({
|
||||
data: {
|
||||
titles: Array.from({ length: MAX_TITLES }).map(
|
||||
(_, i) => `a title ${i + 1}`
|
||||
),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,16 @@ import {
|
||||
SiTiktokHex,
|
||||
SiYoutubeHex,
|
||||
SiLinkedinHex,
|
||||
SiMatrixHex,
|
||||
SiGithubHex,
|
||||
SiGiteaHex,
|
||||
SiLeetcodeHex,
|
||||
SiSteamHex,
|
||||
SiDiscordHex,
|
||||
SiTwitchHex,
|
||||
SiForgejoHex,
|
||||
SiGitlabHex,
|
||||
SiBitbucketHex,
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
|
||||
const invertColor = (hex) => {
|
||||
@@ -36,6 +45,9 @@ export const theme = {
|
||||
maxWidth: {
|
||||
68: '17rem',
|
||||
},
|
||||
height: {
|
||||
128: '32rem',
|
||||
},
|
||||
aspectRatio: {
|
||||
portrait: '4 / 5',
|
||||
},
|
||||
@@ -84,18 +96,67 @@ export const theme = {
|
||||
light: SiYoutubeHex,
|
||||
dark: SiYoutubeHex,
|
||||
},
|
||||
|
||||
steam: {
|
||||
light: SiSteamHex,
|
||||
dark: invertColor(SiSteamHex),
|
||||
},
|
||||
discord: {
|
||||
light: SiDiscordHex,
|
||||
dark: SiDiscordHex,
|
||||
},
|
||||
twitch: {
|
||||
light: SiTwitchHex,
|
||||
dark: SiTwitchHex,
|
||||
},
|
||||
linkedin: {
|
||||
light: SiLinkedinHex,
|
||||
dark: SiLinkedinHex,
|
||||
},
|
||||
matrix: {
|
||||
light: SiMatrixHex,
|
||||
dark: invertColor(SiMatrixHex),
|
||||
},
|
||||
github: {
|
||||
light: SiGithubHex,
|
||||
dark: invertColor(SiGithubHex),
|
||||
},
|
||||
gitea: {
|
||||
light: SiGiteaHex,
|
||||
dark: SiGiteaHex,
|
||||
},
|
||||
forgejo: {
|
||||
light: SiForgejoHex,
|
||||
dark: SiForgejoHex,
|
||||
},
|
||||
gitlab: {
|
||||
light: SiGitlabHex,
|
||||
dark: SiGitlabHex,
|
||||
},
|
||||
bitbucket: {
|
||||
light: SiBitbucketHex,
|
||||
dark: SiBitbucketHex,
|
||||
},
|
||||
leetcode: {
|
||||
light: SiLeetcodeHex,
|
||||
dark: SiLeetcodeHex,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
+20
-7
@@ -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.0.0",
|
||||
"@redwoodjs/forms": "8.0.0",
|
||||
"@redwoodjs/router": "8.0.0",
|
||||
"@redwoodjs/web": "8.0.0",
|
||||
"@redwoodjs/web-server": "8.0.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",
|
||||
@@ -27,15 +34,21 @@
|
||||
"@uppy/progress-bar": "^4.0.0",
|
||||
"@uppy/react": "^4.0.1",
|
||||
"@uppy/tus": "^4.0.0",
|
||||
"@uppy/webcam": "^4.0.1",
|
||||
"country-flag-icons": "^1.5.13",
|
||||
"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-dom": "18.3.1",
|
||||
"react-html-parser": "^2.0.2",
|
||||
"react-typed": "^2.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redwoodjs/vite": "8.0.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",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 757 B |
Binary file not shown.
+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>
|
||||
|
||||
+14
-3
@@ -3,7 +3,7 @@ import { Router, Route, Set, PrivateSet } from '@redwoodjs/router'
|
||||
import { useAuth } from 'src/auth'
|
||||
import AccountbarLayout from 'src/layouts/AccountbarLayout'
|
||||
import NavbarLayout from 'src/layouts/NavbarLayout/NavbarLayout'
|
||||
import ScaffoldLayout from 'src/layouts/ScaffoldLayout'
|
||||
import ScaffoldLayout from 'src/layouts/ScaffoldLayout/ScaffoldLayout'
|
||||
|
||||
const Routes = () => {
|
||||
return (
|
||||
@@ -27,11 +27,19 @@ const Routes = () => {
|
||||
<Route path="/admin/portrait" page={PortraitPortraitPage} name="portrait" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={ScaffoldLayout} title="Titles" titleTo="titles">
|
||||
<Route path="/admin/titles" page={TitleTitlesPage} name="titles" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={ScaffoldLayout} title="Resume" titleTo="adminResume">
|
||||
<Route path="/admin/resume" page={ResumeAdminResumePage} name="adminResume" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={ScaffoldLayout} title="Projects" titleTo="projects" 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={ProjectProjectPage} name="project" />
|
||||
<Route path="/admin/projects" page={ProjectProjectsPage} name="projects" />
|
||||
<Route path="/admin/projects/{id:Int}" page={ProjectAdminProjectPage} name="adminProject" />
|
||||
<Route path="/admin/projects" page={ProjectAdminProjectsPage} name="adminProjects" />
|
||||
</Set>
|
||||
</PrivateSet>
|
||||
|
||||
@@ -49,7 +57,10 @@ const Routes = () => {
|
||||
|
||||
<Set wrap={NavbarLayout}>
|
||||
<Route path="/" page={HomePage} name="home" />
|
||||
<Route path="/projects" page={ProjectProjectsPage} name="projects" />
|
||||
<Route path="/project/{id:Int}" page={ProjectProjectPage} name="project" />
|
||||
<Route path="/contact" page={ContactPage} name="contact" />
|
||||
<Route path="/resume" page={ResumeResumePage} name="resume" />
|
||||
</Set>
|
||||
|
||||
<Route notfound page={NotFoundPage} />
|
||||
|
||||
+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)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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
|
||||
@@ -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,10 +36,17 @@ const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
|
||||
<Icon path={mdiContentCopy} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm "
|
||||
onClick={async () => {
|
||||
try {
|
||||
setColor(await navigator.clipboard.readText())
|
||||
const clipboardText = await navigator.clipboard.readText()
|
||||
const hexColorRegex =
|
||||
/^#(?:(?:[\da-f]{3}){1,2}|(?:[\da-f]{4}){1,2})$/i
|
||||
|
||||
if (!hexColorRegex.test(clipboardText))
|
||||
toast.error(`Text is not a valid hex color`)
|
||||
else setColor(clipboardText)
|
||||
} catch {
|
||||
toast.error(`Failed to paste, please try again`)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useLayoutEffect } from 'react'
|
||||
|
||||
import SocialLinksCell from 'src/components/Social/SocialLinksCell'
|
||||
import { ContactCardPortrait } from 'types/graphql'
|
||||
|
||||
import SocialLinks from 'src/components/Social/SocialLinks/SocialLinks'
|
||||
|
||||
interface ContactCardProps {
|
||||
portraitUrl: string
|
||||
socials: ContactCardPortrait['socials']
|
||||
}
|
||||
|
||||
const ContactCard = ({ portraitUrl }: ContactCardProps) => {
|
||||
const ContactCard = ({ portraitUrl, socials }: ContactCardProps) => {
|
||||
const [width, setWidth] = useState()
|
||||
const [height, setHeight] = useState()
|
||||
|
||||
const observedDiv = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!observedDiv.current) return
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
@@ -56,7 +59,7 @@ const ContactCard = ({ portraitUrl }: ContactCardProps) => {
|
||||
<img
|
||||
className="contact-me-image aspect-portrait object-cover"
|
||||
src={portraitUrl}
|
||||
alt={`${process.env.NAME}`}
|
||||
alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`}
|
||||
/>
|
||||
</figure>
|
||||
<div
|
||||
@@ -68,7 +71,7 @@ const ContactCard = ({ portraitUrl }: ContactCardProps) => {
|
||||
</h2>
|
||||
<p className="p-2"></p>
|
||||
<div className="card-actions">
|
||||
<SocialLinksCell />
|
||||
<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'
|
||||
@@ -12,13 +17,19 @@ import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard'
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
FindPortrait,
|
||||
FindPortraitVariables
|
||||
ContactCardPortrait,
|
||||
ContactCardPortraitVariables
|
||||
> = gql`
|
||||
query ContactCardPortrait {
|
||||
portrait: portrait {
|
||||
portrait {
|
||||
fileId
|
||||
}
|
||||
socials {
|
||||
id
|
||||
name
|
||||
type
|
||||
username
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -26,12 +37,28 @@ 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} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import {
|
||||
add,
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
getDay,
|
||||
isEqual,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
parse,
|
||||
startOfWeek,
|
||||
sub,
|
||||
} from 'date-fns'
|
||||
|
||||
interface DatePickerProps {
|
||||
date: Date
|
||||
setDate: React.Dispatch<React.SetStateAction<Date>>
|
||||
month: string
|
||||
setMonth: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
const DatePicker = ({ date, setDate, month, setMonth }: DatePickerProps) => {
|
||||
const currentMonthFirstDay = parse(month, 'MMMM yyyy', new Date())
|
||||
|
||||
const days = eachDayOfInterval({
|
||||
start: startOfWeek(currentMonthFirstDay),
|
||||
end: endOfWeek(endOfMonth(currentMonthFirstDay)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-fit bg-base-100 space-y-2 p-2 rounded-xl">
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setMonth(
|
||||
format(sub(currentMonthFirstDay, { months: 1 }), 'MMMM yyyy')
|
||||
)
|
||||
}
|
||||
className="btn btn-sm btn-square btn-ghost"
|
||||
>
|
||||
<Icon path={mdiChevronLeft} className="size-5" />
|
||||
</button>
|
||||
|
||||
<p>{format(currentMonthFirstDay, 'MMMM yyyy')}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setMonth(
|
||||
format(add(currentMonthFirstDay, { months: 1 }), 'MMMM yyyy')
|
||||
)
|
||||
}
|
||||
className="btn btn-sm btn-square btn-ghost"
|
||||
>
|
||||
<Icon path={mdiChevronRight} className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 border-y py-1 border-base-300 grid-cols-7 text-center">
|
||||
{['S', 'M', 'T', 'W', 'T', 'F', 'S '].map((weekday, i) => (
|
||||
<div key={i}>{weekday}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-2 grid-cols-7 text-center">
|
||||
{days.map((day, i) => {
|
||||
const selected = isEqual(day, date)
|
||||
const isCurrentMonth = isSameMonth(day, currentMonthFirstDay)
|
||||
const today = isToday(day)
|
||||
const weekday = getDay(day)
|
||||
|
||||
let btnColor = 'btn-ghost'
|
||||
|
||||
if (!isCurrentMonth) btnColor += ' opacity-40'
|
||||
|
||||
if (today) btnColor = 'btn-error'
|
||||
if (selected) btnColor = 'btn-primary'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toString()}
|
||||
className={`${i === 0 && ['col-start-1', 'col-start-2', 'col-start-3', 'col-start-4', 'col-start-5', 'col-start-6', 'col-start-7'][weekday]}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDate(day)}
|
||||
className={`btn btn-sm btn-square ${btnColor}`}
|
||||
>
|
||||
<time dateTime={format(day, 'yyyy-MM-dd')}>
|
||||
{format(day, 'd')}
|
||||
</time>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatePicker
|
||||
@@ -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
|
||||
@@ -11,22 +11,18 @@ import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
import PortraitForm from 'src/components/Portrait/PortraitForm/PortraitForm'
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
FindPortrait,
|
||||
FindPortraitVariables
|
||||
> = gql`
|
||||
query FindPortrait {
|
||||
portrait: portrait {
|
||||
id
|
||||
fileId
|
||||
export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
|
||||
gql`
|
||||
query FindPortrait {
|
||||
portrait {
|
||||
id
|
||||
fileId
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
|
||||
export const Empty = () => <CellEmpty />
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps<FindPortraitVariables>) => (
|
||||
<CellFailure error={error} />
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Meta, UploadResult } from '@uppy/core'
|
||||
import type {
|
||||
@@ -15,6 +15,7 @@ import { TypedDocumentNode, useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import Uploader from 'src/components/Uploader/Uploader'
|
||||
import { deleteFile, handleBeforeUnload } from 'src/lib/tus'
|
||||
|
||||
interface PortraitFormProps {
|
||||
portrait?: Portrait
|
||||
@@ -54,14 +55,8 @@ const CREATE_PORTRAIT_MUTATION: TypedDocumentNode<
|
||||
}
|
||||
`
|
||||
|
||||
const PortraitForm = (props: PortraitFormProps) => {
|
||||
const [fileId, _setFileId] = useState<string>(props.portrait?.fileId)
|
||||
const fileIdRef = useRef(fileId)
|
||||
|
||||
const setFileId = (fileId: string) => {
|
||||
_setFileId(fileId)
|
||||
fileIdRef.current = fileId
|
||||
}
|
||||
const PortraitForm = ({ portrait }: PortraitFormProps) => {
|
||||
const [fileId, setFileId] = useState<string>(portrait?.fileId)
|
||||
|
||||
const unloadAbortController = new AbortController()
|
||||
|
||||
@@ -89,40 +84,27 @@ const PortraitForm = (props: PortraitFormProps) => {
|
||||
}
|
||||
)
|
||||
|
||||
const handleBeforeUnload = (_e: BeforeUnloadEvent) => {
|
||||
deleteFile(fileIdRef.current)
|
||||
|
||||
if (navigator.userAgent.match(/firefox|fxios/i)) {
|
||||
const firefoxVer = Number(navigator.userAgent.match(/Firefox\/(\d+)/)[1])
|
||||
|
||||
// One day dom.fetchKeepalive.enabled becomes true by default... until then!
|
||||
if (firefoxVer < 129) {
|
||||
const time = Date.now()
|
||||
|
||||
while (Date.now() - time < 500) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onUploadComplete = (
|
||||
result: UploadResult<Meta, Record<string, never>>
|
||||
) => {
|
||||
setFileId(result.successful[0]?.uploadURL)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload, {
|
||||
once: true,
|
||||
signal: unloadAbortController.signal,
|
||||
})
|
||||
window.addEventListener(
|
||||
'beforeunload',
|
||||
(e) => handleBeforeUnload(e, [fileId]),
|
||||
{
|
||||
once: true,
|
||||
signal: unloadAbortController.signal,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (props.portrait?.fileId)
|
||||
if (portrait?.fileId)
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
<img
|
||||
className="aspect-portrait max-w-2xl rounded-xl object-cover"
|
||||
src={props.portrait?.fileId}
|
||||
alt={`${process.env.NAME} Portrait`}
|
||||
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`}
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
@@ -131,7 +113,7 @@ const PortraitForm = (props: PortraitFormProps) => {
|
||||
className="btn btn-error btn-sm uppercase"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure?')) {
|
||||
deleteFile(props.portrait?.fileId)
|
||||
deleteFile(portrait?.fileId)
|
||||
deletePortrait()
|
||||
setFileId(null)
|
||||
}
|
||||
@@ -151,7 +133,7 @@ const PortraitForm = (props: PortraitFormProps) => {
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="22rem"
|
||||
height="11.5rem"
|
||||
height="34.5rem"
|
||||
className="flex justify-center"
|
||||
/>
|
||||
<p className="text-center">
|
||||
@@ -162,7 +144,7 @@ const PortraitForm = (props: PortraitFormProps) => {
|
||||
<img
|
||||
className="aspect-portrait max-w-2xl rounded-xl object-cover"
|
||||
src={fileId}
|
||||
alt={`${process.env.NAME} Portrait`}
|
||||
alt={`${process.env.FIRST_NAME} Portrait`}
|
||||
/>
|
||||
)}
|
||||
{fileId && (
|
||||
@@ -202,14 +184,4 @@ const PortraitForm = (props: PortraitFormProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
const deleteFile = async (fileId: string) => {
|
||||
await fetch(fileId, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Tus-Resumable': '1.0.0',
|
||||
},
|
||||
keepalive: true,
|
||||
})
|
||||
}
|
||||
|
||||
export default PortraitForm
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import parseHtml from 'react-html-parser'
|
||||
import type {
|
||||
DeleteProjectMutation,
|
||||
DeleteProjectMutationVariables,
|
||||
AdminFindProjectById,
|
||||
} from 'types/graphql'
|
||||
|
||||
import { Link, routes, navigate } from '@redwoodjs/router'
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import type { TypedDocumentNode } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { calculateLuminance } from 'src/lib/color'
|
||||
import { timeTag } from 'src/lib/formatters'
|
||||
import { batchDelete } from 'src/lib/tus'
|
||||
|
||||
const DELETE_PROJECT_MUTATION: TypedDocumentNode<
|
||||
DeleteProjectMutation,
|
||||
DeleteProjectMutationVariables
|
||||
> = gql`
|
||||
mutation DeleteProjectMutation($id: Int!) {
|
||||
deleteProject(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface Props {
|
||||
project: NonNullable<AdminFindProjectById['project']>
|
||||
}
|
||||
|
||||
const AdminProject = ({ project }: Props) => {
|
||||
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Project deleted')
|
||||
navigate(routes.adminProjects())
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => {
|
||||
if (confirm('Are you sure you want to delete project ' + id + '?')) {
|
||||
batchDelete(project.images)
|
||||
deleteProject({ variables: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full 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">
|
||||
Project {project.id}: {project.title}
|
||||
</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<td>{project.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<td>{project.title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<td>
|
||||
<article className="prose">
|
||||
{parseHtml(project.description)}
|
||||
</article>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<td>{timeTag(project.date)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Images</th>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.images.map((image, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={image}
|
||||
target="_blank"
|
||||
className="btn btn-sm btn-square"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{i + 1}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Tags</th>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>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>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav className="my-2 flex justify-center space-x-2">
|
||||
<Link
|
||||
to={routes.editProject({ id: project.id })}
|
||||
title={'Edit project ' + project.id}
|
||||
className="btn btn-primary btn-sm uppercase"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title={'Delete project ' + project.id}
|
||||
className="btn btn-error btn-sm uppercase"
|
||||
onClick={() => onDeleteClick(project.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminProject
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
AdminFindProjectById,
|
||||
AdminFindProjectByIdVariables,
|
||||
} 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 Project from 'src/components/Project/AdminProject/AdminProject'
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
AdminFindProjectById,
|
||||
AdminFindProjectByIdVariables
|
||||
> = gql`
|
||||
query AdminFindProjectById($id: Int!) {
|
||||
project: project(id: $id) {
|
||||
id
|
||||
title
|
||||
description
|
||||
date
|
||||
links
|
||||
images
|
||||
tags {
|
||||
id
|
||||
tag
|
||||
color
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
export const Empty = () => <CellEmpty />
|
||||
export const Failure = ({
|
||||
error,
|
||||
}: CellFailureProps<AdminFindProjectByIdVariables>) => (
|
||||
<CellFailure error={error} />
|
||||
)
|
||||
export const Success = ({
|
||||
project,
|
||||
}: CellSuccessProps<AdminFindProjectById, AdminFindProjectByIdVariables>) => (
|
||||
<Project project={project} />
|
||||
)
|
||||
@@ -25,6 +25,12 @@ export const QUERY: TypedDocumentNode<EditProjectById> = gql`
|
||||
description
|
||||
date
|
||||
links
|
||||
images
|
||||
tags {
|
||||
id
|
||||
tag
|
||||
color
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -55,7 +61,7 @@ export const Success = ({ project }: CellSuccessProps<EditProjectById>) => {
|
||||
{
|
||||
onCompleted: () => {
|
||||
toast.success('Project updated')
|
||||
navigate(routes.projects())
|
||||
navigate(routes.adminProjects())
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const NewProject = () => {
|
||||
{
|
||||
onCompleted: () => {
|
||||
toast.success('Project created')
|
||||
navigate(routes.projects())
|
||||
navigate(routes.adminProjects())
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
}
|
||||
|
||||
@@ -1,105 +1,91 @@
|
||||
import type {
|
||||
DeleteProjectMutation,
|
||||
DeleteProjectMutationVariables,
|
||||
FindProjectById,
|
||||
} from 'types/graphql'
|
||||
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 { Link, routes, navigate } from '@redwoodjs/router'
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import type { TypedDocumentNode } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { timeTag } from 'src/lib/formatters'
|
||||
|
||||
const DELETE_PROJECT_MUTATION: TypedDocumentNode<
|
||||
DeleteProjectMutation,
|
||||
DeleteProjectMutationVariables
|
||||
> = gql`
|
||||
mutation DeleteProjectMutation($id: Int!) {
|
||||
deleteProject(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
import { calculateLuminance } from 'src/lib/color'
|
||||
|
||||
interface Props {
|
||||
project: NonNullable<FindProjectById['project']>
|
||||
}
|
||||
|
||||
const Project = ({ project }: Props) => {
|
||||
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Project deleted')
|
||||
navigate(routes.projects())
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => {
|
||||
if (confirm('Are you sure you want to delete project ' + id + '?'))
|
||||
deleteProject({ variables: { id } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full 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">
|
||||
Project {project.id}: {project.title}
|
||||
</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<td>{project.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<td>{project.title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<td>{project.description}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<td>{timeTag(project.date)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Links</th>
|
||||
<td className="space-x-2 space-y-2">
|
||||
{project.links.map((link, i) => (
|
||||
<div className="badge badge-ghost text-nowrap" key={i}>
|
||||
{link}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="grid grid-rows-1 grid-cols-1 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-8 p-8">
|
||||
<h1 className="text-5xl font-bold font-syne w-fit">{project.title}</h1>
|
||||
<div className="flex flex-wrap gap-2 w-fit">
|
||||
{isAfter(new Date(project.date), startOfToday()) && (
|
||||
<div className="badge badge-lg badge-info whitespace-nowrap">
|
||||
planned
|
||||
</div>
|
||||
)}
|
||||
<div className="badge badge-lg badge-ghost whitespace-nowrap">
|
||||
{format(project.date, 'PPP')}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="my-2 flex justify-center space-x-2">
|
||||
<Link
|
||||
to={routes.editProject({ id: project.id })}
|
||||
title={'Edit project ' + project.id}
|
||||
className="btn btn-primary btn-sm uppercase"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title={'Delete project ' + project.id}
|
||||
className="btn btn-error btn-sm uppercase"
|
||||
onClick={() => onDeleteClick(project.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</nav>
|
||||
{project.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 w-fit">
|
||||
{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>
|
||||
)}
|
||||
{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">
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{project.images.length > 0 && (
|
||||
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 pt-8 justify-center h-fit">
|
||||
{project.images.length > 0 &&
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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'
|
||||
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
import Project from 'src/components/Project/Project'
|
||||
import Project from 'src/components/Project/Project/Project'
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
FindProjectById,
|
||||
@@ -22,6 +24,12 @@ export const QUERY: TypedDocumentNode<
|
||||
description
|
||||
date
|
||||
links
|
||||
images
|
||||
tags {
|
||||
id
|
||||
tag
|
||||
color
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -34,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} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { mdiFormatTitle, mdiLinkVariant } from '@mdi/js'
|
||||
import { mdiCalendar, mdiDelete, mdiFormatTitle, mdiLinkVariant } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import type { EditProjectById, UpdateProjectInput } from 'types/graphql'
|
||||
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 {
|
||||
EditProjectById,
|
||||
FindTags,
|
||||
UpdateProjectInput,
|
||||
} 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 FormTextList from 'src/components/FormTextList/FormTextList'
|
||||
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'
|
||||
|
||||
type FormProject = NonNullable<EditProjectById['project']>
|
||||
|
||||
// TODO: add project images
|
||||
|
||||
interface ProjectFormProps {
|
||||
project?: EditProjectById['project']
|
||||
onSave: (data: UpdateProjectInput, id?: FormProject['id']) => void
|
||||
@@ -29,8 +35,46 @@ 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)
|
||||
const [date, setDate] = useState<Date>(
|
||||
props.project?.date ? new Date(props.project.date) : today
|
||||
)
|
||||
const [month, setMonth] = useState<string>(format(today, 'MMMM yyyy'))
|
||||
const [fileIds, setFileIds] = useState<string[]>(props.project?.images || [])
|
||||
const [selectedTags, setSelectedTags] = useState<FindTags['tags']>(
|
||||
props.project?.tags || []
|
||||
)
|
||||
const [appendUploader, setAppendUploader] = useState<boolean>(false)
|
||||
const [toDelete, setToDelete] = useState<string[]>([])
|
||||
|
||||
const onUploadComplete = (
|
||||
result: UploadResult<Meta, Record<string, never>>
|
||||
) => {
|
||||
setFileIds(
|
||||
appendUploader
|
||||
? [...fileIds, ...result.successful.map((file) => file.uploadURL)]
|
||||
: result.successful.map((file) => file.uploadURL)
|
||||
)
|
||||
setAppendUploader(false)
|
||||
}
|
||||
|
||||
const urlRegex = useMemo(
|
||||
() =>
|
||||
@@ -50,9 +94,22 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
if (errorsExist) return toast.error(`${errorCount} links invalid`)
|
||||
if (emptyCount > 0) return toast.error(`${emptyCount} links empty`)
|
||||
|
||||
data.links = links
|
||||
data.date = new Date().toISOString() // TODO: change to date picker value
|
||||
props.onSave(data, props?.project?.id)
|
||||
batchDelete(toDelete)
|
||||
|
||||
props.onSave(
|
||||
{
|
||||
title: data.title,
|
||||
description: descEditor.getHTML(),
|
||||
date: date.toISOString(),
|
||||
links: links.filter((link) => link.trim().length > 0),
|
||||
images: fileIds,
|
||||
tags: selectedTags.map((tag) => tag.id),
|
||||
removeTags: props.project?.tags
|
||||
.filter((tag) => !selectedTags.some((st) => st.id === tag.id))
|
||||
.map((tag) => tag.id),
|
||||
},
|
||||
props?.project?.id
|
||||
)
|
||||
}
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null)
|
||||
@@ -99,31 +156,138 @@ 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"
|
||||
<RichTextEditor editor={descEditor} />
|
||||
|
||||
<div className="form-control w-full">
|
||||
<Label
|
||||
name="date"
|
||||
className="input input-bordered flex items-center gap-2"
|
||||
errorClassName="input input-bordered flex items-center gap-2 input-error"
|
||||
onClick={() => setPickerVisible(!pickerVisible)}
|
||||
>
|
||||
<Label
|
||||
name="date"
|
||||
className="size-4 opacity-70"
|
||||
errorClassName="size-4 text-error"
|
||||
>
|
||||
<Icon path={mdiCalendar} />
|
||||
</Label>
|
||||
<TextField
|
||||
name="date"
|
||||
ref={titleRef}
|
||||
placeholder="Date"
|
||||
value={format(date, 'PP')}
|
||||
className="w-full"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{pickerVisible && (
|
||||
<div className="flex justify-center">
|
||||
<DatePicker
|
||||
date={date}
|
||||
setDate={setDate}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<FormTextList
|
||||
name="Links"
|
||||
itemPlaceholder="URL"
|
||||
icon={mdiLinkVariant}
|
||||
list={links}
|
||||
errors={linkErrors}
|
||||
setList={setLinks}
|
||||
<div className={`${!pickerVisible && 'pt-2'}`}>
|
||||
<FormTextList
|
||||
name="Links"
|
||||
hint="Short links are recommended"
|
||||
itemPlaceholder="URL"
|
||||
icon={mdiLinkVariant}
|
||||
list={links}
|
||||
errors={linkErrors}
|
||||
setList={setLinks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TagsSelectorCell
|
||||
selectedTags={selectedTags}
|
||||
setSelectedTags={setSelectedTags}
|
||||
/>
|
||||
|
||||
<div className="py-2 space-y-2">
|
||||
<div className="flex space-x-2 justify-between items-center">
|
||||
<p className="font-semibold">Images</p>
|
||||
{fileIds.length > 0 &&
|
||||
(appendUploader ? (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
type="button"
|
||||
onClick={() => setAppendUploader(false)}
|
||||
>
|
||||
Nevermind
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
type="button"
|
||||
onClick={() => setAppendUploader(true)}
|
||||
>
|
||||
Upload More
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{fileIds.length > 0 ? (
|
||||
<>
|
||||
{!appendUploader &&
|
||||
fileIds.map((fileId, i) => (
|
||||
<div key={i} className="flex justify-center">
|
||||
<div className="card rounded-xl w-fit image-full image-full-no-overlay">
|
||||
<figure>
|
||||
<img src={fileId} alt={i.toString()} />
|
||||
</figure>
|
||||
<div className="card-body p-2 rounded-xl">
|
||||
<div className="card-actions rounded-md justify-end">
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{appendUploader && (
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="20rem"
|
||||
height="30rem"
|
||||
className="flex justify-center"
|
||||
maxFiles={10}
|
||||
disabled={props.loading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="20rem"
|
||||
height="30rem"
|
||||
className="flex justify-center pt-3"
|
||||
maxFiles={10}
|
||||
disabled={props.loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAfter(date, today) && (
|
||||
<div className="flex justify-center py-2">
|
||||
<p>Project will be marked as</p>
|
||||
<div className="ml-1 badge badge-info">planned</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="my-2 flex justify-center space-x-2">
|
||||
<Submit
|
||||
disabled={props.loading}
|
||||
|
||||
@@ -1,5 +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,
|
||||
@@ -12,7 +14,9 @@ import type { TypedDocumentNode } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { QUERY } from 'src/components/Project/ProjectsCell'
|
||||
import { calculateLuminance } from 'src/lib/color'
|
||||
import { timeTag, truncate } from 'src/lib/formatters'
|
||||
import { batchDelete } from 'src/lib/tus'
|
||||
|
||||
const DELETE_PROJECT_MUTATION: TypedDocumentNode<
|
||||
DeleteProjectMutation,
|
||||
@@ -34,8 +38,10 @@ const ProjectsList = ({ projects }: FindProjects) => {
|
||||
})
|
||||
|
||||
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => {
|
||||
if (confirm('Are you sure you want to delete project ' + id + '?'))
|
||||
if (confirm('Are you sure you want to delete project ' + id + '?')) {
|
||||
batchDelete(projects.find((project) => project.id === id).images)
|
||||
deleteProject({ variables: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -46,78 +52,117 @@ const ProjectsList = ({ projects }: FindProjects) => {
|
||||
<th>Title</th>
|
||||
<th>Description</th>
|
||||
<th>Date</th>
|
||||
<th>Images</th>
|
||||
<th>Tags</th>
|
||||
<th>Links</th>
|
||||
<th className="w-0"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((project) => {
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Link
|
||||
to={routes.project({ id: project.id })}
|
||||
title={'Show project ' + project.id + ' detail'}
|
||||
className="btn btn-xs uppercase"
|
||||
>
|
||||
Show
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.editProject({ id: project.id })}
|
||||
title={'Edit project ' + project.id}
|
||||
className="btn btn-primary btn-xs uppercase"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title={'Delete projectt ' + project.id}
|
||||
className="btn btn-error btn-xs uppercase"
|
||||
onClick={() => onDeleteClick(project.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
{projects
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1
|
||||
)
|
||||
.map((project) => {
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Link
|
||||
to={routes.adminProject({ id: project.id })}
|
||||
title={'Show project ' + project.id + ' detail'}
|
||||
className="btn btn-xs uppercase"
|
||||
>
|
||||
Show
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.editProject({ id: project.id })}
|
||||
title={'Edit project ' + project.id}
|
||||
className="btn btn-primary btn-xs uppercase"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title={'Delete projectt ' + project.id}
|
||||
className="btn btn-error btn-xs uppercase"
|
||||
onClick={() => onDeleteClick(project.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<tr key={project.id}>
|
||||
<td>{truncate(project.title)}</td>
|
||||
<td>{truncate(project.description)}</td>
|
||||
<td>{timeTag(project.date)}</td>
|
||||
<td className="space-x-2 space-y-2">
|
||||
{project.links.map((link, i) => (
|
||||
<div className="badge badge-ghost text-nowrap" key={i}>
|
||||
{link}
|
||||
return (
|
||||
<tr key={project.id}>
|
||||
<td>{truncate(project.title)}</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>
|
||||
<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>
|
||||
))}
|
||||
</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"
|
||||
/>
|
||||
</td>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
</td>
|
||||
<td>
|
||||
<nav className="hidden justify-end space-x-2 md:flex">
|
||||
{actionButtons}
|
||||
</nav>
|
||||
<div className="dropdown dropdown-end flex justify-end md: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>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,14 @@ export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> =
|
||||
id
|
||||
title
|
||||
description
|
||||
images
|
||||
date
|
||||
links
|
||||
tags {
|
||||
id
|
||||
tag
|
||||
color
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { format, isAfter, startOfToday } from 'date-fns'
|
||||
import parseHtml from 'react-html-parser'
|
||||
import { FindProjects } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import AutoCarousel from 'src/components/AutoCarousel/AutoCarousel'
|
||||
import { calculateLuminance } from 'src/lib/color'
|
||||
|
||||
const CARD_WIDTH = 384
|
||||
|
||||
const ProjectsShowcase = ({ projects }: FindProjects) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [columns, setColumns] = useState<number>(
|
||||
Math.max(
|
||||
Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
|
||||
1
|
||||
)
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const handleResize = () =>
|
||||
setColumns(
|
||||
Math.max(
|
||||
Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
|
||||
1
|
||||
)
|
||||
)
|
||||
|
||||
handleResize()
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex flex-wrap justify-center gap-2">
|
||||
{split(
|
||||
projects
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1
|
||||
),
|
||||
columns
|
||||
).map((projectChunk, i) => (
|
||||
<div className="flex flex-col gap-2" key={i}>
|
||||
{projectChunk.map((project, j) => (
|
||||
<Link key={`${i}-${j}`} to={routes.project({ id: project.id })}>
|
||||
<div className="card card-compact bg-base-100 w-96 h-fit shadow-xl transition-all hover:-translate-y-1 hover:shadow-2xl">
|
||||
{project.images.length > 0 && (
|
||||
<AutoCarousel images={project.images} />
|
||||
)}
|
||||
<div className="card-body">
|
||||
<div className="card-title overflow-auto">
|
||||
<p className="whitespace-nowrap">{project.title}</p>
|
||||
</div>
|
||||
<div className="line-clamp-5">
|
||||
<article className="prose text-sm">
|
||||
{parseHtml(project.description)}
|
||||
</article>
|
||||
</div>
|
||||
<div className="card-actions justify-between">
|
||||
<div className="flex gap-2">
|
||||
{isAfter(new Date(project.date), startOfToday()) && (
|
||||
<div className="badge badge-info">planned</div>
|
||||
)}
|
||||
<div className="badge badge-ghost">
|
||||
{format(project.date, 'yyyy-MM-dd')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="badge whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color:
|
||||
calculateLuminance(tag.color) > 0.5
|
||||
? 'black'
|
||||
: 'white',
|
||||
}}
|
||||
>
|
||||
{tag.tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectsShowcase
|
||||
|
||||
function split<T>(arr: T[], chunks: number): T[][] {
|
||||
const result: T[][] = []
|
||||
const chunkSize = Math.ceil(arr.length / chunks)
|
||||
|
||||
for (let i = 0; i < arr.length; i += chunkSize) {
|
||||
result.push(arr.slice(i, i + chunkSize))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { FindProjects, FindProjectsVariables } from 'types/graphql'
|
||||
|
||||
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'
|
||||
|
||||
export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> =
|
||||
gql`
|
||||
query FindProjects {
|
||||
projects {
|
||||
id
|
||||
title
|
||||
description
|
||||
images
|
||||
date
|
||||
links
|
||||
tags {
|
||||
id
|
||||
tag
|
||||
color
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
export const Empty = () => <CellEmpty />
|
||||
export const Failure = ({ error }: CellFailureProps<FindProjectsVariables>) => (
|
||||
<CellFailure error={error} />
|
||||
)
|
||||
|
||||
export const Success = ({
|
||||
projects,
|
||||
}: CellSuccessProps<FindProjects, FindProjectsVariables>) => (
|
||||
<>
|
||||
<Metadata
|
||||
title="Projects"
|
||||
og={{
|
||||
title: 'Projects',
|
||||
type: 'website',
|
||||
description: `${projects.length} projects`,
|
||||
url: routes.projects(),
|
||||
}}
|
||||
/>
|
||||
<ProjectsShowcase projects={projects} />
|
||||
</>
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AdminFindResume, AdminFindResumeVariables } 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 ResumeForm from 'src/components/Resume/ResumeForm/ResumeForm'
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
AdminFindResume,
|
||||
AdminFindResumeVariables
|
||||
> = gql`
|
||||
query AdminFindResume {
|
||||
resume {
|
||||
id
|
||||
fileId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
export const Empty = () => <CellEmpty />
|
||||
export const Failure = ({
|
||||
error,
|
||||
}: CellFailureProps<AdminFindResumeVariables>) => <CellFailure error={error} />
|
||||
|
||||
export const Success = ({
|
||||
resume,
|
||||
}: CellSuccessProps<AdminFindResume, AdminFindResumeVariables>) =>
|
||||
resume.id === -1 ? <ResumeForm /> : <ResumeForm resume={resume} />
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Resume as ResumeType } from 'types/graphql'
|
||||
|
||||
import PDF from 'src/components/PDF/PDF'
|
||||
|
||||
interface ResumeProps {
|
||||
resume?: ResumeType
|
||||
}
|
||||
|
||||
const Resume = ({ resume }: ResumeProps) => <PDF url={resume?.fileId} />
|
||||
|
||||
export default Resume
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { FindResume, FindResumeVariables } from 'types/graphql'
|
||||
|
||||
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 Resume from 'src/components/Resume/Resume/Resume'
|
||||
|
||||
export const QUERY: TypedDocumentNode<FindResume, FindResumeVariables> = gql`
|
||||
query FindResume {
|
||||
resume {
|
||||
id
|
||||
fileId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
export const Empty = () => <CellEmpty />
|
||||
export const Failure = ({ error }: CellFailureProps<FindResumeVariables>) => (
|
||||
<CellFailure error={error} />
|
||||
)
|
||||
|
||||
export const Success = ({
|
||||
resume,
|
||||
}: CellSuccessProps<FindResume, FindResumeVariables>) => (
|
||||
<>
|
||||
<Metadata
|
||||
title="Contact"
|
||||
og={{
|
||||
title: 'Resume',
|
||||
type: 'website',
|
||||
url: routes.resume(),
|
||||
}}
|
||||
/>
|
||||
<Resume resume={resume} />
|
||||
</>
|
||||
)
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Meta, UploadResult } from '@uppy/core'
|
||||
import type {
|
||||
CreateResumeMutation,
|
||||
CreateResumeMutationVariables,
|
||||
DeleteResumeMutation,
|
||||
DeleteResumeMutationVariables,
|
||||
FindResume,
|
||||
FindResumeVariables,
|
||||
Resume,
|
||||
} from 'types/graphql'
|
||||
|
||||
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'
|
||||
|
||||
interface ResumeFormProps {
|
||||
resume?: Resume
|
||||
}
|
||||
|
||||
export const QUERY: TypedDocumentNode<FindResume, FindResumeVariables> = gql`
|
||||
query ResumeForm {
|
||||
resume {
|
||||
id
|
||||
fileId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DELETE_RESUME_MUTATION: TypedDocumentNode<
|
||||
DeleteResumeMutation,
|
||||
DeleteResumeMutationVariables
|
||||
> = gql`
|
||||
mutation DeleteResumeMutation {
|
||||
deleteResume {
|
||||
id
|
||||
fileId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CREATE_RESUME_MUTATION: TypedDocumentNode<
|
||||
CreateResumeMutation,
|
||||
CreateResumeMutationVariables
|
||||
> = gql`
|
||||
mutation CreateResumeMutation($input: CreateResumeInput!) {
|
||||
createResume(input: $input) {
|
||||
id
|
||||
fileId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ResumeForm = ({ resume }: ResumeFormProps) => {
|
||||
const [fileId, setFileId] = useState<string>(resume?.fileId)
|
||||
|
||||
const unloadAbortController = new AbortController()
|
||||
|
||||
const [deleteResume, { loading: deleteLoading }] = useMutation(
|
||||
DELETE_RESUME_MUTATION,
|
||||
{
|
||||
onCompleted: () => {
|
||||
toast.success('Resume deleted')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
refetchQueries: [{ query: QUERY }],
|
||||
awaitRefetchQueries: true,
|
||||
}
|
||||
)
|
||||
|
||||
const [createResume, { loading: createLoading }] = useMutation(
|
||||
CREATE_RESUME_MUTATION,
|
||||
{
|
||||
onCompleted: () => toast.success('Resume saved'),
|
||||
onError: (error) => toast.error(error.message),
|
||||
refetchQueries: [{ query: QUERY }],
|
||||
awaitRefetchQueries: true,
|
||||
}
|
||||
)
|
||||
|
||||
const onUploadComplete = (
|
||||
result: UploadResult<Meta, Record<string, never>>
|
||||
) => {
|
||||
setFileId(result.successful[0]?.uploadURL)
|
||||
window.addEventListener(
|
||||
'beforeunload',
|
||||
(e) => handleBeforeUnload(e, [fileId]),
|
||||
{
|
||||
once: true,
|
||||
signal: unloadAbortController.signal,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (resume?.fileId)
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
<PDF form url={resume?.fileId} />
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
title="Delete resume"
|
||||
className="btn btn-error btn-sm uppercase"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure?')) {
|
||||
deleteFile(resume?.fileId)
|
||||
deleteResume()
|
||||
setFileId(null)
|
||||
}
|
||||
}}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
else
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
{!fileId ? (
|
||||
<>
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="22rem"
|
||||
height="11.5rem"
|
||||
className="flex justify-center"
|
||||
type="pdf"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<PDF form url={fileId} />
|
||||
)}
|
||||
{fileId && (
|
||||
<div className="flex justify-center space-x-2">
|
||||
<button
|
||||
className={`btn btn-sm ${!fileId && 'btn-disabled'} uppercase`}
|
||||
disabled={!fileId || deleteLoading}
|
||||
onClick={() => {
|
||||
deleteFile(fileId)
|
||||
setFileId(null)
|
||||
unloadAbortController.abort()
|
||||
console.log('aborted')
|
||||
}}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-primary btn-sm ${
|
||||
!fileId && 'btn-disabled'
|
||||
} uppercase`}
|
||||
disabled={!fileId || createLoading}
|
||||
onClick={() => {
|
||||
createResume({
|
||||
variables: {
|
||||
input: {
|
||||
fileId,
|
||||
},
|
||||
},
|
||||
})
|
||||
unloadAbortController.abort()
|
||||
console.log('aborted')
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResumeForm
|
||||
@@ -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
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
mdiRename,
|
||||
mdiAt,
|
||||
mdiLinkVariant,
|
||||
mdiPound,
|
||||
mdiAccountPlus,
|
||||
} from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import type { EditSocialById, UpdateSocialInput } from 'types/graphql'
|
||||
@@ -14,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']>
|
||||
|
||||
@@ -32,13 +34,34 @@ const types: FormSocial['type'][] = [
|
||||
'facebook',
|
||||
'tiktok',
|
||||
'youtube',
|
||||
'steam',
|
||||
'discord',
|
||||
'twitch',
|
||||
'linkedin',
|
||||
'matrix',
|
||||
'github',
|
||||
'gitea',
|
||||
'forgejo',
|
||||
'gitlab',
|
||||
'bitbucket',
|
||||
'leetcode',
|
||||
'email',
|
||||
'phone',
|
||||
'custom',
|
||||
]
|
||||
|
||||
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'
|
||||
)
|
||||
@@ -76,7 +99,7 @@ const SocialForm = (props: SocialFormProps) => {
|
||||
<Form<FormSocial>
|
||||
onSubmit={onSubmit}
|
||||
error={props.error}
|
||||
className="h-96 max-w-80 space-y-2"
|
||||
className="h-128 max-w-80 space-y-2"
|
||||
>
|
||||
<Label name="name" className="form-control w-full">
|
||||
<Label
|
||||
@@ -128,16 +151,28 @@ const SocialForm = (props: SocialFormProps) => {
|
||||
path={
|
||||
type == 'email'
|
||||
? mdiAt
|
||||
: type == 'custom'
|
||||
? mdiLinkVariant
|
||||
: mdiAccount
|
||||
: type == 'phone'
|
||||
? mdiPound
|
||||
: urlHandles.includes(type)
|
||||
? mdiLinkVariant
|
||||
: type == 'discord'
|
||||
? mdiAccountPlus
|
||||
: mdiAccount
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<TextField
|
||||
name="username"
|
||||
placeholder={
|
||||
type == 'custom' ? 'URL' : type == 'email' ? 'Email' : 'Username'
|
||||
urlHandles.includes(type)
|
||||
? 'URL'
|
||||
: type == 'phone'
|
||||
? 'Phone'
|
||||
: type == 'email'
|
||||
? 'Email'
|
||||
: type == 'discord'
|
||||
? 'Invite Code'
|
||||
: 'Username'
|
||||
}
|
||||
className="w-full"
|
||||
defaultValue={username}
|
||||
@@ -152,11 +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}$/
|
||||
: type == 'custom' &&
|
||||
/^(?:(?:(?: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,
|
||||
? emailRegex
|
||||
: type == 'phone'
|
||||
? phoneRegex
|
||||
: urlHandles.includes(type)
|
||||
? urlRegex
|
||||
: type == 'matrix' && matrixRegex,
|
||||
message: `Invalid ${
|
||||
type == 'custom' ? 'URL' : type == 'email' && 'Email'
|
||||
urlHandles.includes(type)
|
||||
? 'URL'
|
||||
: type == 'phone'
|
||||
? 'phone number'
|
||||
: type == 'email'
|
||||
? 'Email'
|
||||
: type == 'matrix' && 'Matrix identifier'
|
||||
}`,
|
||||
},
|
||||
}}
|
||||
@@ -167,9 +211,14 @@ const SocialForm = (props: SocialFormProps) => {
|
||||
name="username"
|
||||
className="text-xs font-semibold text-error"
|
||||
/>
|
||||
{type !== 'custom' && type !== 'email' && (
|
||||
<span className="label-text-alt">{`${baseUrls[type]}${username}`}</span>
|
||||
{type == 'phone' && (
|
||||
<span className="label-text-alt">Format: +1 555-555-5555</span>
|
||||
)}
|
||||
{!urlHandles.includes(type) &&
|
||||
type !== 'phone' &&
|
||||
type !== 'email' && (
|
||||
<span className="label-text-alt">{`${baseUrls[type]}${username}`}</span>
|
||||
)}
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
@@ -197,19 +246,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 { ContactCardPortrait } 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 }: ContactCardPortrait) => (
|
||||
<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
|
||||
|
||||
@@ -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} />
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
@@ -65,7 +65,7 @@ const Tag = ({ tag }: Props) => {
|
||||
<th>Color</th>
|
||||
<td>
|
||||
<div
|
||||
className="badge"
|
||||
className="badge whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color:
|
||||
|
||||
@@ -96,7 +96,7 @@ const TagsList = ({ tags }: FindTags) => {
|
||||
<td>{truncate(tag.tag)}</td>
|
||||
<td>
|
||||
<div
|
||||
className="badge"
|
||||
className="badge whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color:
|
||||
@@ -137,55 +137,6 @@ const TagsList = ({ tags }: FindTags) => {
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
// return (
|
||||
// <div className="rw-segment rw-table-wrapper-responsive">
|
||||
// <table className="rw-table">
|
||||
// <thead>
|
||||
// <tr>
|
||||
// <th>Id</th>
|
||||
// <th>Tag</th>
|
||||
// <th>Color</th>
|
||||
// <th> </th>
|
||||
// </tr>
|
||||
// </thead>
|
||||
// <tbody>
|
||||
// {tags.map((tag) => (
|
||||
// <tr key={tag.id}>
|
||||
// <td>{truncate(tag.id)}</td>
|
||||
// <td>{truncate(tag.tag)}</td>
|
||||
// <td>{truncate(tag.color)}</td>
|
||||
// <td>
|
||||
// <nav className="rw-table-actions">
|
||||
// <Link
|
||||
// to={routes.tag({ id: tag.id })}
|
||||
// title={'Show tag ' + tag.id + ' detail'}
|
||||
// className="rw-button rw-button-small"
|
||||
// >
|
||||
// Show
|
||||
// </Link>
|
||||
// <Link
|
||||
// to={routes.editTag({ id: tag.id })}
|
||||
// title={'Edit tag ' + tag.id}
|
||||
// className="rw-button rw-button-small rw-button-blue"
|
||||
// >
|
||||
// Edit
|
||||
// </Link>
|
||||
// <button
|
||||
// type="button"
|
||||
// title={'Delete tag ' + tag.id}
|
||||
// className="rw-button rw-button-small rw-button-red"
|
||||
// onClick={() => onDeleteClick(tag.id)}
|
||||
// >
|
||||
// Delete
|
||||
// </button>
|
||||
// </nav>
|
||||
// </td>
|
||||
// </tr>
|
||||
// ))}
|
||||
// </tbody>
|
||||
// </table>
|
||||
// </div>
|
||||
// )
|
||||
}
|
||||
|
||||
export default TagsList
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { FindTags } from 'types/graphql'
|
||||
|
||||
import { calculateLuminance } from 'src/lib/color'
|
||||
|
||||
interface TagsSelectorProps {
|
||||
selectedTags: FindTags['tags']
|
||||
setSelectedTags: React.Dispatch<React.SetStateAction<FindTags['tags']>>
|
||||
}
|
||||
|
||||
const TagsSelector = ({
|
||||
tags: _tags,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
}: FindTags & TagsSelectorProps) => {
|
||||
const [tags, setTags] = useState<FindTags['tags']>(
|
||||
_tags.filter((tag) => !selectedTags.some((t) => t.id === tag.id))
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const newTags = _tags.filter(
|
||||
(tag) => !selectedTags.some((t) => t.id === tag.id)
|
||||
)
|
||||
setTags(newTags)
|
||||
}, [selectedTags, _tags])
|
||||
|
||||
return (
|
||||
<div className="w-80 space-y-2">
|
||||
{tags.length > 0 && (
|
||||
<>
|
||||
<p className="font-semibold">Tags</p>
|
||||
<div className="flex flex-wrap gap-2 ">
|
||||
{tags.map((tag, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="badge active:scale-95"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color:
|
||||
calculateLuminance(tag.color) > 0.5 ? 'black' : 'white',
|
||||
}}
|
||||
onClick={() => setSelectedTags([...selectedTags, tag])}
|
||||
>
|
||||
{tag.tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{selectedTags.length > 0 && (
|
||||
<>
|
||||
<p className="font-semibold">Selected</p>
|
||||
<div className="flex flex-wrap gap-2 ">
|
||||
{selectedTags.map((tag, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="badge active:scale-95"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color:
|
||||
calculateLuminance(tag.color) > 0.5 ? 'black' : 'white',
|
||||
}}
|
||||
onClick={() =>
|
||||
setSelectedTags(selectedTags.filter((t) => t.id !== tag.id))
|
||||
}
|
||||
>
|
||||
{tag.tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagsSelector
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { FindTags, FindTagsVariables } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import type {
|
||||
CellSuccessProps,
|
||||
CellFailureProps,
|
||||
TypedDocumentNode,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
|
||||
import TagsSelector from '../TagsSelector/TagsSelector'
|
||||
|
||||
export const QUERY: TypedDocumentNode<FindTags, FindTagsVariables> = gql`
|
||||
query FindTags {
|
||||
tags {
|
||||
id
|
||||
tag
|
||||
color
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface TagsSelectorCellProps {
|
||||
selectedTags: FindTags['tags']
|
||||
setSelectedTags: React.Dispatch<React.SetStateAction<FindTags['tags']>>
|
||||
}
|
||||
|
||||
export const beforeQuery = (props: TagsSelectorCellProps) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { selectedTags, setSelectedTags } = props
|
||||
|
||||
return {
|
||||
variables: {
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
}
|
||||
}
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
export const Empty = () => (
|
||||
<div className="w-80 space-y-2">
|
||||
<p className="font-semibold">Tags</p>
|
||||
<p className="font-normal opacity-60">
|
||||
No tags yet,{' '}
|
||||
<Link
|
||||
className="link link-primary link-hover"
|
||||
target="_blank"
|
||||
to={routes.newTag()}
|
||||
>
|
||||
create one?
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
export const Failure = ({ error }: CellFailureProps<FindTags>) => (
|
||||
<CellFailure error={error} />
|
||||
)
|
||||
export const Success = ({
|
||||
tags,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
}: CellSuccessProps<FindTags, FindTagsVariables> & TagsSelectorCellProps) => (
|
||||
<TagsSelector
|
||||
selectedTags={selectedTags}
|
||||
setSelectedTags={setSelectedTags}
|
||||
tags={tags}
|
||||
/>
|
||||
)
|
||||
@@ -3,14 +3,17 @@ import { useState, useEffect } from 'react'
|
||||
import { mdiWeatherSunny, mdiWeatherNight } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
const LIGHT_THEME = 'light'
|
||||
const DARK_THEME = 'dark'
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const [theme, setTheme] = useState(
|
||||
localStorage.getItem('theme') ? localStorage.getItem('theme') : 'light'
|
||||
localStorage.getItem('theme') ?? LIGHT_THEME
|
||||
)
|
||||
|
||||
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) setTheme('dark')
|
||||
else setTheme('light')
|
||||
if (e.target.checked) setTheme(DARK_THEME)
|
||||
else setTheme(LIGHT_THEME)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,16 +34,11 @@ const ThemeToggle = () => {
|
||||
<input
|
||||
type="checkbox"
|
||||
className="theme-controller"
|
||||
checked={theme === 'dark'}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AdminTitlesQuery, AdminTitlesQueryVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
CellSuccessProps,
|
||||
CellFailureProps,
|
||||
TypedDocumentNode,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
|
||||
import TitlesForm from '../TitlesForm/TitlesForm'
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
AdminTitlesQuery,
|
||||
AdminTitlesQueryVariables
|
||||
> = gql`
|
||||
query AdminTitlesQuery {
|
||||
titles {
|
||||
id
|
||||
titles
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
export const Failure = ({
|
||||
error,
|
||||
}: 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>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ReactTyped } from 'react-typed'
|
||||
|
||||
interface TitlesProps {
|
||||
titles: string[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Titles = ({ titles, className }: 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { TitlesQuery, TitlesQueryVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
CellSuccessProps,
|
||||
CellFailureProps,
|
||||
TypedDocumentNode,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
|
||||
import { Titles } from '../Titles/Titles'
|
||||
|
||||
export const QUERY: TypedDocumentNode<TitlesQuery, TitlesQueryVariables> = gql`
|
||||
query TitlesQuery {
|
||||
titles {
|
||||
titles
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface TitleProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const beforeQuery = ({ className = '' }: TitleProps) => {
|
||||
return {
|
||||
variables: {
|
||||
className,
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
}
|
||||
}
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
export const Failure = ({ error }: CellFailureProps<TitlesQueryVariables>) => (
|
||||
<CellFailure error={error} />
|
||||
)
|
||||
|
||||
export const Success = ({
|
||||
titles: { titles },
|
||||
className = '',
|
||||
}: CellSuccessProps<TitlesQuery> & TitleProps) => (
|
||||
<Titles className={className} titles={titles} />
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { mdiFormatTitle } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import { Titles, UpdateTitlesInput } from 'types/graphql'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const UPDATE_TITLES_MUTATION: TypedDocumentNode<UpdateTitlesInput> = gql`
|
||||
mutation UpdateTitlesMutation($input: UpdateTitlesInput!) {
|
||||
updateTitles(input: $input) {
|
||||
titles
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
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(), [])
|
||||
|
||||
const [updateTitles, { loading: updateLoading }] = useMutation(
|
||||
UPDATE_TITLES_MUTATION,
|
||||
{
|
||||
onCompleted: () => toast.success('Titles saved'),
|
||||
onError: (error) => toast.error(error.message),
|
||||
}
|
||||
)
|
||||
|
||||
const onSubmit = (data: Record<string, string>) =>
|
||||
updateTitles({
|
||||
variables: {
|
||||
input: {
|
||||
titles: Object.values(data).map((value) => value),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
name={`title${i}`}
|
||||
className="input input-bordered flex items-center gap-2"
|
||||
errorClassName="input input-bordered flex items-center gap-2 input-error"
|
||||
>
|
||||
<Label
|
||||
name={`title${i}`}
|
||||
className="size-4 opacity-70"
|
||||
errorClassName="size-4 text-error"
|
||||
>
|
||||
<Icon path={mdiFormatTitle} />
|
||||
</Label>
|
||||
<TextField
|
||||
name={`title${i}`}
|
||||
ref={i === 0 ? title1ref : null}
|
||||
placeholder={`Title ${i + 1}`}
|
||||
defaultValue={states[i][0]}
|
||||
autoComplete="off"
|
||||
onChange={(e) => states[i][1](e.target.value)}
|
||||
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>
|
||||
))}
|
||||
|
||||
<nav className="my-2 flex justify-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm uppercase"
|
||||
onClick={() => setPreview(!preview)}
|
||||
>
|
||||
{preview ? 'Hide' : 'Show'} Preview
|
||||
</button>
|
||||
<Submit
|
||||
disabled={updateLoading}
|
||||
className="btn btn-primary btn-sm uppercase"
|
||||
>
|
||||
Save
|
||||
</Submit>
|
||||
</nav>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitlesForm
|
||||
@@ -5,11 +5,15 @@ 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'
|
||||
|
||||
interface Props {
|
||||
onComplete?(result: UploadResult<Meta, Record<string, never>>): void
|
||||
@@ -19,6 +23,7 @@ interface Props {
|
||||
maxFiles?: number
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
type?: FileType
|
||||
}
|
||||
|
||||
const apiDomain = isProduction
|
||||
@@ -33,16 +38,15 @@ const Uploader = ({
|
||||
disabled = false,
|
||||
hidden = false,
|
||||
maxFiles = 1,
|
||||
type = 'image',
|
||||
}: Props) => {
|
||||
const [uppy] = useState(() => {
|
||||
const instance = new Uppy({
|
||||
restrictions: {
|
||||
allowedFileTypes: [
|
||||
'image/webp',
|
||||
'image/png',
|
||||
'image/jpg',
|
||||
'image/jpeg',
|
||||
],
|
||||
allowedFileTypes:
|
||||
type === 'image'
|
||||
? ['image/webp', 'image/png', 'image/jpg', 'image/jpeg']
|
||||
: type === 'pdf' && ['application/pdf'],
|
||||
maxNumberOfFiles: maxFiles,
|
||||
maxFileSize: 25 * 1024 * 1024,
|
||||
},
|
||||
@@ -67,6 +71,11 @@ const Uploader = ({
|
||||
mimeType: 'image/webp',
|
||||
})
|
||||
|
||||
if (type === 'image')
|
||||
instance.use(Webcam, {
|
||||
modes: ['picture'],
|
||||
})
|
||||
|
||||
return instance.on('complete', onComplete)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hasFlag } from 'country-flag-icons'
|
||||
import { hydrateRoot, createRoot } from 'react-dom/client'
|
||||
|
||||
import App from 'src/App'
|
||||
@@ -15,6 +16,11 @@ if (!redwoodAppElement)
|
||||
"exists in your 'web/src/index.html' file."
|
||||
)
|
||||
|
||||
if (!hasFlag(process.env.COUNTRY))
|
||||
throw new Error(
|
||||
'Invalid COUNTRY environment variable, please select a valid ISO-3166-1 alpha-2 country code\n See https://en.wikipedia.org/wiki/ISO_3166-1#Codes'
|
||||
)
|
||||
|
||||
if (redwoodAppElement.children?.length > 0)
|
||||
hydrateRoot(redwoodAppElement, <App />)
|
||||
else {
|
||||
|
||||
@@ -15,3 +15,11 @@
|
||||
.w-52 .react-colorful {
|
||||
width: 13rem;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -20,6 +20,14 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
const { isAuthenticated, logOut } = useAuth()
|
||||
|
||||
const navbarRoutes: NavbarRoute[] = [
|
||||
{
|
||||
name: 'Projects',
|
||||
path: routes.projects(),
|
||||
},
|
||||
{
|
||||
name: 'Resume',
|
||||
path: routes.resume(),
|
||||
},
|
||||
{
|
||||
name: 'Contact',
|
||||
path: routes.contact(),
|
||||
@@ -33,7 +41,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
},
|
||||
{
|
||||
name: 'Projects',
|
||||
path: routes.projects(),
|
||||
path: routes.adminProjects(),
|
||||
},
|
||||
{
|
||||
name: 'Tags',
|
||||
@@ -43,6 +51,14 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
name: 'Portrait',
|
||||
path: routes.portrait(),
|
||||
},
|
||||
{
|
||||
name: 'Titles',
|
||||
path: routes.titles(),
|
||||
},
|
||||
{
|
||||
name: 'Resume',
|
||||
path: routes.adminResume(),
|
||||
},
|
||||
]
|
||||
|
||||
const navbarButtons = () =>
|
||||
@@ -54,18 +70,16 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
|
||||
const navbarAdminButtons = () =>
|
||||
navbarAdminRoutes.map((route, i) => (
|
||||
<li key={i}>
|
||||
<Link to={route.path} className="btn btn-ghost btn-sm">
|
||||
{route.name}
|
||||
</Link>
|
||||
</li>
|
||||
<Link key={i} to={route.path} className="btn btn-ghost btn-sm">
|
||||
{route.name}
|
||||
</Link>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
@@ -78,31 +92,24 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
<div
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content -ml-2 mt-4 w-36 space-y-2 rounded-box bg-base-200 shadow-xl last:space-y-0"
|
||||
className="menu dropdown-content -ml-2 mt-4 w-36 gap-2 rounded-box bg-base-200 shadow-xl"
|
||||
>
|
||||
{isAuthenticated && (
|
||||
<p className="btn btn-active no-animation btn-sm btn-block">
|
||||
Public
|
||||
</p>
|
||||
)}
|
||||
{navbarButtons()}
|
||||
{isAuthenticated && (
|
||||
<div className="dropdown sm:hidden">
|
||||
<div
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content -ml-2 mt-4 w-36 space-y-2 rounded-box bg-base-200 shadow-xl"
|
||||
>
|
||||
<p className="btn btn-active no-animation btn-sm btn-block">
|
||||
Admin
|
||||
</p>
|
||||
|
||||
{navbarAdminButtons()}
|
||||
<li>
|
||||
<button
|
||||
onClick={logOut}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<p className="btn btn-active no-animation btn-sm btn-block">
|
||||
Admin
|
||||
</p>
|
||||
{navbarAdminButtons()}
|
||||
<button onClick={logOut} className="btn btn-error btn-sm">
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +117,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
to={routes.home()}
|
||||
className="btn btn-ghost hidden font-syne text-xl sm:flex"
|
||||
>
|
||||
{process.env.NAME}
|
||||
{process.env.FIRST_NAME + ' ' + process.env.LAST_NAME}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="navbar-center">
|
||||
@@ -118,7 +125,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
to={routes.home()}
|
||||
className="btn btn-ghost font-syne text-xl sm:hidden"
|
||||
>
|
||||
{process.env.NAME}
|
||||
{process.env.FIRST_NAME + ' ' + process.env.LAST_NAME}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="navbar-center hidden lg:flex">
|
||||
@@ -126,7 +133,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
</div>
|
||||
<div className="navbar-end space-x-2">
|
||||
{isAuthenticated && (
|
||||
<div className="hidden space-x-2 sm:flex">
|
||||
<div className="hidden space-x-2 lg:flex">
|
||||
<button className="btn btn-square btn-ghost" onClick={logOut}>
|
||||
<Icon path={mdiLogout} className="size-8" />
|
||||
</button>
|
||||
@@ -141,7 +148,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
<ul
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content -ml-8 mt-4 w-36 space-y-2 rounded-box bg-base-200 shadow-xl"
|
||||
className="menu dropdown-content -ml-8 mt-4 w-36 gap-2 rounded-box bg-base-200 shadow-xl"
|
||||
>
|
||||
{navbarAdminButtons()}
|
||||
</ul>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
import { format } from 'date-fns'
|
||||
import humanize from 'humanize-string'
|
||||
|
||||
const MAX_STRING_LENGTH = 150
|
||||
@@ -41,11 +42,12 @@ export const jsonTruncate = (obj: unknown) => {
|
||||
|
||||
export const timeTag = (dateTime?: string) => {
|
||||
let output: string | JSX.Element = ''
|
||||
const date = new Date(dateTime)
|
||||
|
||||
if (dateTime) {
|
||||
output = (
|
||||
<time dateTime={dateTime} title={dateTime}>
|
||||
{new Date(dateTime).toUTCString()}
|
||||
{format(date, 'PPpp')}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
|
||||
+62
-6
@@ -8,22 +8,41 @@ import {
|
||||
SiTiktok,
|
||||
SiYoutube,
|
||||
SiLinkedin,
|
||||
SiMatrix,
|
||||
SiGithub,
|
||||
SiGitea,
|
||||
SiLeetcode,
|
||||
SiBitbucket,
|
||||
SiDiscord,
|
||||
SiForgejo,
|
||||
SiGitlab,
|
||||
SiSteam,
|
||||
SiTwitch,
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
import { mdiEmail, mdiLink } from '@mdi/js'
|
||||
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/',
|
||||
facebook: 'https://www.facebook.com/',
|
||||
github: 'https://github.com/',
|
||||
instagram: 'https://www.instagram.com/',
|
||||
linkedin: 'https://www.linkedin.com/in/',
|
||||
threads: 'https://www.threads.net/@',
|
||||
instagram: 'https://www.instagram.com/',
|
||||
facebook: 'https://www.facebook.com/',
|
||||
tiktok: 'https://www.tiktok.com/@',
|
||||
youtube: 'https://www.youtube.com/@',
|
||||
steam: 'https://steamcommunity.com/id/',
|
||||
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: '',
|
||||
gitlab: 'https://gitlab.com/',
|
||||
bitbucket: 'https://bitbucket.org/',
|
||||
leetcode: 'https://leetcode.com/u/',
|
||||
email: 'mailto:',
|
||||
phone: 'tel:',
|
||||
custom: '',
|
||||
}
|
||||
|
||||
@@ -38,12 +57,49 @@ const logoComponents: Record<Handle, ReactElement> = {
|
||||
),
|
||||
tiktok: <SiTiktok className="text-tiktok-light dark:text-tiktok-dark" />,
|
||||
youtube: <SiYoutube className="text-youtube-light dark:text-youtube-dark" />,
|
||||
steam: <SiSteam className="text-steam-light dark:text-steam-dark" />,
|
||||
discord: <SiDiscord className="text-discord-light dark:text-discord-dark" />,
|
||||
twitch: <SiTwitch className="text-twitch-light dark:text-twitch-dark" />,
|
||||
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" />,
|
||||
gitlab: <SiGitlab className="text-gitlab-light dark:text-gitlab-dark" />,
|
||||
bitbucket: (
|
||||
<SiBitbucket className="text-bitbucket-light dark:text-bitbucket-dark" />
|
||||
),
|
||||
leetcode: (
|
||||
<SiLeetcode className="text-leetcode-light dark:text-leetcode-dark" />
|
||||
),
|
||||
email: <Icon path={mdiEmail} className="size-7" />,
|
||||
phone: <Icon path={mdiPhone} className="size-7" />,
|
||||
custom: <Icon path={mdiLink} className="size-7" />,
|
||||
}
|
||||
|
||||
export const sortOrder: Social['type'][] = [
|
||||
'phone',
|
||||
'email',
|
||||
'custom',
|
||||
'matrix',
|
||||
'linkedin',
|
||||
'leetcode',
|
||||
'github',
|
||||
'gitea',
|
||||
'forgejo',
|
||||
'gitlab',
|
||||
'bitbucket',
|
||||
'youtube',
|
||||
'x',
|
||||
'instagram',
|
||||
'tiktok',
|
||||
'facebook',
|
||||
'threads',
|
||||
'twitch',
|
||||
'discord',
|
||||
'steam',
|
||||
]
|
||||
|
||||
export const getLogoComponent = (type: Handle) => logoComponents[type]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const deleteFile = async (fileId: string) => {
|
||||
await fetch(fileId, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Tus-Resumable': '1.0.0',
|
||||
},
|
||||
keepalive: true,
|
||||
})
|
||||
}
|
||||
|
||||
export const handleBeforeUnload = (_e: BeforeUnloadEvent, files: string[]) =>
|
||||
batchDelete(files)
|
||||
|
||||
export const batchDelete = (files: string[]) => {
|
||||
for (const file of files) deleteFile(file)
|
||||
|
||||
if (navigator.userAgent.match(/firefox|fxios/i)) {
|
||||
const firefoxVer = Number(navigator.userAgent.match(/Firefox\/(\d+)/)[1])
|
||||
|
||||
// One day dom.fetchKeepalive.enabled becomes true by default... until then!
|
||||
if (firefoxVer < 129) {
|
||||
const time = Date.now()
|
||||
|
||||
while (Date.now() - time < 500) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(100vh-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 mx-auto">Submit</Submit>
|
||||
<Submit className="btn btn-primary btn-sm mx-auto uppercase">
|
||||
Submit
|
||||
</Submit>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,64 @@
|
||||
import { mdiCompass, mdiContacts } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import getUnicodeFlagIcon from 'country-flag-icons/unicode'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
const HomePage = () => {
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Home" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
import TitlesCell from 'src/components/Title/TitlesCell'
|
||||
import { getLogoComponent } from 'src/lib/handle'
|
||||
|
||||
const HomePage = () => (
|
||||
<>
|
||||
<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-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>
|
||||
<div className="fixed bottom-2 right-2 z-10">
|
||||
<p className="btn btn-square text-xl">
|
||||
{getUnicodeFlagIcon(process.env.COUNTRY)}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import AdminProjectCell from 'src/components/Project/AdminProjectCell/AdminProjectCell'
|
||||
|
||||
type ProjectPageProps = {
|
||||
id: number
|
||||
}
|
||||
|
||||
const ProjectPage = ({ id }: ProjectPageProps) => (
|
||||
<>
|
||||
<Metadata title={`Project ${id}`} />
|
||||
<AdminProjectCell id={id} />
|
||||
</>
|
||||
)
|
||||
|
||||
export default ProjectPage
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ProjectsCell from 'src/components/Project/ProjectsCell'
|
||||
|
||||
const ProjectsPage = () => (
|
||||
<>
|
||||
<Metadata title="Projects" />
|
||||
<ProjectsCell />
|
||||
</>
|
||||
)
|
||||
|
||||
export default ProjectsPage
|
||||
@@ -1,16 +1,11 @@
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ProjectCell from 'src/components/Project/ProjectCell'
|
||||
|
||||
type ProjectPageProps = {
|
||||
interface ProjectPageProps {
|
||||
id: number
|
||||
}
|
||||
|
||||
const ProjectPage = ({ id }: ProjectPageProps) => (
|
||||
<>
|
||||
<Metadata title={`Project ${id}`} />
|
||||
<ProjectCell id={id} />
|
||||
</>
|
||||
)
|
||||
const ProjectPage = ({ id }: ProjectPageProps) => {
|
||||
return <ProjectCell id={id} />
|
||||
}
|
||||
|
||||
export default ProjectPage
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
import mobile from 'is-mobile'
|
||||
|
||||
import ProjectsCell from 'src/components/Project/ProjectsCell'
|
||||
import ProjectsShowcaseCell from 'src/components/Project/ProjectsShowcaseCell'
|
||||
|
||||
const ProjectsPage = () => (
|
||||
<>
|
||||
<Metadata title="Projects" />
|
||||
<ProjectsCell />
|
||||
</>
|
||||
)
|
||||
const ProjectsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{mobile({
|
||||
tablet: true,
|
||||
})
|
||||
? 'Tap'
|
||||
: 'Click'}{' '}
|
||||
on a project for details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectsShowcaseCell />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectsPage
|
||||
|
||||
@@ -102,7 +102,7 @@ const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
|
||||
|
||||
<div className="flex w-full">
|
||||
<Submit
|
||||
className={`btn btn-primary mx-auto ${
|
||||
className={`btn btn-primary btn-sm uppercase mx-auto ${
|
||||
!enabled ? 'btn-disabled' : ''
|
||||
}`}
|
||||
disabled={!enabled}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user