Compare commits
58 Commits
430a2da835
...
v1.1.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
ef60832bc2
|
|||
|
4f782560de
|
|||
|
979cf7320e
|
|||
|
1f9f11e1be
|
|||
|
1d183c37f8
|
|||
|
3aeec4d23e
|
|||
|
debfcf7226
|
|||
|
15bbc27238
|
|||
|
16bd44c599
|
|||
|
d13b16c032
|
|||
|
d144f7385b
|
|||
|
0283c293ef
|
|||
| 8d75849c55 | |||
| 58b44dddad | |||
| c9de531389 | |||
| d48cfe12f2 | |||
| 3a9cc20f86 | |||
| 54a34ef5ee | |||
| f097d7761d | |||
| 03717113f4 | |||
| 284a4c5520 | |||
| f03faabbee | |||
| 353fb3899e | |||
| 62ce137bcb | |||
| f8987b08da | |||
| cbf75acbeb | |||
| 7973663b2a | |||
| 6540329f36 | |||
| bac5b5fe48 | |||
| f3f75d3e57 | |||
| f14732cdf0 | |||
| 77db153fe6 | |||
| 684d6f88c2 | |||
| 03f606bbde | |||
| 1eafaee2c0 | |||
| b8063e8692 | |||
| 738260f7de | |||
| 82313bef46 | |||
| 74db2e1034 | |||
| e2dfb6f237 | |||
| 708634fa68 | |||
| 6873c5c026 | |||
| 1b7e79c765 | |||
| 6e401cf2b3 | |||
| b89a5ee1b8 | |||
| 3c2b944bf4 | |||
| 11783069a8 | |||
| 835d895fc0 | |||
| 73ec75c167 | |||
| 49c943c9f3 | |||
| fb542bb5b5 | |||
| e5f9bbd462 | |||
| 8671f47e91 | |||
| 4a94b6807e | |||
| 9c0dee7d54 | |||
| 38168db452 | |||
| c9227cf9b9 | |||
| 5c41588249 |
Regular → Executable
Regular → Executable
@@ -1,34 +0,0 @@
|
||||
# These environment variables will be used by default if you do not create any
|
||||
# yourself in .env. This file should be safe to check into your version control
|
||||
# system. Any custom values should go in .env and .env should *not* be checked
|
||||
# into version control.
|
||||
|
||||
# location of the test database for api service scenarios (defaults to ./.redwood/test.db if not set)
|
||||
# TEST_DATABASE_URL=file:./.redwood/test.db
|
||||
|
||||
# disables Prisma CLI update notifier
|
||||
PRISMA_HIDE_UPDATE_MESSAGE=true
|
||||
|
||||
# Option to override the current environment's default api-side log level
|
||||
# See: https://redwoodjs.com/docs/logger for level options, defaults to "trace" otherwise.
|
||||
# Most applications want "debug" or "info" during dev, "trace" when you have issues and "warn" in production.
|
||||
# Ordered by how verbose they are: trace | debug | info | warn | error | silent
|
||||
# LOG_LEVEL=debug
|
||||
|
||||
NAME=Ahmed Al-Taiar
|
||||
|
||||
GMAIL=example@gmail.com
|
||||
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
|
||||
|
||||
DOMAIN=example.com
|
||||
API_DOMAIN=api.example.com
|
||||
|
||||
# Must not end with "/"
|
||||
ADDRESS_PROD=https://example.com
|
||||
ADDRESS_DEV=http://localhost:8910
|
||||
API_ADDRESS_PROD=https://api.example.com
|
||||
API_ADDRESS_DEV=http://localhost:8911
|
||||
|
||||
MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/rw_portfolio
|
||||
Regular → Executable
+17
-5
@@ -3,18 +3,30 @@
|
||||
# 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
|
||||
DEFAULT_THEME=light
|
||||
|
||||
COUNTRY=US
|
||||
STATE=New York
|
||||
CITY=Manhattan
|
||||
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=noreply@example.com
|
||||
EMAIL_FROM=noreply@example.com
|
||||
EMAIL_TO=email@example.com
|
||||
SMTP_PASSWORD=password
|
||||
|
||||
DOMAIN=example.com
|
||||
API_DOMAIN=api.example.com
|
||||
|
||||
# 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
|
||||
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
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 --build-arg APP_VERSION=${{ gitea.ref_name }} -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
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+52
-23
@@ -1,15 +1,13 @@
|
||||
# base
|
||||
# ----
|
||||
FROM node:20-bookworm-slim as base
|
||||
FROM node:lts-alpine AS base
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
# We tried to make the Dockerfile as lean as possible. In some cases, that means we excluded a dependency your project needs.
|
||||
# By far the most common is Python. If you're running into build errors because `python3` isn't available,
|
||||
# add `python3 make gcc \` before the `openssl \` line below and in other stages as necessary:
|
||||
RUN apt-get update && apt-get install -y \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add openssl
|
||||
|
||||
USER node
|
||||
WORKDIR /home/node/app
|
||||
@@ -29,51 +27,78 @@ RUN --mount=type=cache,target=/home/node/.yarn/berry/cache,uid=1000 \
|
||||
|
||||
COPY --chown=node:node redwood.toml .
|
||||
COPY --chown=node:node graphql.config.js .
|
||||
COPY --chown=node:node .env.defaults .env.defaults
|
||||
|
||||
# api build
|
||||
# ---------
|
||||
FROM base as api_build
|
||||
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
|
||||
ARG APP_VERSION
|
||||
|
||||
COPY --chown=node:node api api
|
||||
RUN yarn rw build api
|
||||
|
||||
# web prerender build
|
||||
# -------------------
|
||||
FROM api_build as web_build_with_prerender
|
||||
FROM api_build AS web_build_with_prerender
|
||||
|
||||
ARG NAME
|
||||
ARG FIRST_NAME
|
||||
ARG LAST_NAME
|
||||
ARG COUNTRY
|
||||
ARG STATE
|
||||
ARG CITY
|
||||
ARG DEFAULT_THEME
|
||||
ARG API_ADDRESS_PROD
|
||||
ARG API_ADDRESS_DEV
|
||||
ARG APP_VERSION
|
||||
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
COPY --chown=node:node web web
|
||||
RUN yarn rw build web
|
||||
|
||||
# web build
|
||||
# ---------
|
||||
FROM base as web_build
|
||||
FROM base AS web_build
|
||||
|
||||
ARG NAME
|
||||
ARG FIRST_NAME
|
||||
ARG LAST_NAME
|
||||
ARG COUNTRY
|
||||
ARG STATE
|
||||
ARG CITY
|
||||
ARG DEFAULT_THEME
|
||||
ARG API_ADDRESS_PROD
|
||||
ARG API_ADDRESS_DEV
|
||||
ARG APP_VERSION
|
||||
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
COPY --chown=node:node web web
|
||||
RUN yarn rw build web --no-prerender
|
||||
|
||||
# api serve
|
||||
# ---------
|
||||
FROM node:20-bookworm-slim as api_serve
|
||||
FROM node:lts-alpine AS api_serve
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add openssl
|
||||
|
||||
USER node
|
||||
WORKDIR /home/node/app
|
||||
@@ -92,26 +117,28 @@ RUN --mount=type=cache,target=/home/node/.yarn/berry/cache,uid=1000 \
|
||||
|
||||
COPY --chown=node:node redwood.toml .
|
||||
COPY --chown=node:node graphql.config.js .
|
||||
COPY --chown=node:node .env.defaults .env.defaults
|
||||
|
||||
COPY --chown=node:node --from=api_build /home/node/app/api/dist /home/node/app/api/dist
|
||||
COPY --chown=node:node --from=api_build /home/node/app/api/db /home/node/app/api/db
|
||||
COPY --chown=node:node --from=api_build /home/node/app/node_modules/.prisma /home/node/app/node_modules/.prisma
|
||||
|
||||
ARG APP_VERSION
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# default api serve command
|
||||
# ---------
|
||||
# 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
|
||||
# ---------
|
||||
FROM node:20-bookworm-slim as web_serve
|
||||
FROM node:lts-alpine AS web_serve
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
@@ -132,10 +159,12 @@ RUN --mount=type=cache,target=/home/node/.yarn/berry/cache,uid=1000 \
|
||||
|
||||
COPY --chown=node:node redwood.toml .
|
||||
COPY --chown=node:node graphql.config.js .
|
||||
COPY --chown=node:node .env.defaults .env.defaults
|
||||
|
||||
COPY --chown=node:node --from=web_build /home/node/app/web/dist /home/node/app/web/dist
|
||||
|
||||
ARG APP_VERSION
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
API_PROXY_TARGET=http://api:8911
|
||||
|
||||
@@ -144,7 +173,7 @@ CMD "node_modules/.bin/rw-web-server" "--api-proxy-target" "$API_PROXY_TARGET"
|
||||
|
||||
# console
|
||||
# -------
|
||||
FROM base as console
|
||||
FROM base AS console
|
||||
|
||||
# To add more packages:
|
||||
#
|
||||
|
||||
@@ -1,122 +1,92 @@
|
||||
# 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
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- API_PROXY_TARGET=http://localhost:8911
|
||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||
- FIRST_NAME=first name # Your first name
|
||||
- LAST_NAME=lastname # Your last name
|
||||
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||
- STATE=New York # Optional, state or province
|
||||
- CITY=Manhattan # Optional
|
||||
- DEFAULT_THEME=light # 'light' or 'dark'
|
||||
- SMTP_HOST=smtp.example.com
|
||||
- SMTP_PORT=465
|
||||
- SMTP_SECURE=true
|
||||
- SMTP_USER=noreply@example.com
|
||||
- EMAIL_FROM=noreply@example.com
|
||||
- EMAIL_TO=email@example.com
|
||||
- SMTP_PASSWORD=password
|
||||
- DOMAIN=portfolio.example.com
|
||||
- API_DOMAIN=api.portfolio.example.com
|
||||
# Careful, addresses below must not end with a '/'
|
||||
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
||||
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
||||
ports:
|
||||
- 8910:8910 # Web
|
||||
- 8911:8911 # API
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
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 # Change to a more secure password
|
||||
- POSTGRES_DB=portfolio
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d DATABASE_URL"] # Replace DATABASE_URL with the database URL from the portfolio container
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
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)
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+14
@@ -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";
|
||||
+2
@@ -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")
|
||||
);
|
||||
+16
@@ -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';
|
||||
Regular → Executable
Regular → Executable
+15
-12
@@ -25,6 +25,7 @@ enum Handle {
|
||||
discord
|
||||
twitch
|
||||
linkedin
|
||||
matrix
|
||||
github
|
||||
gitea
|
||||
forgejo
|
||||
@@ -58,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
|
||||
@@ -65,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[]
|
||||
description String @default("No description provided")
|
||||
images String[] @default([])
|
||||
date DateTime
|
||||
links String[] @default([])
|
||||
links String[] @default([])
|
||||
tags Tag[]
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+7
-6
@@ -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",
|
||||
"@tus/file-store": "^1.4.0",
|
||||
"@tus/server": "^1.7.0",
|
||||
"@redwoodjs/api": "8.4.0",
|
||||
"@redwoodjs/api-server": "8.4.0",
|
||||
"@redwoodjs/auth-dbauth-api": "8.4.0",
|
||||
"@redwoodjs/graphql-server": "8.4.0",
|
||||
"@tus/file-store": "1.4.0",
|
||||
"@tus/server": "1.7.0",
|
||||
"countries-list": "^3.1.1",
|
||||
"graphql-scalars": "^1.23.0",
|
||||
"nodemailer": "^6.9.14"
|
||||
},
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+22
-9
@@ -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,
|
||||
},
|
||||
|
||||
Regular → Executable
+5
@@ -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(),
|
||||
})
|
||||
|
||||
Regular → Executable
Regular → Executable
@@ -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
|
||||
}
|
||||
`
|
||||
Regular → Executable
+8
-3
@@ -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 {
|
||||
|
||||
Executable
+19
@@ -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
|
||||
}
|
||||
`
|
||||
Regular → Executable
Regular → Executable
+1
@@ -17,6 +17,7 @@ export const schema = gql`
|
||||
discord
|
||||
twitch
|
||||
linkedin
|
||||
matrix
|
||||
github
|
||||
gitea
|
||||
forgejo
|
||||
|
||||
Regular → Executable
+2
-2
@@ -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 {
|
||||
|
||||
Executable
+18
@@ -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
|
||||
}
|
||||
`
|
||||
Regular → Executable
Regular → Executable
+14
-2
@@ -2,10 +2,17 @@ import type { FastifyReply } from 'fastify'
|
||||
|
||||
import { isProduction } from '@redwoodjs/api/logger'
|
||||
|
||||
export const setCorsHeaders = (res: FastifyReply) => {
|
||||
export const setCorsHeaders = (
|
||||
res: FastifyReply,
|
||||
isPublic: boolean = false
|
||||
) => {
|
||||
res.raw.setHeader(
|
||||
'Access-Control-Allow-Origin',
|
||||
isProduction ? process.env.ADDRESS_PROD : process.env.ADDRESS_DEV
|
||||
isPublic
|
||||
? '*'
|
||||
: isProduction
|
||||
? process.env.ADDRESS_PROD
|
||||
: process.env.ADDRESS_DEV
|
||||
)
|
||||
res.raw.setHeader(
|
||||
'Access-Control-Allow-Methods',
|
||||
@@ -16,4 +23,9 @@ export const setCorsHeaders = (res: FastifyReply) => {
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset'
|
||||
)
|
||||
res.raw.setHeader('Access-Control-Allow-Credentials', 'true')
|
||||
res.raw.setHeader(
|
||||
'Access-Control-Expose-Headers',
|
||||
'Upload-Offset, Upload-Length, Upload-Metadata, Tus-Version,' +
|
||||
'Tus-Resumable, Tus-Max-Size, Tus-Extension, Tus-Checksum-Algorithm'
|
||||
)
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+8
-7
@@ -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('@')
|
||||
|
||||
Regular → Executable
Regular → Executable
+9
-3
@@ -25,10 +25,16 @@ export const handleTusUpload = (
|
||||
tusHandler: Server,
|
||||
isPublicEndpoint: boolean
|
||||
) => {
|
||||
res.hijack()
|
||||
|
||||
if (req.method === 'GET' && isPublicEndpoint) {
|
||||
setCorsHeaders(res)
|
||||
}
|
||||
|
||||
if (isProduction) {
|
||||
if (req.method === 'OPTIONS') handleOptionsRequest(res)
|
||||
else if (isPublicEndpoint && req.method === 'GET')
|
||||
tusHandler.handle(req.raw, res.raw)
|
||||
void tusHandler.handle(req.raw, res.raw)
|
||||
else if (['GET', 'POST', 'HEAD', 'PATCH'].includes(req.method)) {
|
||||
if (req.headers.cookie) handleAuthenticatedRequest(req, res, tusHandler)
|
||||
else {
|
||||
@@ -40,8 +46,8 @@ export const handleTusUpload = (
|
||||
res.raw.end('Method not allowed')
|
||||
}
|
||||
} else {
|
||||
setCorsHeaders(res)
|
||||
tusHandler.handle(req.raw, res.raw)
|
||||
setCorsHeaders(res, isPublicEndpoint)
|
||||
void tusHandler.handle(req.raw, res.raw)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
+32
-7
@@ -9,14 +9,34 @@ import { createServer } from '@redwoodjs/api-server'
|
||||
|
||||
import { logger } from 'src/lib/logger'
|
||||
import { handleTusUpload } from 'src/lib/tus'
|
||||
|
||||
enum Theme {
|
||||
light = 'light',
|
||||
dark = 'dark',
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
const { countries } = await import('countries-list')
|
||||
|
||||
if (!Object.keys(countries).includes(process.env.COUNTRY))
|
||||
throw new Error(
|
||||
'Invalid COUNTRY environment variable, please select a valid ISO-3166-1 alpha-2 country code\n See https://en.wikipedia.org/wiki/ISO_3166-1#Codes'
|
||||
)
|
||||
|
||||
if (!(process.env.DEFAULT_THEME.toLowerCase() in Theme))
|
||||
throw new Error(
|
||||
'Invalid DEFAULT_THEME environment variable, please select either light or dark'
|
||||
)
|
||||
|
||||
logger.info(`Portfolio ${process.env.APP_VERSION}`)
|
||||
|
||||
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,
|
||||
})
|
||||
@@ -34,7 +54,10 @@ import { handleTusUpload } from 'src/lib/tus'
|
||||
datastore: new FileStore({
|
||||
directory: `./files_${isProduction ? 'prod' : 'dev'}`,
|
||||
}),
|
||||
onResponseError: (_req, res, _err) => logger.error(res),
|
||||
onResponseError(_, err) {
|
||||
logger.error(err)
|
||||
return { status_code: 500, body: 'Internal Server Error' }
|
||||
},
|
||||
})
|
||||
|
||||
server.addContentTypeParser(
|
||||
@@ -42,12 +65,14 @@ import { handleTusUpload } from 'src/lib/tus'
|
||||
(_request, _payload, done) => done(null)
|
||||
)
|
||||
|
||||
server.all('/files', (req, res) =>
|
||||
server.all('/files', (req, res) => {
|
||||
res.hijack()
|
||||
handleTusUpload(req, res, tusServer, false)
|
||||
)
|
||||
server.all('/files/*', (req, res) =>
|
||||
})
|
||||
server.all('/files/*', (req, res) => {
|
||||
res.hijack()
|
||||
handleTusUpload(req, res, tusServer, true)
|
||||
)
|
||||
})
|
||||
|
||||
await server.start()
|
||||
})()
|
||||
|
||||
Regular → Executable
Regular → Executable
@@ -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()
|
||||
},
|
||||
}
|
||||
Regular → Executable
+22
-5
@@ -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(),
|
||||
}
|
||||
|
||||
Executable
+42
@@ -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 },
|
||||
})
|
||||
}
|
||||
Regular → Executable
Regular → Executable
Executable
+11
@@ -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 },
|
||||
})
|
||||
Regular → Executable
+1
-1
@@ -5,7 +5,7 @@
|
||||
"esModuleInterop": true,
|
||||
"target": "ES2023",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"moduleResolution": "node16",
|
||||
"skipLibCheck": false,
|
||||
"rootDirs": [
|
||||
"./src",
|
||||
|
||||
@@ -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
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
portfolio:
|
||||
container_name: portfolio
|
||||
image: git.altaiar.dev/ahmed/portfolio:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- API_PROXY_TARGET=http://localhost:8911
|
||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||
- FIRST_NAME=first name # Your first name
|
||||
- LAST_NAME=lastname # Your last name
|
||||
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||
- STATE=New York # Optional, state or province
|
||||
- CITY=Manhattan # Optional
|
||||
- DEFAULT_THEME=light # 'light' or 'dark'
|
||||
- SMTP_HOST=smtp.example.com
|
||||
- SMTP_PORT=465
|
||||
- SMTP_SECURE=true
|
||||
- SMTP_USER=noreply@example.com
|
||||
- EMAIL_FROM=noreply@example.com
|
||||
- EMAIL_TO=email@example.com
|
||||
- SMTP_PASSWORD=password
|
||||
- DOMAIN=portfolio.example.com
|
||||
- API_DOMAIN=api.portfolio.example.com
|
||||
# Careful, addresses below must not end with a '/'
|
||||
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
|
||||
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
|
||||
ports:
|
||||
- 8910:8910 # Web
|
||||
- 8911:8911 # API
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
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 # Change to a more secure password
|
||||
- POSTGRES_DB=portfolio
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d DATABASE_URL"] # Replace DATABASE_URL with the database URL from the portfolio container
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
files: # For persistent file storage across upgrades
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
+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": {
|
||||
|
||||
Regular → Executable
Regular → Executable
+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", "STATE", "CITY", "DEFAULT_THEME", "API_ADDRESS_PROD", "API_ADDRESS_DEV", "APP_VERSION"]
|
||||
[generate]
|
||||
tests = false
|
||||
stories = false
|
||||
|
||||
Regular → Executable
Regular → Executable
+30
-14
@@ -7,28 +7,44 @@ 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: 3 }).map((_, i) => `title ${i + 1}`),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
+19
-2
@@ -6,6 +6,7 @@ import {
|
||||
SiTiktokHex,
|
||||
SiYoutubeHex,
|
||||
SiLinkedinHex,
|
||||
SiMatrixHex,
|
||||
SiGithubHex,
|
||||
SiGiteaHex,
|
||||
SiLeetcodeHex,
|
||||
@@ -112,6 +113,10 @@ export const theme = {
|
||||
light: SiLinkedinHex,
|
||||
dark: SiLinkedinHex,
|
||||
},
|
||||
matrix: {
|
||||
light: SiMatrixHex,
|
||||
dark: invertColor(SiMatrixHex),
|
||||
},
|
||||
github: {
|
||||
light: SiGithubHex,
|
||||
dark: invertColor(SiGithubHex),
|
||||
@@ -141,5 +146,17 @@ export const theme = {
|
||||
}
|
||||
|
||||
export const darkMode = ['class', '[data-theme="dark"]']
|
||||
export const plugins = [require('daisyui')]
|
||||
export const daisyui = { themes: ['light', 'dark'] }
|
||||
export const plugins = [require('@tailwindcss/typography'), require('daisyui')]
|
||||
export const daisyui = {
|
||||
themes: [
|
||||
'light',
|
||||
{
|
||||
dark: {
|
||||
...require('daisyui/src/theming/themes')['dark'],
|
||||
'base-100': '#212121',
|
||||
'base-200': '#1d1d1d',
|
||||
'base-300': '#191919',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+19
-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,16 +34,21 @@
|
||||
"@uppy/progress-bar": "^4.0.0",
|
||||
"@uppy/react": "^4.0.1",
|
||||
"@uppy/tus": "^4.0.0",
|
||||
"@uppy/webcam": "^4.0.1",
|
||||
"countries-list": "^3.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"humanize-string": "2.1.0",
|
||||
"react": "18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "18.3.1"
|
||||
"react-dom": "18.3.1",
|
||||
"react-html-parser": "^2.0.2",
|
||||
"react-pdf": "^9.2.1"
|
||||
},
|
||||
"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",
|
||||
|
||||
Regular → Executable
Regular → Executable
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 757 B |
Regular → Executable
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
Executable
BIN
Binary file not shown.
Regular → Executable
Regular → Executable
Regular → Executable
+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>
|
||||
|
||||
Regular → Executable
+15
-4
@@ -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="Projects" titleTo="projects" buttonLabel="New Project" buttonTo="newProject">
|
||||
<Set wrap={ScaffoldLayout} title="Titles" titleTo="titles">
|
||||
<Route path="/admin/titles" page={TitleTitlesPage} name="titles" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={ScaffoldLayout} title="Résumé" titleTo="adminResume">
|
||||
<Route path="/admin/resume" page={ResumeAdminResumePage} name="adminResume" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={ScaffoldLayout} title="Projects" titleTo="adminProjects" buttonLabel="New Project" buttonTo="newProject">
|
||||
<Route path="/admin/projects/new" page={ProjectNewProjectPage} name="newProject" />
|
||||
<Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" />
|
||||
<Route path="/admin/projects/{id:Int}" page={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} />
|
||||
|
||||
Regular → Executable
+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)
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+44
-40
@@ -9,46 +9,50 @@ interface ColorPickerProps {
|
||||
setColor: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
|
||||
return (
|
||||
<div className="flex flex-col space-y-2 bg-base-100 p-2 rounded-xl w-min">
|
||||
<section className="w-52">
|
||||
<HexColorPicker color={color} onChange={setColor} />
|
||||
</section>
|
||||
<div className="flex space-x-2 w-52">
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label className="input input-bordered flex items-center gap-2 input-sm w-full grow">
|
||||
<Icon path={mdiPound} className="size-4 opacity-70" />
|
||||
<HexColorInput color={color} className="w-16" />
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-square btn-sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(color)
|
||||
toast.success('Copied color to clipboard')
|
||||
} catch {
|
||||
toast.error(`Failed to copy, please try again`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiContentCopy} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-square btn-sm "
|
||||
onClick={async () => {
|
||||
try {
|
||||
setColor(await navigator.clipboard.readText())
|
||||
} catch {
|
||||
toast.error(`Failed to paste, please try again`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiContentPaste} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
const ColorPicker = ({ color, setColor }: ColorPickerProps) => (
|
||||
<div className="flex flex-col space-y-2 bg-base-100 p-2 rounded-xl">
|
||||
<HexColorPicker color={color} onChange={setColor} />
|
||||
<div className="flex space-x-2">
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label className="input input-bordered flex items-center gap-2 input-sm grow">
|
||||
<Icon path={mdiPound} className="size-4 opacity-70" />
|
||||
<HexColorInput color={color} className="w-16" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(color)
|
||||
toast.success('Copied color to clipboard')
|
||||
} catch {
|
||||
toast.error(`Failed to copy, please try again`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiContentCopy} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText()
|
||||
const hexColorRegex =
|
||||
/^#(?:(?:[\da-f]{3}){1,2}|(?:[\da-f]{4}){1,2})$/i
|
||||
|
||||
if (!hexColorRegex.test(clipboardText))
|
||||
toast.error(`Text is not a valid hex color`)
|
||||
else setColor(clipboardText)
|
||||
} catch {
|
||||
toast.error(`Failed to paste, please try again`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiContentPaste} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ColorPicker
|
||||
|
||||
Regular → Executable
+20
-23
@@ -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 [width, setWidth] = useState()
|
||||
const [height, setHeight] = useState()
|
||||
const ContactCard = ({ portraitUrl, socials }: ContactCardProps) => {
|
||||
const [width, setWidth] = useState<number>(0)
|
||||
const [height, setHeight] = useState<number>(0)
|
||||
|
||||
const observedDiv = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!observedDiv.current) return
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
@@ -50,25 +53,19 @@ const ContactCard = ({ portraitUrl }: ContactCardProps) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-[calc(100vh-6rem)] items-center justify-center">
|
||||
<div className="card bg-base-100 shadow-xl md:card-side">
|
||||
<figure>
|
||||
<img
|
||||
className="contact-me-image aspect-portrait object-cover"
|
||||
src={portraitUrl}
|
||||
alt={`${process.env.NAME}`}
|
||||
/>
|
||||
</figure>
|
||||
<div
|
||||
className="card-body mx-auto h-fit w-fit md:mx-0"
|
||||
ref={observedDiv}
|
||||
>
|
||||
<h2 className="card-title justify-center text-3xl md:justify-start">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="card card-compact bg-base-100 shadow-xl md:card-side">
|
||||
<img
|
||||
className="contact-me-image rounded-box aspect-portrait p-2 object-cover"
|
||||
src={portraitUrl}
|
||||
alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`}
|
||||
/>
|
||||
<div className="card-body mx-auto w-fit md:mx-0" ref={observedDiv}>
|
||||
<h2 className="card-title justify-center text-3xl pb-2 md:justify-start">
|
||||
Contact Me
|
||||
</h2>
|
||||
<p className="p-2"></p>
|
||||
<div className="card-actions">
|
||||
<SocialLinksCell />
|
||||
<div className="card-actions rounded-btn">
|
||||
<SocialLinks socials={socials} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Regular → Executable
+38
-11
@@ -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} />
|
||||
</>
|
||||
)
|
||||
|
||||
Regular → Executable
Regular → Executable
+17
-10
@@ -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>
|
||||
))}
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { mdiOpenInNew } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import { Document, Page as PdfPage, pdfjs } from 'react-pdf'
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||||
import 'react-pdf/dist/Page/TextLayer.css'
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString()
|
||||
|
||||
interface PDFProps {
|
||||
url: string
|
||||
form?: boolean
|
||||
}
|
||||
|
||||
const PDF = ({ url, form = false }: PDFProps) => {
|
||||
const [numPages, setNumPages] = useState<number>(0)
|
||||
function onLoadSuccess({ numPages }: { numPages: number }) {
|
||||
setNumPages(numPages)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-y-auto flex justify-center"
|
||||
style={{
|
||||
width: 'calc(100vw - 1rem)',
|
||||
height: `calc(100vh - ${form ? '8.5rem' : '6rem'})`,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="fixed top-20 left-0 z-10 m-2 p-2 rounded-xl btn btn-square btn-ghost shadow-lg"
|
||||
>
|
||||
<Icon path={mdiOpenInNew} size={1} className="text-gray-600" />
|
||||
</a>
|
||||
<Document file={url} onLoadSuccess={onLoadSuccess}>
|
||||
{Array.from({ length: numPages }, (_, i) => (
|
||||
<PdfPage key={i} pageNumber={i + 1} width={800} />
|
||||
))}
|
||||
</Document>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PDF
|
||||
Regular → Executable
+8
-12
@@ -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} />
|
||||
)
|
||||
|
||||
Regular → Executable
+21
-50
@@ -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)
|
||||
}
|
||||
@@ -145,14 +127,13 @@ const PortraitForm = (props: PortraitFormProps) => {
|
||||
)
|
||||
else
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
<div className="mx-auto max-w-prose space-y-2">
|
||||
{!fileId ? (
|
||||
<>
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="22rem"
|
||||
height="11.5rem"
|
||||
className="flex justify-center"
|
||||
width="auto"
|
||||
height="30rem"
|
||||
/>
|
||||
<p className="text-center">
|
||||
High quality, 4:5 aspect ratio image recommended
|
||||
@@ -162,7 +143,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 +183,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
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
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 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 colSpan={2}>
|
||||
Project {project.id}: {project.title}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="text-right">ID</th>
|
||||
<td>{project.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-right">Title</th>
|
||||
<td>{project.title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-right">Description</th>
|
||||
<td>
|
||||
<article className="prose">
|
||||
{parseHtml(project.description)}
|
||||
</article>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-right">Date</th>
|
||||
<td>{timeTag(project.date)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-right">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 ${i === 0 && 'btn-primary'}`}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{i + 1}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-right">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 className="text-right">Links</th>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.links.map((link, i) => (
|
||||
<>
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="hidden sm:flex badge badge-ghost text-nowrap"
|
||||
key={i}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="btn btn-sm btn-square sm:hidden"
|
||||
key={i}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{i + 1}
|
||||
</a>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</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} />
|
||||
)
|
||||
Regular → Executable
+11
-5
@@ -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),
|
||||
}
|
||||
@@ -67,12 +73,12 @@ export const Success = ({ project }: CellSuccessProps<EditProjectById>) => {
|
||||
) => updateProject({ variables: { id, input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<div className="flex mx-auto max-w-prose justify-center">
|
||||
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th className="w-0">Edit Project {project.id}</th>
|
||||
<th>Edit Project {project.id}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
Regular → Executable
+4
-4
@@ -28,7 +28,7 @@ const NewProject = () => {
|
||||
{
|
||||
onCompleted: () => {
|
||||
toast.success('Project created')
|
||||
navigate(routes.projects())
|
||||
navigate(routes.adminProjects())
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
}
|
||||
@@ -38,9 +38,9 @@ const NewProject = () => {
|
||||
createProject({ variables: { input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<div className="flex mx-auto max-w-prose justify-center">
|
||||
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th>New Project</th>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user