28 Commits

Author SHA1 Message Date
Ahmed Al-Taiar f14732cdf0 Upgrade RedwoodJS
Publish Docker Image / Publish Docker Image (push) Successful in 1m6s
2024-10-15 14:52:01 -04:00
Ahmed Al-Taiar 77db153fe6 Add Matrix social 2024-10-15 14:51:43 -04:00
Ahmed Al-Taiar 684d6f88c2 Combine GraphQL queries for contact card (2 -> 1) 2024-10-15 14:30:05 -04:00
Ahmed Al-Taiar 03f606bbde Improve color picker paste logic 2024-10-15 14:21:52 -04:00
Ahmed Al-Taiar 1eafaee2c0 Follow system color scheme by default instead of light 2024-10-10 14:09:09 -04:00
Ahmed Al-Taiar b8063e8692 Auth tweaks
Publish Docker Image / Publish Docker Image (push) Successful in 26s
2024-10-09 20:44:12 -04:00
Ahmed Al-Taiar 738260f7de Watermark 2024-10-09 20:37:27 -04:00
Ahmed Al-Taiar 82313bef46 Simplify PDF embed 2024-10-09 20:32:39 -04:00
Ahmed Al-Taiar 74db2e1034 README
Publish Docker Image / Publish Docker Image (push) Successful in 1m1s
2024-10-08 21:21:06 -04:00
Ahmed Al-Taiar e2dfb6f237 Implement rich text for project description 2024-10-08 20:36:48 -04:00
Ahmed Al-Taiar 708634fa68 Fix db seed overwriting password every time 2024-10-08 15:45:21 -04:00
Ahmed Al-Taiar 6873c5c026 ._.
Publish Docker Image / Publish Docker Image (push) Successful in 1m48s
2024-10-08 15:36:08 -04:00
Ahmed Al-Taiar 1b7e79c765 CI/CD pipeline 2024-10-08 14:42:37 -04:00
Ahmed Al-Taiar 6e401cf2b3 Add blur effect on navbar(s) 2024-10-08 14:36:34 -04:00
Ahmed Al-Taiar b89a5ee1b8 Add camera option for image uploads 2024-10-08 13:09:48 -04:00
Ahmed Al-Taiar 3c2b944bf4 Enforce CORS 2024-10-07 23:09:18 -04:00
Ahmed Al-Taiar 11783069a8 Docker setup 2024-10-07 20:58:52 -04:00
Ahmed Al-Taiar 835d895fc0 Shorten homepage filew 2024-10-07 15:31:42 -04:00
Ahmed Al-Taiar 73ec75c167 Sort socials option in form 2024-10-06 18:58:05 -04:00
Ahmed Al-Taiar 49c943c9f3 Sort socials 2024-10-06 18:54:26 -04:00
Ahmed Al-Taiar fb542bb5b5 Polishing touches and tweaks 2024-10-06 00:31:59 -04:00
Ahmed Al-Taiar e5f9bbd462 Titles with a cool effect 2024-10-04 23:13:44 -04:00
Ahmed Al-Taiar 8671f47e91 New favicon 2024-10-01 21:20:39 -04:00
Ahmed Al-Taiar 4a94b6807e Resume + Projects done 2024-10-01 20:45:43 -04:00
Ahmed Al-Taiar 9c0dee7d54 Public facing projects showcase, individual project details not done yet 2024-09-29 15:38:26 -04:00
Ahmed Al-Taiar 38168db452 Update Dockerfile 2024-09-27 22:58:09 -04:00
Ahmed Al-Taiar c9227cf9b9 Split name env variable 2024-09-27 22:57:30 -04:00
Ahmed Al-Taiar 5c41588249 Update to RW 8.3.0 + Project CRUD complete 2024-09-27 22:52:41 -04:00
97 changed files with 4603 additions and 2257 deletions
+4 -3
View File
@@ -15,7 +15,8 @@ PRISMA_HIDE_UPDATE_MESSAGE=true
# Ordered by how verbose they are: trace | debug | info | warn | error | silent # Ordered by how verbose they are: trace | debug | info | warn | error | silent
# LOG_LEVEL=debug # LOG_LEVEL=debug
NAME=Ahmed Al-Taiar FIRST_NAME=firstname
LAST_NAME=lastname
GMAIL=example@gmail.com GMAIL=example@gmail.com
GMAIL_SMTP_PASSWORD=chan geme xyza bcde GMAIL_SMTP_PASSWORD=chan geme xyza bcde
@@ -24,9 +25,9 @@ DOMAIN=example.com
API_DOMAIN=api.example.com API_DOMAIN=api.example.com
# Must not end with "/" # Must not end with "/"
ADDRESS_PROD=https://example.com ADDRESS_PROD=https://portfolio.example.com
ADDRESS_DEV=http://localhost:8910 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 API_ADDRESS_DEV=http://localhost:8911
MAX_HTTP_CONNECTIONS_PER_MINUTE=60 MAX_HTTP_CONNECTIONS_PER_MINUTE=60
+4 -3
View File
@@ -3,7 +3,8 @@
# PRISMA_HIDE_UPDATE_MESSAGE=true # PRISMA_HIDE_UPDATE_MESSAGE=true
# LOG_LEVEL=trace # LOG_LEVEL=trace
NAME=Firstname Lastname FIRST_NAME=firstname
LAST_NAME=lastname
GMAIL=example@gmail.com GMAIL=example@gmail.com
GMAIL_SMTP_PASSWORD=chan geme xyza bcde GMAIL_SMTP_PASSWORD=chan geme xyza bcde
@@ -12,9 +13,9 @@ DOMAIN=example.com
API_DOMAIN=api.example.com API_DOMAIN=api.example.com
# Must not end with "/" # Must not end with "/"
ADDRESS_PROD=https://example.com ADDRESS_PROD=https://portfolio.example.com
ADDRESS_DEV=http://localhost:8910 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 API_ADDRESS_DEV=http://localhost:8911
MAX_HTTP_CONNECTIONS_PER_MINUTE=60 MAX_HTTP_CONNECTIONS_PER_MINUTE=60
+31
View File
@@ -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
+17 -7
View File
@@ -37,8 +37,16 @@ FROM base as api_build
# If your api side build relies on build-time environment variables, # If your api side build relies on build-time environment variables,
# specify them here as ARGs. (But don't put secrets in your Dockerfile!) # specify them here as ARGs. (But don't put secrets in your Dockerfile!)
#
# ARG MY_BUILD_TIME_ENV_VAR ARG ADDRESS_PROD
ARG ADDRESS_DEV
ARG DOMAIN
ARG API_DOMAIN
ARG MAX_HTTP_CONNECTIONS_PER_MINUTE
ARG GMAIL
ARG GMAIL_SMTP_PASSWORD
ARG FIRST_NAME
ARG LAST_NAME
COPY --chown=node:node api api COPY --chown=node:node api api
RUN yarn rw build api RUN yarn rw build api
@@ -47,7 +55,8 @@ RUN yarn rw build api
# ------------------- # -------------------
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 API_ADDRESS_PROD ARG API_ADDRESS_PROD
ARG API_ADDRESS_DEV ARG API_ADDRESS_DEV
@@ -58,7 +67,8 @@ RUN yarn rw build web
# --------- # ---------
FROM base as web_build FROM base as web_build
ARG NAME ARG FIRST_NAME
ARG LAST_NAME
ARG API_ADDRESS_PROD ARG API_ADDRESS_PROD
ARG API_ADDRESS_DEV ARG API_ADDRESS_DEV
@@ -105,9 +115,9 @@ ENV NODE_ENV=production
# If you are using a custom server file, you must use the following # 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. # command to launch your server instead of the default api-server below.
# This is important if you intend to configure GraphQL to use Realtime. # This is important if you intend to configure GraphQL to use Realtime.
#
# CMD [ "./api/dist/server.js" ] CMD [ "./api/dist/server.js" ]
CMD [ "node_modules/.bin/rw-server", "api" ] # CMD [ "node_modules/.bin/rw-server", "api" ]
# web serve # web serve
# --------- # ---------
+66 -116
View File
@@ -1,122 +1,72 @@
# README # Portfolio Website
## Setup
### Domain Records
Create two A records, one for the web side of the website and one for the api side of the website. Ideally, the records should look something like `myportfolio.example.com` for the web side and `api.myportfolio.example.com`, but it is not important.
### Reverse Proxy
- It doesn't matter what reverse proxy you use (Nginx, Apache, Traefik, Caddy, etc)
1. Point the web domain to the web port (default: 8910)
2. Point the api domain to the api port (default: 8911)
### Gmail App Password
1. Go to your Google [account dashboard](https://myaccount.google.com)
2. Go to Security > 2-Step Verification > App Passwords > Create a new app password
3. Copy the 16 character password
### [Docker Compose](./docker-compose.yml)
```yaml
version: '3.8'
Welcome to [RedwoodJS](https://redwoodjs.com)! services:
portfolio:
container_name: portfolio
image: git.altaiar.dev/ahmed/portfolio:latest
environment:
- NODE_ENV=production
- API_PROXY_TARGET=http://localhost:8911
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
- DATABASE_URL=postgresql://redwood:redwood@db:5432/portfolio
- FIRST_NAME=first name # Your first name
- LAST_NAME=lastname # Your last name
- GMAIL=example@gmail.com # The Gmail address associated with the app password created earlier
- GMAIL_SMTP_PASSWORD=aaaa bbbb cccc dddd # The app password created earlier
- DOMAIN=portfolio.example.com
- API_DOMAIN=api.portfolio.example.com
# Careful, addresses below must not end with a '/'
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
ports:
- '8910:8910' # Web
- '8911:8911' # API
depends_on:
- db
command: >
/bin/sh -c "
yarn rw build &&
yarn rw prisma migrate deploy &&
yarn rw prisma db seed &&
yarn rw serve"
> **Prerequisites**
>
> - Redwood requires [Node.js](https://nodejs.org/en/) (=20.x) and [Yarn](https://yarnpkg.com/)
> - Are you on Windows? For best results, follow our [Windows development setup](https://redwoodjs.com/docs/how-to/windows-development-setup) guide
Start by installing dependencies: db:
container_name: portfolio-db
image: postgres:16-bookworm
environment:
POSTGRES_USER: redwood
POSTGRES_PASSWORD: redwood
POSTGRES_DB: portfolio
ports:
- '5432:5432'
volumes:
- postgres:/var/lib/postgresql/data
volumes:
postgres:
``` ```
yarn install ## Logging In
``` - Once the container is up and running, head to `/login` (`https://portfolio.example.com/login`), default credentials are below
- If you would like to change the password, head to `/forgot-password` (`https://portfolio.example.com/forgot-password`), the username is `admin`
- If you correctly set up the Gmail app password, you should receive an email from yourself
- It contains the link needed to change your password
### Default Credentials
**Username:** `admin`
Then start the development server: **Password:** [`GMAIL_SMTP_PASSWORD`](#gmail-app-password)
```
yarn redwood dev
```
Your browser should automatically open to [http://localhost:8910](http://localhost:8910) where you'll see the Welcome Page, which links out to many great resources.
> **The Redwood CLI**
>
> Congratulations on running your first Redwood CLI command! From dev to deploy, the CLI is with you the whole way. And there's quite a few commands at your disposal:
>
> ```
> yarn redwood --help
> ```
>
> For all the details, see the [CLI reference](https://redwoodjs.com/docs/cli-commands).
## Prisma and the database
Redwood wouldn't be a full-stack framework without a database. It all starts with the schema. Open the [`schema.prisma`](api/db/schema.prisma) file in `api/db` and replace the `UserExample` model with the following `Post` model:
```prisma
model Post {
id Int @id @default(autoincrement())
title String
body String
createdAt DateTime @default(now())
}
```
Redwood uses [Prisma](https://www.prisma.io/), a next-gen Node.js and TypeScript ORM, to talk to the database. Prisma's schema offers a declarative way of defining your app's data models. And Prisma [Migrate](https://www.prisma.io/migrate) uses that schema to make database migrations hassle-free:
```
yarn rw prisma migrate dev
# ...
? Enter a name for the new migration: create posts
```
> `rw` is short for `redwood`
You'll be prompted for the name of your migration. `create posts` will do.
Now let's generate everything we need to perform all the CRUD (Create, Retrieve, Update, Delete) actions on our `Post` model:
```
yarn redwood generate scaffold post
```
Navigate to [http://localhost:8910/posts/new](http://localhost:8910/posts/new), fill in the title and body, and click "Save".
Did we just create a post in the database? Yup! With `yarn rw generate scaffold <model>`, Redwood created all the pages, components, and services necessary to perform all CRUD actions on our posts table.
## Frontend first with Storybook
Don't know what your data models look like? That's more than ok—Redwood integrates Storybook so that you can work on design without worrying about data. Mockup, build, and verify your React components, even in complete isolation from the backend:
```
yarn rw storybook
```
Seeing "Couldn't find any stories"? That's because you need a `*.stories.{tsx,jsx}` file. The Redwood CLI makes getting one easy enough—try generating a [Cell](https://redwoodjs.com/docs/cells), Redwood's data-fetching abstraction:
```
yarn rw generate cell examplePosts
```
The Storybook server should hot reload and now you'll have four stories to work with. They'll probably look a little bland since there's no styling. See if the Redwood CLI's `setup ui` command has your favorite styling library:
```
yarn rw setup ui --help
```
## Testing with Jest
It'd be hard to scale from side project to startup without a few tests. Redwood fully integrates Jest with both the front- and back-ends, and makes it easy to keep your whole app covered by generating test files with all your components and services:
```
yarn rw test
```
To make the integration even more seamless, Redwood augments Jest with database [scenarios](https://redwoodjs.com/docs/testing#scenarios) and [GraphQL mocking](https://redwoodjs.com/docs/testing#mocking-graphql-calls).
## Ship it
Redwood is designed for both serverless deploy targets like Netlify and Vercel and serverful deploy targets like Render and AWS:
```
yarn rw setup deploy --help
```
Don't go live without auth! Lock down your app with Redwood's built-in, database-backed authentication system ([dbAuth](https://redwoodjs.com/docs/authentication#self-hosted-auth-installation-and-setup)), or integrate with nearly a dozen third-party auth providers:
```
yarn rw setup auth --help
```
## Next Steps
The best way to learn Redwood is by going through the comprehensive [tutorial](https://redwoodjs.com/docs/tutorial/foreword) and joining the community (via the [Discourse forum](https://community.redwoodjs.com) or the [Discord server](https://discord.gg/redwoodjs)).
## Quick Links
- Stay updated: read [Forum announcements](https://community.redwoodjs.com/c/announcements/5), follow us on [Twitter](https://twitter.com/redwoodjs), and subscribe to the [newsletter](https://redwoodjs.com/newsletter)
- [Learn how to contribute](https://redwoodjs.com/docs/contributing)
@@ -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';
+15 -12
View File
@@ -25,6 +25,7 @@ enum Handle {
discord discord
twitch twitch
linkedin linkedin
matrix
github github
gitea gitea
forgejo forgejo
@@ -58,6 +59,16 @@ model Portrait {
fileId String fileId String
} }
model Resume {
id Int @id @default(autoincrement())
fileId String
}
model Titles {
id Int @id @default(autoincrement())
titles String[] @default([])
}
model Tag { model Tag {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
tag String tag String
@@ -65,20 +76,12 @@ model Tag {
projects Project[] projects Project[]
} }
model ProjectImage {
id Int @id @default(autoincrement())
fileId String
Project Project? @relation(fields: [projectId], references: [id])
projectId Int?
}
model Project { model Project {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
title String title String
description String @default("No description provided") description String @default("No description provided")
images ProjectImage[] images String[] @default([])
date DateTime date DateTime
links String[] @default([]) links String[] @default([])
tags Tag[] tags Tag[]
} }
+4 -4
View File
@@ -5,10 +5,10 @@
"dependencies": { "dependencies": {
"@fastify/cors": "^9.0.1", "@fastify/cors": "^9.0.1",
"@fastify/rate-limit": "^9.1.0", "@fastify/rate-limit": "^9.1.0",
"@redwoodjs/api": "8.0.0", "@redwoodjs/api": "8.4.0",
"@redwoodjs/api-server": "8.0.0", "@redwoodjs/api-server": "8.4.0",
"@redwoodjs/auth-dbauth-api": "8.0.0", "@redwoodjs/auth-dbauth-api": "8.4.0",
"@redwoodjs/graphql-server": "8.0.0", "@redwoodjs/graphql-server": "8.4.0",
"@tus/file-store": "^1.4.0", "@tus/file-store": "^1.4.0",
"@tus/server": "^1.7.0", "@tus/server": "^1.7.0",
"graphql-scalars": "^1.23.0", "graphql-scalars": "^1.23.0",
+22 -9
View File
@@ -11,6 +11,22 @@ import { cookieName } from 'src/lib/auth'
import { db } from 'src/lib/db' import { db } from 'src/lib/db'
import { censorEmail, sendEmail } from 'src/lib/email' 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 ( export const handler = async (
event: APIGatewayProxyEvent, event: APIGatewayProxyEvent,
context: Context context: Context
@@ -95,9 +111,7 @@ ${domain}/reset-password?resetToken=${resetToken}
// the database. Returning anything truthy will automatically log the user // the database. Returning anything truthy will automatically log the user
// in. Return `false` otherwise, and in the Reset Password page redirect the // in. Return `false` otherwise, and in the Reset Password page redirect the
// user to the login page. // user to the login page.
handler: (_user) => { handler: (_user) => false,
return true
},
// If `false` then the new password MUST be different from the current one // If `false` then the new password MUST be different from the current one
allowReusedPassword: true, allowReusedPassword: true,
@@ -197,10 +211,8 @@ ${domain}/reset-password?resetToken=${resetToken}
}, },
cors: { cors: {
origin: isProduction origin: isProduction ? process.env.ADDRESS_PROD : process.env.ADDRESS_DEV,
? [process.env.ADDRESS_PROD, process.env.API_ADDRESS_PROD] credentials: isProduction,
: [process.env.ADDRESS_DEV, process.env.API_ADDRESS_DEV],
credentials: true,
methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'], methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'],
}, },
@@ -218,8 +230,9 @@ ${domain}/reset-password?resetToken=${resetToken}
Path: '/', Path: '/',
SameSite: isProduction ? 'None' : 'Strict', SameSite: isProduction ? 'None' : 'Strict',
Secure: isProduction, Secure: isProduction,
Domain: isProduction ? 'localhost' : 'localhost', Domain: isProduction
// Domain: isProduction ? process.env.DOMAIN : 'localhost', ? getCommonCookieDomain(process.env.DOMAIN, process.env.API_DOMAIN)
: 'localhost',
}, },
name: cookieName, name: cookieName,
}, },
+5
View File
@@ -5,6 +5,7 @@ import {
HexColorCodeResolver, HexColorCodeResolver,
} from 'graphql-scalars' } from 'graphql-scalars'
import { isProduction } from '@redwoodjs/api/logger'
import { createAuthDecoder } from '@redwoodjs/auth-dbauth-api' import { createAuthDecoder } from '@redwoodjs/auth-dbauth-api'
import { createGraphQLHandler } from '@redwoodjs/graphql-server' import { createGraphQLHandler } from '@redwoodjs/graphql-server'
@@ -32,5 +33,9 @@ export const handler = createGraphQLHandler({
HexColorCode: HexColorCodeResolver, HexColorCode: HexColorCodeResolver,
}, },
}, },
cors: {
origin: isProduction ? process.env.ADDRESS_PROD : process.env.ADDRESS_DEV,
credentials: isProduction,
},
onException: () => db.$disconnect(), onException: () => db.$disconnect(),
}) })
-33
View File
@@ -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
}
`
+8 -3
View File
@@ -3,15 +3,15 @@ export const schema = gql`
id: Int! id: Int!
title: String! title: String!
description: String! description: String!
images: [ProjectImage]! images: [String]!
date: DateTime! date: DateTime!
links: [URL]! links: [URL]!
tags: [Tag]! tags: [Tag]!
} }
type Query { type Query {
projects: [Project!]! @requireAuth projects: [Project!]! @skipAuth
project(id: Int!): Project @requireAuth project(id: Int!): Project @skipAuth
} }
input CreateProjectInput { input CreateProjectInput {
@@ -19,6 +19,8 @@ export const schema = gql`
description: String! description: String!
date: DateTime! date: DateTime!
links: [URL]! links: [URL]!
images: [URL]!
tags: [Int!]
} }
input UpdateProjectInput { input UpdateProjectInput {
@@ -26,6 +28,9 @@ export const schema = gql`
description: String description: String
date: DateTime date: DateTime
links: [URL]! links: [URL]!
images: [URL]!
tags: [Int!]
removeTags: [Int!]
} }
type Mutation { type Mutation {
+19
View File
@@ -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
}
`
+1
View File
@@ -17,6 +17,7 @@ export const schema = gql`
discord discord
twitch twitch
linkedin linkedin
matrix
github github
gitea gitea
forgejo forgejo
+2 -2
View File
@@ -7,8 +7,8 @@ export const schema = gql`
} }
type Query { type Query {
tags: [Tag!]! @requireAuth tags: [Tag!]! @skipAuth
tag(id: Int!): Tag @requireAuth tag(id: Int!): Tag @skipAuth
} }
input CreateTagInput { input CreateTagInput {
+18
View File
@@ -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
}
`
+3 -4
View File
@@ -15,15 +15,14 @@ const transporter = nodemailer.createTransport({
}, },
}) })
export const sendEmail = async ({ to, subject, text, html }: Options) => { export const sendEmail = async ({ to, subject, text, html }: Options) =>
return await transporter.sendMail({ await transporter.sendMail({
from: `"${process.env.NAME} (noreply)" <${process.env.GMAIL}>`, from: `"${process.env.FIRST_NAME} ${process.env.LAST_NAME} (noreply)" <${process.env.GMAIL}>`,
to: Array.isArray(to) ? to : [to], to: Array.isArray(to) ? to : [to],
subject, subject,
text, text,
html, html,
}) })
}
export const censorEmail = (email: string): string => { export const censorEmail = (email: string): string => {
const [localPart, domain] = email.split('@') const [localPart, domain] = email.split('@')
+2 -2
View File
@@ -15,8 +15,8 @@ import { handleTusUpload } from 'src/lib/tus'
configureApiServer: async (server) => { configureApiServer: async (server) => {
await server.register(Cors, { await server.register(Cors, {
origin: isProduction origin: isProduction
? [process.env.ADDRESS_PROD, process.env.API_ADDRESS_PROD] ? process.env.ADDRESS_PROD
: [process.env.ADDRESS_DEV, process.env.API_ADDRESS_DEV], : process.env.ADDRESS_DEV,
methods: ['GET', 'POST', 'OPTIONS', 'PATCH', 'HEAD'], methods: ['GET', 'POST', 'OPTIONS', 'PATCH', 'HEAD'],
credentials: isProduction ? true : false, 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()
},
}
+22 -5
View File
@@ -15,15 +15,34 @@ export const project: QueryResolvers['project'] = ({ id }) =>
export const createProject: MutationResolvers['createProject'] = ({ input }) => export const createProject: MutationResolvers['createProject'] = ({ input }) =>
db.project.create({ 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, id,
input, input,
}) => }) =>
db.project.update({ 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 }, where: { id },
}) })
@@ -33,8 +52,6 @@ export const deleteProject: MutationResolvers['deleteProject'] = ({ id }) =>
}) })
export const Project: ProjectRelationResolvers = { export const Project: ProjectRelationResolvers = {
images: (_obj, { root }) =>
db.project.findUnique({ where: { id: root?.id } }).images(),
tags: (_obj, { root }) => tags: (_obj, { root }) =>
db.project.findUnique({ where: { id: root?.id } }).tags(), db.project.findUnique({ where: { id: root?.id } }).tags(),
} }
+42
View File
@@ -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 },
})
}
+11
View File
@@ -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 },
})
-58
View File
@@ -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:
-63
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
version: '3.8'
services:
portfolio:
container_name: portfolio
image: git.altaiar.dev/ahmed/portfolio:latest
environment:
- NODE_ENV=production
- API_PROXY_TARGET=http://localhost:8911
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
- DATABASE_URL=postgresql://redwood:redwood@db:5432/portfolio
- FIRST_NAME=first name # Your first name
- LAST_NAME=lastname # Your last name
- GMAIL=example@gmail.com # The Gmail address associated with the app password created earlier
- GMAIL_SMTP_PASSWORD=aaaa bbbb cccc dddd # The app password created earlier
- DOMAIN=portfolio.example.com
- API_DOMAIN=api.portfolio.example.com
# Careful, addresses below must not end with a '/'
- ADDRESS_PROD=https://portfolio.example.com # https://DOMAIN
- API_ADDRESS_PROD=https://api.portfolio.example.com # https://API_DOMAIN
ports:
- '8910:8910' # Web
- '8911:8911' # API
depends_on:
- db
command: >
/bin/sh -c "
yarn rw build &&
yarn rw prisma migrate deploy &&
yarn rw prisma db seed &&
yarn rw serve"
db:
container_name: portfolio-db
image: postgres:16-bookworm
environment:
POSTGRES_USER: redwood
POSTGRES_PASSWORD: redwood
POSTGRES_DB: portfolio
ports:
- '5432:5432'
volumes:
- postgres:/var/lib/postgresql/data
volumes:
postgres:
+3 -3
View File
@@ -7,9 +7,9 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@redwoodjs/auth-dbauth-setup": "8.0.0", "@redwoodjs/auth-dbauth-setup": "8.4.0",
"@redwoodjs/core": "8.0.0", "@redwoodjs/core": "8.4.0",
"@redwoodjs/project-config": "8.0.0", "@redwoodjs/project-config": "8.4.0",
"prettier-plugin-tailwindcss": "0.4.1" "prettier-plugin-tailwindcss": "0.4.1"
}, },
"eslintConfig": { "eslintConfig": {
+2 -2
View File
@@ -6,10 +6,10 @@
# https://redwoodjs.com/docs/app-configuration-redwood-toml # https://redwoodjs.com/docs/app-configuration-redwood-toml
[web] [web]
title = "${NAME}" title = "${FIRST_NAME} ${LAST_NAME}"
port = 8910 port = 8910
apiUrl = "/api" apiUrl = "/api"
includeEnvironmentVariables = ["NAME", "API_ADDRESS_PROD", "API_ADDRESS_DEV"] includeEnvironmentVariables = ["FIRST_NAME", "LAST_NAME", "API_ADDRESS_PROD", "API_ADDRESS_DEV"]
[generate] [generate]
tests = false tests = false
stories = false stories = false
+24 -12
View File
@@ -3,6 +3,8 @@ import { db } from 'api/src/lib/db'
import { hashPassword } from '@redwoodjs/auth-dbauth-api' import { hashPassword } from '@redwoodjs/auth-dbauth-api'
const MAX_TITLES = 5
export default async () => { export default async () => {
try { try {
const admin = { const admin = {
@@ -13,22 +15,32 @@ export default async () => {
const [hashedPassword, salt] = hashPassword(admin.password) const [hashedPassword, salt] = hashPassword(admin.password)
await db.user.upsert({ const existingAdmin = await db.user.findFirst({
where: { where: {
email: admin.email, email: admin.email,
}, },
create: {
username: admin.username,
email: admin.email,
hashedPassword,
salt,
},
update: {
username: admin.username,
hashedPassword,
salt,
},
}) })
if (!existingAdmin)
await db.user.create({
data: {
username: admin.username,
email: admin.email,
hashedPassword,
salt,
},
})
const titles = await db.titles.findFirst()
if (!titles)
await db.titles.create({
data: {
titles: Array.from({ length: MAX_TITLES }).map(
(_, i) => `a title ${i + 1}`
),
},
})
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
+19 -2
View File
@@ -6,6 +6,7 @@ import {
SiTiktokHex, SiTiktokHex,
SiYoutubeHex, SiYoutubeHex,
SiLinkedinHex, SiLinkedinHex,
SiMatrixHex,
SiGithubHex, SiGithubHex,
SiGiteaHex, SiGiteaHex,
SiLeetcodeHex, SiLeetcodeHex,
@@ -112,6 +113,10 @@ export const theme = {
light: SiLinkedinHex, light: SiLinkedinHex,
dark: SiLinkedinHex, dark: SiLinkedinHex,
}, },
matrix: {
light: SiMatrixHex,
dark: invertColor(SiMatrixHex),
},
github: { github: {
light: SiGithubHex, light: SiGithubHex,
dark: invertColor(SiGithubHex), dark: invertColor(SiGithubHex),
@@ -141,5 +146,17 @@ export const theme = {
} }
export const darkMode = ['class', '[data-theme="dark"]'] export const darkMode = ['class', '[data-theme="dark"]']
export const plugins = [require('daisyui')] export const plugins = [require('@tailwindcss/typography'), require('daisyui')]
export const daisyui = { themes: ['light', 'dark'] } export const daisyui = {
themes: [
'light',
{
dark: {
...require('daisyui/src/theming/themes')['dark'],
'base-100': '#212121',
'base-200': '#1d1d1d',
'base-300': '#191919',
},
},
],
}
+18 -7
View File
@@ -14,11 +14,18 @@
"@icons-pack/react-simple-icons": "^10.0.0", "@icons-pack/react-simple-icons": "^10.0.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@redwoodjs/auth-dbauth-web": "8.0.0", "@redwoodjs/auth-dbauth-web": "8.4.0",
"@redwoodjs/forms": "8.0.0", "@redwoodjs/forms": "8.4.0",
"@redwoodjs/router": "8.0.0", "@redwoodjs/router": "8.4.0",
"@redwoodjs/web": "8.0.0", "@redwoodjs/web": "8.4.0",
"@redwoodjs/web-server": "8.0.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/compressor": "^2.0.1",
"@uppy/core": "^4.1.0", "@uppy/core": "^4.1.0",
"@uppy/dashboard": "^4.0.2", "@uppy/dashboard": "^4.0.2",
@@ -27,16 +34,20 @@
"@uppy/progress-bar": "^4.0.0", "@uppy/progress-bar": "^4.0.0",
"@uppy/react": "^4.0.1", "@uppy/react": "^4.0.1",
"@uppy/tus": "^4.0.0", "@uppy/tus": "^4.0.0",
"@uppy/webcam": "^4.0.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"humanize-string": "2.1.0", "humanize-string": "2.1.0",
"react": "18.3.1", "react": "18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "18.3.1" "react-dom": "18.3.1",
"react-html-parser": "^2.0.2",
"react-typed": "^2.0.12"
}, },
"devDependencies": { "devDependencies": {
"@redwoodjs/vite": "8.0.0", "@redwoodjs/vite": "8.4.0",
"@types/react": "^18.2.55", "@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.2.19",
"@types/react-html-parser": "^2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"daisyui": "^4.12.10", "daisyui": "^4.12.10",
"postcss": "^8.4.41", "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
View File
@@ -5,14 +5,20 @@ import { AuthProvider, useAuth } from 'src/auth'
import FatalErrorPage from 'src/pages/FatalErrorPage' import FatalErrorPage from 'src/pages/FatalErrorPage'
import Routes from 'src/Routes' import Routes from 'src/Routes'
import 'src/scaffold.css'
import 'src/index.css' import 'src/index.css'
const App = () => ( const App = () => (
<FatalErrorBoundary page={FatalErrorPage}> <FatalErrorBoundary page={FatalErrorPage}>
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle"> <RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
<AuthProvider> <AuthProvider>
<RedwoodApolloProvider useAuth={useAuth}> <RedwoodApolloProvider
useAuth={useAuth}
graphQLClientConfig={{
httpLinkConfig: {
credentials: 'include',
},
}}
>
<Routes /> <Routes />
</RedwoodApolloProvider> </RedwoodApolloProvider>
</AuthProvider> </AuthProvider>
+14 -3
View File
@@ -3,7 +3,7 @@ import { Router, Route, Set, PrivateSet } from '@redwoodjs/router'
import { useAuth } from 'src/auth' import { useAuth } from 'src/auth'
import AccountbarLayout from 'src/layouts/AccountbarLayout' import AccountbarLayout from 'src/layouts/AccountbarLayout'
import NavbarLayout from 'src/layouts/NavbarLayout/NavbarLayout' import NavbarLayout from 'src/layouts/NavbarLayout/NavbarLayout'
import ScaffoldLayout from 'src/layouts/ScaffoldLayout' import ScaffoldLayout from 'src/layouts/ScaffoldLayout/ScaffoldLayout'
const Routes = () => { const Routes = () => {
return ( return (
@@ -27,11 +27,19 @@ const Routes = () => {
<Route path="/admin/portrait" page={PortraitPortraitPage} name="portrait" /> <Route path="/admin/portrait" page={PortraitPortraitPage} name="portrait" />
</Set> </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"> <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/new" page={ProjectNewProjectPage} name="newProject" />
<Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" /> <Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" />
<Route path="/admin/projects/{id:Int}" page={ProjectProjectPage} name="project" /> <Route path="/admin/projects/{id:Int}" page={ProjectAdminProjectPage} name="adminProject" />
<Route path="/admin/projects" page={ProjectProjectsPage} name="projects" /> <Route path="/admin/projects" page={ProjectAdminProjectsPage} name="adminProjects" />
</Set> </Set>
</PrivateSet> </PrivateSet>
@@ -49,7 +57,10 @@ const Routes = () => {
<Set wrap={NavbarLayout}> <Set wrap={NavbarLayout}>
<Route path="/" page={HomePage} name="home" /> <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="/contact" page={ContactPage} name="contact" />
<Route path="/resume" page={ResumeResumePage} name="resume" />
</Set> </Set>
<Route notfound page={NotFoundPage} /> <Route notfound page={NotFoundPage} />
+5 -1
View File
@@ -1,5 +1,9 @@
import { createDbAuthClient, createAuth } from '@redwoodjs/auth-dbauth-web' import { createDbAuthClient, createAuth } from '@redwoodjs/auth-dbauth-web'
const dbAuthClient = createDbAuthClient() const dbAuthClient = createDbAuthClient({
fetchConfig: {
credentials: 'include',
},
})
export const { AuthProvider, useAuth } = createAuth(dbAuthClient) 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" /> <HexColorInput color={color} className="w-16" />
</label> </label>
<button <button
type="button"
className="btn btn-square btn-sm" className="btn btn-square btn-sm"
onClick={async () => { onClick={async () => {
try { try {
@@ -35,10 +36,17 @@ const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
<Icon path={mdiContentCopy} className="size-4" /> <Icon path={mdiContentCopy} className="size-4" />
</button> </button>
<button <button
type="button"
className="btn btn-square btn-sm " className="btn btn-square btn-sm "
onClick={async () => { onClick={async () => {
try { 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 { } catch {
toast.error(`Failed to paste, please try again`) 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 { interface ContactCardProps {
portraitUrl: string portraitUrl: string
socials: ContactCardPortrait['socials']
} }
const ContactCard = ({ portraitUrl }: ContactCardProps) => { const ContactCard = ({ portraitUrl, socials }: ContactCardProps) => {
const [width, setWidth] = useState() const [width, setWidth] = useState()
const [height, setHeight] = useState() const [height, setHeight] = useState()
const observedDiv = useRef(null) const observedDiv = useRef(null)
useEffect(() => { useLayoutEffect(() => {
if (!observedDiv.current) return if (!observedDiv.current) return
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
@@ -56,7 +59,7 @@ const ContactCard = ({ portraitUrl }: ContactCardProps) => {
<img <img
className="contact-me-image aspect-portrait object-cover" className="contact-me-image aspect-portrait object-cover"
src={portraitUrl} src={portraitUrl}
alt={`${process.env.NAME}`} alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`}
/> />
</figure> </figure>
<div <div
@@ -68,7 +71,7 @@ const ContactCard = ({ portraitUrl }: ContactCardProps) => {
</h2> </h2>
<p className="p-2"></p> <p className="p-2"></p>
<div className="card-actions"> <div className="card-actions">
<SocialLinksCell /> <SocialLinks socials={socials} />
</div> </div>
</div> </div>
</div> </div>
@@ -1,4 +1,7 @@
import type { FindPortrait, FindPortraitVariables } from 'types/graphql' import type {
ContactCardPortrait,
ContactCardPortraitVariables,
} from 'types/graphql'
import type { import type {
TypedDocumentNode, TypedDocumentNode,
@@ -12,13 +15,19 @@ import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard' import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard'
export const QUERY: TypedDocumentNode< export const QUERY: TypedDocumentNode<
FindPortrait, ContactCardPortrait,
FindPortraitVariables ContactCardPortraitVariables
> = gql` > = gql`
query ContactCardPortrait { query ContactCardPortrait {
portrait: portrait { portrait {
fileId fileId
} }
socials {
id
name
type
username
}
} }
` `
@@ -26,12 +35,13 @@ export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty /> export const Empty = () => <CellEmpty />
export const Failure = ({ error }: CellFailureProps<FindPortrait>) => ( export const Failure = ({ error }: CellFailureProps<ContactCardPortrait>) => (
<CellFailure error={error} /> <CellFailure error={error} />
) )
export const Success = ({ export const Success = ({
portrait, portrait,
}: CellSuccessProps<FindPortrait, FindPortraitVariables>) => ( socials,
<ContactCard portraitUrl={portrait.fileId} /> }: CellSuccessProps<ContactCardPortrait, ContactCardPortraitVariables>) => (
<ContactCard portraitUrl={portrait.fileId} socials={socials} />
) )
@@ -4,6 +4,7 @@ import Icon from '@mdi/react'
interface FormTextListProps { interface FormTextListProps {
name: string name: string
hint?: string
itemPlaceholder: string itemPlaceholder: string
icon?: string icon?: string
list: string[] list: string[]
@@ -13,6 +14,7 @@ interface FormTextListProps {
const FormTextList = ({ const FormTextList = ({
name, name,
hint,
itemPlaceholder, itemPlaceholder,
icon, icon,
list, list,
@@ -23,15 +25,20 @@ const FormTextList = ({
<div className="flex flex-col space-y-2 bg-base-100 rounded-xl"> <div className="flex flex-col space-y-2 bg-base-100 rounded-xl">
<div className="flex space-x-2 justify-between"> <div className="flex space-x-2 justify-between">
<div className="flex items-center"> <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> </div>
<button
className="btn btn-square btn-sm"
type="button"
onClick={() => setList([...list, ''])}
>
<Icon path={mdiPlus} className="size-4" />
</button>
</div> </div>
{list.map((item, i) => ( {list.map((item, i) => (
<label <label
@@ -53,11 +60,11 @@ const FormTextList = ({
} }
/> />
<button <button
className="btn btn-square btn-sm flex-none" className="btn btn-square btn-error btn-sm flex-none"
type="button" type="button"
onClick={() => setList(list.filter((_, j) => j !== i))} onClick={() => setList(list.filter((_, j) => j !== i))}
> >
<Icon path={mdiDelete} className="size-4 text-error" /> <Icon path={mdiDelete} className="size-4" />
</button> </button>
</label> </label>
))} ))}
+19
View File
@@ -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 CellLoading from 'src/components/Cell/CellLoading/CellLoading'
import PortraitForm from 'src/components/Portrait/PortraitForm/PortraitForm' import PortraitForm from 'src/components/Portrait/PortraitForm/PortraitForm'
export const QUERY: TypedDocumentNode< export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
FindPortrait, gql`
FindPortraitVariables query FindPortrait {
> = gql` portrait {
query FindPortrait { id
portrait: portrait { fileId
id }
fileId
} }
} `
`
export const Loading = () => <CellLoading /> export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty /> export const Empty = () => <CellEmpty />
export const Failure = ({ error }: CellFailureProps<FindPortraitVariables>) => ( export const Failure = ({ error }: CellFailureProps<FindPortraitVariables>) => (
<CellFailure error={error} /> <CellFailure error={error} />
) )
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react' import { useState } from 'react'
import { Meta, UploadResult } from '@uppy/core' import { Meta, UploadResult } from '@uppy/core'
import type { import type {
@@ -15,6 +15,7 @@ import { TypedDocumentNode, useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast' import { toast } from '@redwoodjs/web/toast'
import Uploader from 'src/components/Uploader/Uploader' import Uploader from 'src/components/Uploader/Uploader'
import { deleteFile, handleBeforeUnload } from 'src/lib/tus'
interface PortraitFormProps { interface PortraitFormProps {
portrait?: Portrait portrait?: Portrait
@@ -54,14 +55,8 @@ const CREATE_PORTRAIT_MUTATION: TypedDocumentNode<
} }
` `
const PortraitForm = (props: PortraitFormProps) => { const PortraitForm = ({ portrait }: PortraitFormProps) => {
const [fileId, _setFileId] = useState<string>(props.portrait?.fileId) const [fileId, setFileId] = useState<string>(portrait?.fileId)
const fileIdRef = useRef(fileId)
const setFileId = (fileId: string) => {
_setFileId(fileId)
fileIdRef.current = fileId
}
const unloadAbortController = new AbortController() 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 = ( const onUploadComplete = (
result: UploadResult<Meta, Record<string, never>> result: UploadResult<Meta, Record<string, never>>
) => { ) => {
setFileId(result.successful[0]?.uploadURL) setFileId(result.successful[0]?.uploadURL)
window.addEventListener('beforeunload', handleBeforeUnload, { window.addEventListener(
once: true, 'beforeunload',
signal: unloadAbortController.signal, (e) => handleBeforeUnload(e, [fileId]),
}) {
once: true,
signal: unloadAbortController.signal,
}
)
} }
if (props.portrait?.fileId) if (portrait?.fileId)
return ( return (
<div className="mx-auto w-fit space-y-2"> <div className="mx-auto w-fit space-y-2">
<img <img
className="aspect-portrait max-w-2xl rounded-xl object-cover" className="aspect-portrait max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl rounded-xl object-cover"
src={props.portrait?.fileId} src={portrait?.fileId}
alt={`${process.env.NAME} Portrait`} alt={`${process.env.FIRST_NAME} Portrait`}
/> />
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
@@ -131,7 +113,7 @@ const PortraitForm = (props: PortraitFormProps) => {
className="btn btn-error btn-sm uppercase" className="btn btn-error btn-sm uppercase"
onClick={() => { onClick={() => {
if (confirm('Are you sure?')) { if (confirm('Are you sure?')) {
deleteFile(props.portrait?.fileId) deleteFile(portrait?.fileId)
deletePortrait() deletePortrait()
setFileId(null) setFileId(null)
} }
@@ -151,7 +133,7 @@ const PortraitForm = (props: PortraitFormProps) => {
<Uploader <Uploader
onComplete={onUploadComplete} onComplete={onUploadComplete}
width="22rem" width="22rem"
height="11.5rem" height="34.5rem"
className="flex justify-center" className="flex justify-center"
/> />
<p className="text-center"> <p className="text-center">
@@ -162,7 +144,7 @@ const PortraitForm = (props: PortraitFormProps) => {
<img <img
className="aspect-portrait max-w-2xl rounded-xl object-cover" className="aspect-portrait max-w-2xl rounded-xl object-cover"
src={fileId} src={fileId}
alt={`${process.env.NAME} Portrait`} alt={`${process.env.FIRST_NAME} Portrait`}
/> />
)} )}
{fileId && ( {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 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>&nbsp;</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 description
date date
links links
images
tags {
id
tag
color
}
} }
} }
` `
@@ -55,7 +61,7 @@ export const Success = ({ project }: CellSuccessProps<EditProjectById>) => {
{ {
onCompleted: () => { onCompleted: () => {
toast.success('Project updated') toast.success('Project updated')
navigate(routes.projects()) navigate(routes.adminProjects())
}, },
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
} }
@@ -28,7 +28,7 @@ const NewProject = () => {
{ {
onCompleted: () => { onCompleted: () => {
toast.success('Project created') toast.success('Project created')
navigate(routes.projects()) navigate(routes.adminProjects())
}, },
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
} }
+75 -98
View File
@@ -1,111 +1,88 @@
import type { import { mdiLinkVariant } from '@mdi/js'
DeleteProjectMutation, import Icon from '@mdi/react'
DeleteProjectMutationVariables, import { format, isAfter, startOfToday } from 'date-fns'
FindProjectById, import parseHtml from 'react-html-parser'
} from 'types/graphql' import type { FindProjectById } from 'types/graphql'
import { Link, routes, navigate } from '@redwoodjs/router' import { calculateLuminance } from 'src/lib/color'
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
}
}
`
interface Props { interface Props {
project: NonNullable<FindProjectById['project']> project: NonNullable<FindProjectById['project']>
} }
const Project = ({ project }: Props) => { 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 ( return (
<div className="flex w-full justify-center"> <div className="grid grid-rows-1 grid-cols-1 sm:grid-cols-2">
<div> <div className="flex flex-col gap-8 p-8">
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100"> <h1 className="text-5xl font-bold font-syne w-fit">{project.title}</h1>
<table className="table"> <div className="flex flex-wrap gap-2 w-fit">
<thead className="bg-base-200 font-syne"> {isAfter(new Date(project.date), startOfToday()) && (
<tr> <div className="badge badge-lg badge-info whitespace-nowrap">
<th className="w-0"> planned
Project {project.id}: {project.title} </div>
</th> )}
<th>&nbsp;</th> <div className="badge badge-lg badge-ghost whitespace-nowrap">
</tr> {format(project.date, 'PPP')}
</thead> </div>
<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) => (
<a
href={link}
target="_blank"
className="badge badge-ghost text-nowrap"
key={i}
rel="noreferrer"
>
{link}
</a>
))}
</td>
</tr>
</tbody>
</table>
</div> </div>
<nav className="my-2 flex justify-center space-x-2"> {project.tags.length > 0 && (
<Link <div className="flex flex-wrap gap-2 w-fit">
to={routes.editProject({ id: project.id })} {project.tags.map((tag, i) => (
title={'Edit project ' + project.id} <div
className="btn btn-primary btn-sm uppercase" 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>
</>
)}
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
</div>
<div className="flex flex-wrap gap-4 items-center pt-8 justify-center">
{project.images.map((image, i) => (
<a
href={image}
target="_blank"
rel="noreferrer"
key={i}
className="rounded-xl"
> >
Edit <img src={image} alt="" className="rounded-xl" />
</Link> </a>
<button ))}
type="button"
title={'Delete project ' + project.id}
className="btn btn-error btn-sm uppercase"
onClick={() => onDeleteClick(project.id)}
>
Delete
</button>
</nav>
</div> </div>
</div> </div>
) )
@@ -9,7 +9,7 @@ import type {
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty' import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
import CellFailure from 'src/components/Cell/CellFailure/CellFailure' import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
import CellLoading from 'src/components/Cell/CellLoading/CellLoading' import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
import Project from 'src/components/Project/Project' import Project from 'src/components/Project/Project/Project'
export const QUERY: TypedDocumentNode< export const QUERY: TypedDocumentNode<
FindProjectById, FindProjectById,
@@ -22,6 +22,12 @@ export const QUERY: TypedDocumentNode<
description description
date date
links links
images
tags {
id
tag
color
}
} }
} }
` `
@@ -1,28 +1,32 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { mdiCalendar, mdiFormatTitle, mdiLinkVariant } from '@mdi/js' import { mdiCalendar, mdiDelete, mdiFormatTitle, mdiLinkVariant } from '@mdi/js'
import Icon from '@mdi/react' import Icon from '@mdi/react'
import Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline'
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Meta, UploadResult } from '@uppy/core'
import { format, isAfter, startOfToday } from 'date-fns' import { format, isAfter, startOfToday } from 'date-fns'
import type { EditProjectById, UpdateProjectInput } from 'types/graphql' import type {
EditProjectById,
FindTags,
UpdateProjectInput,
} from 'types/graphql'
import type { RWGqlError } from '@redwoodjs/forms' import type { RWGqlError } from '@redwoodjs/forms'
import { import { Form, FieldError, Label, TextField, Submit } from '@redwoodjs/forms'
Form,
FieldError,
Label,
TextField,
Submit,
TextAreaField,
} from '@redwoodjs/forms'
import { toast } from '@redwoodjs/web/toast' import { toast } from '@redwoodjs/web/toast'
import DatePicker from 'src/components/DatePicker/DatePicker' import DatePicker from 'src/components/DatePicker'
import FormTextList from 'src/components/FormTextList/FormTextList' 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']> type FormProject = NonNullable<EditProjectById['project']>
// TODO: add project images
interface ProjectFormProps { interface ProjectFormProps {
project?: EditProjectById['project'] project?: EditProjectById['project']
onSave: (data: UpdateProjectInput, id?: FormProject['id']) => void onSave: (data: UpdateProjectInput, id?: FormProject['id']) => void
@@ -33,6 +37,20 @@ interface ProjectFormProps {
const ProjectForm = (props: ProjectFormProps) => { const ProjectForm = (props: ProjectFormProps) => {
const today = startOfToday() 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 [links, setLinks] = useState<string[]>(props.project?.links || [])
const [linkErrors, setLinkErrors] = useState<boolean[]>([]) const [linkErrors, setLinkErrors] = useState<boolean[]>([])
const [pickerVisible, setPickerVisible] = useState<boolean>(false) const [pickerVisible, setPickerVisible] = useState<boolean>(false)
@@ -40,6 +58,23 @@ const ProjectForm = (props: ProjectFormProps) => {
props.project?.date ? new Date(props.project.date) : today props.project?.date ? new Date(props.project.date) : today
) )
const [month, setMonth] = useState<string>(format(today, 'MMMM yyyy')) 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( const urlRegex = useMemo(
() => () =>
@@ -59,9 +94,22 @@ const ProjectForm = (props: ProjectFormProps) => {
if (errorsExist) return toast.error(`${errorCount} links invalid`) if (errorsExist) return toast.error(`${errorCount} links invalid`)
if (emptyCount > 0) return toast.error(`${emptyCount} links empty`) if (emptyCount > 0) return toast.error(`${emptyCount} links empty`)
data.links = links batchDelete(toDelete)
data.date = date.toISOString()
props.onSave(data, props?.project?.id) 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) const titleRef = useRef<HTMLInputElement>(null)
@@ -108,21 +156,7 @@ const ProjectForm = (props: ProjectFormProps) => {
</div> </div>
</Label> </Label>
<Label name="description" className="form-control w-full"> <RichTextEditor editor={descEditor} />
<TextAreaField
name="description"
defaultValue={props.project?.description}
className="textarea textarea-bordered"
errorClassName="textarea textarea-bordered textarea-error"
placeholder="Description"
/>
<div className="label">
<FieldError
name="description"
className="text-xs font-semibold text-error"
/>
</div>
</Label>
<div className="form-control w-full"> <div className="form-control w-full">
<Label <Label
@@ -162,6 +196,7 @@ const ProjectForm = (props: ProjectFormProps) => {
<div className={`${!pickerVisible && 'pt-2'}`}> <div className={`${!pickerVisible && 'pt-2'}`}>
<FormTextList <FormTextList
name="Links" name="Links"
hint="Short links are recommended"
itemPlaceholder="URL" itemPlaceholder="URL"
icon={mdiLinkVariant} icon={mdiLinkVariant}
list={links} list={links}
@@ -170,6 +205,82 @@ const ProjectForm = (props: ProjectFormProps) => {
/> />
</div> </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) && ( {isAfter(date, today) && (
<div className="flex justify-center py-2"> <div className="flex justify-center py-2">
<p>Project will be marked as</p> <p>Project will be marked as</p>
+109 -70
View File
@@ -1,5 +1,7 @@
import { mdiDotsVertical } from '@mdi/js' import { mdiDotsVertical } from '@mdi/js'
import Icon from '@mdi/react' import Icon from '@mdi/react'
import { isAfter } from 'date-fns'
import parseHtml from 'react-html-parser'
import type { import type {
DeleteProjectMutation, DeleteProjectMutation,
DeleteProjectMutationVariables, DeleteProjectMutationVariables,
@@ -12,7 +14,9 @@ import type { TypedDocumentNode } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast' import { toast } from '@redwoodjs/web/toast'
import { QUERY } from 'src/components/Project/ProjectsCell' import { QUERY } from 'src/components/Project/ProjectsCell'
import { calculateLuminance } from 'src/lib/color'
import { timeTag, truncate } from 'src/lib/formatters' import { timeTag, truncate } from 'src/lib/formatters'
import { batchDelete } from 'src/lib/tus'
const DELETE_PROJECT_MUTATION: TypedDocumentNode< const DELETE_PROJECT_MUTATION: TypedDocumentNode<
DeleteProjectMutation, DeleteProjectMutation,
@@ -34,8 +38,10 @@ const ProjectsList = ({ projects }: FindProjects) => {
}) })
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => { 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 } }) deleteProject({ variables: { id } })
}
} }
return ( return (
@@ -46,84 +52,117 @@ const ProjectsList = ({ projects }: FindProjects) => {
<th>Title</th> <th>Title</th>
<th>Description</th> <th>Description</th>
<th>Date</th> <th>Date</th>
<th>Images</th>
<th>Tags</th>
<th>Links</th> <th>Links</th>
<th className="w-0">&nbsp;</th> <th className="w-0">&nbsp;</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{projects.map((project) => { {projects
const actionButtons = ( .slice()
<> .sort((a, b) =>
<Link isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1
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>
</>
) )
.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 ( return (
<tr key={project.id}> <tr key={project.id}>
<td>{truncate(project.title)}</td> <td>{truncate(project.title)}</td>
<td>{truncate(project.description)}</td> <td className="max-w-72">
<td>{timeTag(project.date)}</td> <article className="prose text-sm line-clamp-3">
<td className="space-x-2 space-y-2"> {parseHtml(project.description)}
{project.links.map((link, i) => ( </article>
<a </td>
href={link} <td className="max-w-36">{timeTag(project.date)}</td>
target="_blank" <td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td>
className="badge badge-ghost text-nowrap" <td>
key={i} <div className="flex flex-wrap gap-2">
rel="noreferrer" {project.tags.map((tag, i) => (
> <div
{link} key={i}
</a> className="badge whitespace-nowrap"
))} style={{
</td> backgroundColor: tag.color,
<td> color:
<nav className="hidden justify-end space-x-2 sm:flex"> calculateLuminance(tag.color) > 0.5
{actionButtons} ? 'black'
</nav> : 'white',
<div className="dropdown dropdown-end flex justify-end sm:hidden"> }}
<div >
tabIndex={0} {tag.tag}
role="button" </div>
className="btn btn-square btn-ghost btn-sm lg:hidden" ))}
>
<Icon
path={mdiDotsVertical}
className="text-base-content-100 size-6"
/>
</div> </div>
<div </td>
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex <td>
tabIndex={0} <div className="flex flex-wrap gap-2">
className="menu dropdown-content z-10 -mt-1 mr-10 inline rounded-box bg-base-100 shadow-xl" {project.links.map((link, i) => (
> <a
<nav className="w-46 space-x-2">{actionButtons}</nav> href={link}
target="_blank"
className="badge badge-ghost text-nowrap"
key={i}
rel="noreferrer"
>
{link}
</a>
))}
</div> </div>
</div> </td>
</td> <td>
</tr> <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>
</td>
</tr>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -18,8 +18,14 @@ export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> =
id id
title title
description description
images
date date
links 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,44 @@
import type { FindProjects, FindProjectsVariables } 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 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>) => (
<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,33 @@
import type { FindResume, FindResumeVariables } 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 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>) => (
<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
@@ -16,7 +16,7 @@ import type { EditSocialById, UpdateSocialInput } from 'types/graphql'
import type { RWGqlError } from '@redwoodjs/forms' import type { RWGqlError } from '@redwoodjs/forms'
import { Form, FieldError, Label, TextField, Submit } 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']> type FormSocial = NonNullable<EditSocialById['social']>
@@ -38,6 +38,7 @@ const types: FormSocial['type'][] = [
'discord', 'discord',
'twitch', 'twitch',
'linkedin', 'linkedin',
'matrix',
'github', 'github',
'gitea', 'gitea',
'forgejo', 'forgejo',
@@ -52,6 +53,15 @@ const types: FormSocial['type'][] = [
const urlHandles: FormSocial['type'][] = ['custom', 'gitea', 'forgejo'] const urlHandles: FormSocial['type'][] = ['custom', 'gitea', 'forgejo']
const SocialForm = (props: SocialFormProps) => { const SocialForm = (props: SocialFormProps) => {
const emailRegex =
/^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/
const phoneRegex = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/
const urlRegex =
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i
const matrixRegex =
/^([#@][a-zA-Z0-9_\-\.]+):([a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,})$/
const [type, setType] = useState<FormSocial['type']>( const [type, setType] = useState<FormSocial['type']>(
props.social?.type ?? 'x' props.social?.type ?? 'x'
) )
@@ -177,17 +187,20 @@ const SocialForm = (props: SocialFormProps) => {
pattern: { pattern: {
value: value:
type == 'email' type == 'email'
? /^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/ ? emailRegex
: type == 'phone' : type == 'phone'
? /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/ ? phoneRegex
: urlHandles.includes(type) && : urlHandles.includes(type)
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i, ? urlRegex
: type == 'matrix' && matrixRegex,
message: `Invalid ${ message: `Invalid ${
urlHandles.includes(type) urlHandles.includes(type)
? 'URL' ? 'URL'
: type == 'phone' : type == 'phone'
? 'Phone Number' ? 'phone number'
: type == 'email' && 'Email' : type == 'email'
? 'Email'
: type == 'matrix' && 'Matrix identifier'
}`, }`,
}, },
}} }}
@@ -233,19 +246,21 @@ const SocialForm = (props: SocialFormProps) => {
tabIndex={0} 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" 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) => ( {types
<li key={i}> .sort((a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b))
<button .map((type, i) => (
className="btn btn-square btn-ghost" <li key={i}>
onClick={() => { <button
setType(type) className="btn btn-square btn-ghost"
setTypesDropdownOpen(false) onClick={() => {
}} setType(type)
> setTypesDropdownOpen(false)
{getLogoComponent(type)} }}
</button> >
</li> {getLogoComponent(type)}
))} </button>
</li>
))}
</ul> </ul>
)} )}
</div> </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) => { const SocialLinks = ({ socials }: ContactCardPortrait) => (
return ( <div className={`grid max-w-68 grid-cols-[repeat(auto-fit,3rem)] gap-2`}>
<div className={`grid max-w-68 grid-cols-[repeat(auto-fit,3rem)] gap-2`}> {[...socials]
{[...socials] .sort((a, b) => sortOrder.indexOf(a.type) - sortOrder.indexOf(b.type))
.sort((a, b) => (a.type > b.type ? 1 : -1)) .map((social, i) => (
.map((social, i) => ( <div key={i} className="tooltip" data-tip={social.name}>
<div key={i} className="tooltip" data-tip={social.name}> <a
<a className="btn btn-square"
className="btn btn-square" href={`${baseUrls[social.type]}${social.username}`}
href={`${baseUrls[social.type]}${social.username}`} target="_blank"
target="_blank" rel="noreferrer"
rel="noreferrer" >
> {getLogoComponent(social.type)}
{getLogoComponent(social.type)} </a>
</a> </div>
</div> ))}
))} </div>
</div> )
)
}
export default SocialLinks 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} />
)
+62 -58
View File
@@ -13,7 +13,7 @@ import { toast } from '@redwoodjs/web/toast'
import { QUERY } from 'src/components/Social/SocialsCell' import { QUERY } from 'src/components/Social/SocialsCell'
import { truncate } from 'src/lib/formatters' import { truncate } from 'src/lib/formatters'
import { getLogoComponent } from 'src/lib/handle' import { getLogoComponent, sortOrder } from 'src/lib/handle'
const DELETE_SOCIAL_MUTATION: TypedDocumentNode< const DELETE_SOCIAL_MUTATION: TypedDocumentNode<
DeleteSocialMutation, DeleteSocialMutation,
@@ -58,66 +58,70 @@ const SocialsList = ({ socials }: FindSocials) => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{socials.map((social) => { {[...socials]
const actionButtons = ( .sort(
<> (a, b) => sortOrder.indexOf(a.type) - sortOrder.indexOf(b.type)
<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>
</>
) )
.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 ( return (
<tr key={social.id}> <tr key={social.id}>
<th>{getLogoComponent(social.type)}</th> <th>{getLogoComponent(social.type)}</th>
<td>{truncate(social.name)}</td> <td>{truncate(social.name)}</td>
<td>{truncate(social.username)}</td> <td>{truncate(social.username)}</td>
<td> <td>
<nav className="hidden justify-end space-x-2 sm:flex"> <nav className="hidden justify-end space-x-2 sm:flex">
{actionButtons} {actionButtons}
</nav> </nav>
<div className="dropdown dropdown-end flex justify-end sm:hidden"> <div className="dropdown dropdown-end flex justify-end sm:hidden">
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
className="btn btn-square btn-ghost btn-sm lg:hidden" className="btn btn-square btn-ghost btn-sm lg:hidden"
> >
<Icon <Icon
path={mdiDotsVertical} path={mdiDotsVertical}
className="text-base-content-100 size-6" 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>
<div </td>
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex </tr>
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>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>
+1 -1
View File
@@ -65,7 +65,7 @@ const Tag = ({ tag }: Props) => {
<th>Color</th> <th>Color</th>
<td> <td>
<div <div
className="badge" className="badge whitespace-nowrap"
style={{ style={{
backgroundColor: tag.color, backgroundColor: tag.color,
color: color:
+1 -50
View File
@@ -96,7 +96,7 @@ const TagsList = ({ tags }: FindTags) => {
<td>{truncate(tag.tag)}</td> <td>{truncate(tag.tag)}</td>
<td> <td>
<div <div
className="badge" className="badge whitespace-nowrap"
style={{ style={{
backgroundColor: tag.color, backgroundColor: tag.color,
color: color:
@@ -137,55 +137,6 @@ const TagsList = ({ tags }: FindTags) => {
</table> </table>
</div> </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>&nbsp;</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 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}
/>
)
+12 -11
View File
@@ -3,14 +3,20 @@ import { useState, useEffect } from 'react'
import { mdiWeatherSunny, mdiWeatherNight } from '@mdi/js' import { mdiWeatherSunny, mdiWeatherNight } from '@mdi/js'
import Icon from '@mdi/react' import Icon from '@mdi/react'
const LIGHT_THEME = 'light'
const DARK_THEME = 'dark'
const ThemeToggle = () => { const ThemeToggle = () => {
const [theme, setTheme] = useState( const [theme, setTheme] = useState(
localStorage.getItem('theme') ? localStorage.getItem('theme') : 'light' (localStorage.getItem('theme') ??
window.matchMedia('(prefers-color-scheme: dark)').matches)
? DARK_THEME
: LIGHT_THEME
) )
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => { const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) setTheme('dark') if (e.target.checked) setTheme(DARK_THEME)
else setTheme('light') else setTheme(LIGHT_THEME)
} }
useEffect(() => { useEffect(() => {
@@ -31,16 +37,11 @@ const ThemeToggle = () => {
<input <input
type="checkbox" type="checkbox"
className="theme-controller" className="theme-controller"
checked={theme === 'dark'} checked={theme === DARK_THEME}
onChange={handleToggle} onChange={handleToggle}
/> />
<Icon path={mdiWeatherSunny} className="swap-off size-8 text-warning" />
<Icon <Icon path={mdiWeatherNight} className="swap-on size-8 text-primary" />
path={mdiWeatherSunny}
className="swap-off size-8 text-yellow-500"
/>
<Icon path={mdiWeatherNight} className="swap-on size-8 text-blue-500" />
</label> </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&apos;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&apos;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
+15 -6
View File
@@ -5,11 +5,15 @@ import Uppy from '@uppy/core'
import type { UploadResult, Meta } from '@uppy/core' import type { UploadResult, Meta } from '@uppy/core'
import { Dashboard } from '@uppy/react' import { Dashboard } from '@uppy/react'
import Tus from '@uppy/tus' import Tus from '@uppy/tus'
import Webcam from '@uppy/webcam'
import { isProduction } from '@redwoodjs/api/logger' import { isProduction } from '@redwoodjs/api/logger'
import '@uppy/core/dist/style.min.css' import '@uppy/core/dist/style.min.css'
import '@uppy/dashboard/dist/style.min.css' import '@uppy/dashboard/dist/style.min.css'
import '@uppy/webcam/dist/style.min.css'
type FileType = 'image' | 'pdf'
interface Props { interface Props {
onComplete?(result: UploadResult<Meta, Record<string, never>>): void onComplete?(result: UploadResult<Meta, Record<string, never>>): void
@@ -19,6 +23,7 @@ interface Props {
maxFiles?: number maxFiles?: number
disabled?: boolean disabled?: boolean
hidden?: boolean hidden?: boolean
type?: FileType
} }
const apiDomain = isProduction const apiDomain = isProduction
@@ -33,16 +38,15 @@ const Uploader = ({
disabled = false, disabled = false,
hidden = false, hidden = false,
maxFiles = 1, maxFiles = 1,
type = 'image',
}: Props) => { }: Props) => {
const [uppy] = useState(() => { const [uppy] = useState(() => {
const instance = new Uppy({ const instance = new Uppy({
restrictions: { restrictions: {
allowedFileTypes: [ allowedFileTypes:
'image/webp', type === 'image'
'image/png', ? ['image/webp', 'image/png', 'image/jpg', 'image/jpeg']
'image/jpg', : type === 'pdf' && ['application/pdf'],
'image/jpeg',
],
maxNumberOfFiles: maxFiles, maxNumberOfFiles: maxFiles,
maxFileSize: 25 * 1024 * 1024, maxFileSize: 25 * 1024 * 1024,
}, },
@@ -67,6 +71,11 @@ const Uploader = ({
mimeType: 'image/webp', mimeType: 'image/webp',
}) })
if (type === 'image')
instance.use(Webcam, {
modes: ['picture'],
})
return instance.on('complete', onComplete) return instance.on('complete', onComplete)
}) })
+8
View File
@@ -15,3 +15,11 @@
.w-52 .react-colorful { .w-52 .react-colorful {
width: 13rem; width: 13rem;
} }
.image-full-no-overlay::before {
background: none !important;
}
.ProseMirror:focus {
outline: none;
}
@@ -11,7 +11,7 @@ const AccountbarLayout = ({ title, children }: AccountbarLayoutProps) => {
<> <>
<ToasterWrapper /> <ToasterWrapper />
<div className="sticky top-0 z-50 p-2"> <div className="sticky top-0 z-50 p-2">
<div className="navbar rounded-xl bg-base-300 shadow-xl"> <div className="navbar rounded-xl bg-base-300 backdrop-blur bg-opacity-90 shadow-xl">
<div className="navbar-start"> <div className="navbar-start">
<p className="btn btn-ghost font-syne text-xl sm:hidden">{title}</p> <p className="btn btn-ghost font-syne text-xl sm:hidden">{title}</p>
</div> </div>
+40 -33
View File
@@ -20,6 +20,14 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
const { isAuthenticated, logOut } = useAuth() const { isAuthenticated, logOut } = useAuth()
const navbarRoutes: NavbarRoute[] = [ const navbarRoutes: NavbarRoute[] = [
{
name: 'Projects',
path: routes.projects(),
},
{
name: 'Resume',
path: routes.resume(),
},
{ {
name: 'Contact', name: 'Contact',
path: routes.contact(), path: routes.contact(),
@@ -33,7 +41,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
}, },
{ {
name: 'Projects', name: 'Projects',
path: routes.projects(), path: routes.adminProjects(),
}, },
{ {
name: 'Tags', name: 'Tags',
@@ -43,6 +51,14 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
name: 'Portrait', name: 'Portrait',
path: routes.portrait(), path: routes.portrait(),
}, },
{
name: 'Titles',
path: routes.titles(),
},
{
name: 'Resume',
path: routes.adminResume(),
},
] ]
const navbarButtons = () => const navbarButtons = () =>
@@ -54,18 +70,16 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
const navbarAdminButtons = () => const navbarAdminButtons = () =>
navbarAdminRoutes.map((route, i) => ( navbarAdminRoutes.map((route, i) => (
<li key={i}> <Link key={i} to={route.path} className="btn btn-ghost btn-sm">
<Link to={route.path} className="btn btn-ghost btn-sm"> {route.name}
{route.name} </Link>
</Link>
</li>
)) ))
return ( return (
<> <>
<ToasterWrapper /> <ToasterWrapper />
<div className="sticky top-0 z-50 p-2"> <div className="sticky top-0 z-50 p-2">
<div className="navbar rounded-xl bg-base-300 shadow-xl"> <div className="navbar rounded-xl bg-base-300 backdrop-blur bg-opacity-90 shadow-xl">
<div className="navbar-start space-x-2 lg:first:space-x-0"> <div className="navbar-start space-x-2 lg:first:space-x-0">
<div className="dropdown"> <div className="dropdown">
<div <div
@@ -78,31 +92,24 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
<div <div
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0} 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()} {navbarButtons()}
{isAuthenticated && ( {isAuthenticated && (
<div className="dropdown sm:hidden"> <>
<div <p className="btn btn-active no-animation btn-sm btn-block">
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex Admin
tabIndex={0} </p>
className="menu dropdown-content -ml-2 mt-4 w-36 space-y-2 rounded-box bg-base-200 shadow-xl" {navbarAdminButtons()}
> <button onClick={logOut} className="btn btn-error btn-sm">
<p className="btn btn-active no-animation btn-sm btn-block"> Logout
Admin </button>
</p> </>
{navbarAdminButtons()}
<li>
<button
onClick={logOut}
className="btn btn-ghost btn-sm"
>
Logout
</button>
</li>
</div>
</div>
)} )}
</div> </div>
</div> </div>
@@ -110,7 +117,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
to={routes.home()} to={routes.home()}
className="btn btn-ghost hidden font-syne text-xl sm:flex" className="btn btn-ghost hidden font-syne text-xl sm:flex"
> >
{process.env.NAME} {process.env.FIRST_NAME + ' ' + process.env.LAST_NAME}
</Link> </Link>
</div> </div>
<div className="navbar-center"> <div className="navbar-center">
@@ -118,7 +125,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
to={routes.home()} to={routes.home()}
className="btn btn-ghost font-syne text-xl sm:hidden" className="btn btn-ghost font-syne text-xl sm:hidden"
> >
{process.env.NAME} {process.env.FIRST_NAME + ' ' + process.env.LAST_NAME}
</Link> </Link>
</div> </div>
<div className="navbar-center hidden lg:flex"> <div className="navbar-center hidden lg:flex">
@@ -126,7 +133,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
</div> </div>
<div className="navbar-end space-x-2"> <div className="navbar-end space-x-2">
{isAuthenticated && ( {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}> <button className="btn btn-square btn-ghost" onClick={logOut}>
<Icon path={mdiLogout} className="size-8" /> <Icon path={mdiLogout} className="size-8" />
</button> </button>
@@ -141,7 +148,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
<ul <ul
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0} 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()} {navbarAdminButtons()}
</ul> </ul>
@@ -25,7 +25,7 @@ const ScaffoldLayout = ({
<> <>
<ToasterWrapper /> <ToasterWrapper />
<div className="sticky top-0 z-50 p-2"> <div className="sticky top-0 z-50 p-2">
<div className="navbar rounded-xl bg-base-300 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"> <div className="navbar-start space-x-2">
<Link to={routes.home()} className="btn btn-square btn-ghost"> <Link to={routes.home()} className="btn btn-square btn-ghost">
<Icon className="size-8" path={mdiHome} /> <Icon className="size-8" path={mdiHome} />
+27 -1
View File
@@ -8,6 +8,7 @@ import {
SiTiktok, SiTiktok,
SiYoutube, SiYoutube,
SiLinkedin, SiLinkedin,
SiMatrix,
SiGithub, SiGithub,
SiGitea, SiGitea,
SiLeetcode, SiLeetcode,
@@ -20,7 +21,7 @@ import {
} from '@icons-pack/react-simple-icons' } from '@icons-pack/react-simple-icons'
import { mdiEmail, mdiLink, mdiPhone } from '@mdi/js' import { mdiEmail, mdiLink, mdiPhone } from '@mdi/js'
import Icon from '@mdi/react' import Icon from '@mdi/react'
import type { Handle } from 'types/graphql' import type { Handle, Social } from 'types/graphql'
export const baseUrls: Record<Handle, string> = { export const baseUrls: Record<Handle, string> = {
x: 'https://x.com/', x: 'https://x.com/',
@@ -33,6 +34,7 @@ export const baseUrls: Record<Handle, string> = {
discord: 'https://discord.gg/', discord: 'https://discord.gg/',
twitch: 'https://www.twitch.tv/', twitch: 'https://www.twitch.tv/',
linkedin: 'https://www.linkedin.com/in/', linkedin: 'https://www.linkedin.com/in/',
matrix: 'https://matrix.to/#/',
github: 'https://github.com/', github: 'https://github.com/',
gitea: '', gitea: '',
forgejo: '', forgejo: '',
@@ -61,6 +63,7 @@ const logoComponents: Record<Handle, ReactElement> = {
linkedin: ( linkedin: (
<SiLinkedin className="text-linkedin-light dark:text-linkedin-dark" /> <SiLinkedin className="text-linkedin-light dark:text-linkedin-dark" />
), ),
matrix: <SiMatrix className="text-matrix-light dark:text-matrix-dark" />,
github: <SiGithub className="text-github-light dark:text-github-dark" />, github: <SiGithub className="text-github-light dark:text-github-dark" />,
gitea: <SiGitea className="text-gitea-light dark:text-gitea-dark" />, gitea: <SiGitea className="text-gitea-light dark:text-gitea-dark" />,
forgejo: <SiForgejo className="text-forgejo-light dark:text-forgejo-dark" />, forgejo: <SiForgejo className="text-forgejo-light dark:text-forgejo-dark" />,
@@ -76,4 +79,27 @@ const logoComponents: Record<Handle, ReactElement> = {
custom: <Icon path={mdiLink} 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] export const getLogoComponent = (type: Handle) => logoComponents[type]
+29
View File
@@ -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 */
}
}
}
}
@@ -13,45 +13,6 @@ import { DevFatalErrorPage } from '@redwoodjs/web/dist/components/DevFatalErrorP
export default DevFatalErrorPage || export default DevFatalErrorPage ||
(() => ( (() => (
<main> <main>
<style <span>Something went wrong</span>
dangerouslySetInnerHTML={{
__html: `
html, body {
margin: 0;
}
html * {
box-sizing: border-box;
}
main {
display: flex;
align-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
text-align: center;
background-color: #E2E8F0;
height: 100vh;
}
section {
background-color: white;
border-radius: 0.25rem;
width: 32rem;
padding: 1rem;
margin: 0 auto;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
h1 {
font-size: 2rem;
margin: 0;
font-weight: 500;
line-height: 1;
color: #2D3748;
}
`,
}}
/>
<section>
<h1>
<span>Something went wrong</span>
</h1>
</section>
</main> </main>
)) ))
@@ -72,7 +72,9 @@ const ForgotPasswordPage = () => {
<FieldError name="username" className="text-sm text-error" /> <FieldError name="username" className="text-sm text-error" />
<div className="flex w-full"> <div className="flex w-full">
<Submit className="btn btn-primary mx-auto">Submit</Submit> <Submit className="btn btn-primary btn-sm mx-auto uppercase">
Submit
</Submit>
</div> </div>
</Form> </Form>
</div> </div>
+46 -7
View File
@@ -1,11 +1,50 @@
import { mdiCompass, mdiContacts } from '@mdi/js'
import Icon from '@mdi/react'
import { Link, routes } from '@redwoodjs/router'
import { Metadata } from '@redwoodjs/web' import { Metadata } from '@redwoodjs/web'
const HomePage = () => { import TitlesCell from 'src/components/Title/TitlesCell'
return ( import { getLogoComponent } from 'src/lib/handle'
<>
<Metadata title="Home" /> const HomePage = () => (
</> <>
) <Metadata title="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>
</>
)
export default HomePage export default HomePage
+3 -1
View File
@@ -103,7 +103,9 @@ const LoginPage = () => {
<FieldError name="password" className="text-sm text-error" /> <FieldError name="password" className="text-sm text-error" />
<div className="flex w-full"> <div className="flex w-full">
<Submit className="btn btn-primary btn-sm mx-auto">Log In</Submit> <Submit className="btn btn-primary btn-sm mx-auto uppercase">
Log In
</Submit>
</div> </div>
</Form> </Form>
</div> </div>
@@ -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
@@ -2,15 +2,18 @@ import { Metadata } from '@redwoodjs/web'
import ProjectCell from 'src/components/Project/ProjectCell' import ProjectCell from 'src/components/Project/ProjectCell'
type ProjectPageProps = { interface ProjectPageProps {
id: number id: number
} }
const ProjectPage = ({ id }: ProjectPageProps) => ( const ProjectPage = ({ id }: ProjectPageProps) => {
<> return (
<Metadata title={`Project ${id}`} /> <>
<ProjectCell id={id} /> <Metadata title="Project" />
</>
) <ProjectCell id={id} />
</>
)
}
export default ProjectPage export default ProjectPage
@@ -1,12 +1,33 @@
import mobile from 'is-mobile'
import { Metadata } from '@redwoodjs/web' import { Metadata } from '@redwoodjs/web'
import ProjectsCell from 'src/components/Project/ProjectsCell' import ProjectsShowcaseCell from 'src/components/Project/ProjectsShowcaseCell'
const ProjectsPage = () => ( const ProjectsPage = () => {
<> return (
<Metadata title="Projects" /> <>
<ProjectsCell /> <Metadata title="Projects" />
</>
) <div className="hero min-h-64">
<div className="hero-content">
<div className="max-w-md text-center">
<h1 className="text-5xl font-bold">Projects</h1>
<p className="py-6">
{mobile({
tablet: true,
})
? 'Tap'
: 'Click'}{' '}
on a project for details
</p>
</div>
</div>
</div>
<ProjectsShowcaseCell />
</>
)
}
export default ProjectsPage export default ProjectsPage
@@ -102,7 +102,7 @@ const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
<div className="flex w-full"> <div className="flex w-full">
<Submit <Submit
className={`btn btn-primary mx-auto ${ className={`btn btn-primary btn-sm uppercase mx-auto ${
!enabled ? 'btn-disabled' : '' !enabled ? 'btn-disabled' : ''
}`} }`}
disabled={!enabled} disabled={!enabled}
@@ -0,0 +1,14 @@
import { Metadata } from '@redwoodjs/web'
import AdminResumeCell from 'src/components/Resume/AdminResumeCell/AdminResumeCell'
const ResumePage = () => {
return (
<>
<Metadata title="Resume" />
<AdminResumeCell />
</>
)
}
export default ResumePage
@@ -0,0 +1,15 @@
import { Metadata } from '@redwoodjs/web'
import ResumeCell from 'src/components/Resume/ResumeCell'
const ResumePage = () => {
return (
<>
<Metadata title="Resume" />
<ResumeCell />
</>
)
}
export default ResumePage
@@ -0,0 +1,12 @@
import { Metadata } from '@redwoodjs/web'
import AdminTitlesCell from 'src/components/Title/AdminTitlesCell'
const TitlesPage = () => (
<>
<Metadata title="Portrait" />
<AdminTitlesCell />
</>
)
export default TitlesPage
-243
View File
@@ -1,243 +0,0 @@
.rw-scaffold {
@apply bg-white text-gray-600;
}
.rw-scaffold h1,
.rw-scaffold h2 {
@apply m-0;
}
.rw-scaffold a {
@apply bg-transparent;
}
.rw-scaffold ul {
@apply m-0 p-0;
}
.rw-scaffold input:-ms-input-placeholder {
@apply text-gray-500;
}
.rw-scaffold input::-ms-input-placeholder {
@apply text-gray-500;
}
.rw-scaffold input::placeholder {
@apply text-gray-500;
}
.rw-header {
@apply flex justify-between px-8 py-4;
}
.rw-main {
@apply mx-4 pb-4;
}
.rw-segment {
@apply w-full overflow-hidden rounded-lg border border-gray-200;
scrollbar-color: theme('colors.zinc.400') transparent;
}
.rw-segment::-webkit-scrollbar {
height: initial;
}
.rw-segment::-webkit-scrollbar-track {
@apply rounded-b-[10px] rounded-t-none border-0 border-t border-solid border-gray-200 bg-transparent p-[2px];
}
.rw-segment::-webkit-scrollbar-thumb {
@apply rounded-full border-[3px] border-solid border-transparent bg-zinc-400 bg-clip-content;
}
.rw-segment-header {
@apply bg-gray-200 px-4 py-3 text-gray-700;
}
.rw-segment-main {
@apply bg-gray-100 p-4;
}
.rw-link {
@apply text-blue-400 underline;
}
.rw-link:hover {
@apply text-blue-500;
}
.rw-forgot-link {
@apply mt-1 text-right text-xs text-gray-400 underline;
}
.rw-forgot-link:hover {
@apply text-blue-500;
}
.rw-heading {
@apply font-semibold;
}
.rw-heading.rw-heading-primary {
@apply text-xl;
}
.rw-heading.rw-heading-secondary {
@apply text-sm;
}
.rw-heading .rw-link {
@apply text-gray-600 no-underline;
}
.rw-heading .rw-link:hover {
@apply text-gray-900 underline;
}
.rw-cell-error {
@apply text-sm font-semibold;
}
.rw-form-wrapper {
@apply -mt-4 text-sm;
}
.rw-cell-error,
.rw-form-error-wrapper {
@apply my-4 rounded border border-red-100 bg-red-50 p-4 text-red-600;
}
.rw-form-error-title {
@apply m-0 font-semibold;
}
.rw-form-error-list {
@apply mt-2 list-inside list-disc;
}
.rw-button {
@apply flex cursor-pointer justify-center rounded border-0 bg-gray-200 px-4 py-1 text-xs font-semibold uppercase leading-loose tracking-wide text-gray-500 no-underline transition duration-100;
}
.rw-button:hover {
@apply bg-gray-500 text-white;
}
.rw-button.rw-button-small {
@apply rounded-sm px-2 py-1 text-xs;
}
.rw-button.rw-button-green {
@apply bg-green-500 text-white;
}
.rw-button.rw-button-green:hover {
@apply bg-green-700;
}
.rw-button.rw-button-blue {
@apply bg-blue-500 text-white;
}
.rw-button.rw-button-blue:hover {
@apply bg-blue-700;
}
.rw-button.rw-button-red {
@apply bg-red-500 text-white;
}
.rw-button.rw-button-red:hover {
@apply bg-red-700 text-white;
}
.rw-button-icon {
@apply mr-1 text-xl leading-5;
}
.rw-button-group {
@apply mx-2 my-3 flex justify-center;
}
.rw-button-group .rw-button {
@apply mx-1;
}
.rw-form-wrapper .rw-button-group {
@apply mt-8;
}
.rw-label {
@apply mt-6 block text-left font-semibold text-gray-600;
}
.rw-label.rw-label-error {
@apply text-red-600;
}
.rw-input {
@apply mt-2 block w-full rounded border border-gray-200 bg-white p-2 outline-none;
}
.rw-check-radio-items {
@apply flex justify-items-center;
}
.rw-check-radio-item-none {
@apply text-gray-600;
}
.rw-input[type='checkbox'],
.rw-input[type='radio'] {
@apply ml-0 mr-1 mt-1 inline w-4;
}
.rw-input:focus {
@apply border-gray-400;
}
.rw-input-error {
@apply border-red-600 text-red-600;
}
.rw-input-error:focus {
@apply border-red-600 outline-none;
box-shadow: 0 0 5px #c53030;
}
.rw-field-error {
@apply mt-1 block text-xs font-semibold uppercase text-red-600;
}
.rw-table-wrapper-responsive {
@apply overflow-x-auto;
}
.rw-table-wrapper-responsive .rw-table {
min-width: 48rem;
}
.rw-table {
@apply w-full text-sm;
}
.rw-table th,
.rw-table td {
@apply p-3;
}
.rw-table td {
@apply bg-white text-gray-900;
}
.rw-table tr:nth-child(odd) td,
.rw-table tr:nth-child(odd) th {
@apply bg-gray-50;
}
.rw-table thead tr {
@apply bg-gray-200 text-gray-600;
}
.rw-table th {
@apply text-left font-semibold;
}
.rw-table thead th {
@apply text-left;
}
.rw-table tbody th {
@apply text-right;
}
@media (min-width: 768px) {
.rw-table tbody th {
@apply w-1/5;
}
}
.rw-table tbody tr {
@apply border-t border-gray-200;
}
.rw-table input {
@apply ml-0;
}
.rw-table-actions {
@apply flex h-4 items-center justify-end pr-1;
}
.rw-table-actions .rw-button {
@apply bg-transparent;
}
.rw-table-actions .rw-button:hover {
@apply bg-gray-500 text-white;
}
.rw-table-actions .rw-button-blue {
@apply text-blue-500;
}
.rw-table-actions .rw-button-blue:hover {
@apply bg-blue-500 text-white;
}
.rw-table-actions .rw-button-red {
@apply text-red-600;
}
.rw-table-actions .rw-button-red:hover {
@apply bg-red-600 text-white;
}
.rw-text-center {
@apply text-center;
}
.rw-login-container {
@apply mx-auto my-16 flex w-96 flex-wrap items-center justify-center;
}
.rw-login-container .rw-form-wrapper {
@apply w-full text-center;
}
.rw-login-link {
@apply mt-4 w-full text-center text-sm text-gray-600;
}
.rw-webauthn-wrapper {
@apply mx-4 mt-6 leading-6;
}
.rw-webauthn-wrapper h2 {
@apply mb-4 text-xl font-bold;
}
+2007 -1020
View File
File diff suppressed because it is too large Load Diff