17 Commits

Author SHA1 Message Date
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
85 changed files with 3133 additions and 1617 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
# --------- # ---------
@@ -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")
);
+14 -12
View File
@@ -58,6 +58,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 +75,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.3.0",
"@redwoodjs/api-server": "8.0.0", "@redwoodjs/api-server": "8.3.0",
"@redwoodjs/auth-dbauth-api": "8.0.0", "@redwoodjs/auth-dbauth-api": "8.3.0",
"@redwoodjs/graphql-server": "8.0.0", "@redwoodjs/graphql-server": "8.3.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",
+21 -6
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
@@ -197,10 +213,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 +232,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
}
`
+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
}
`
+1 -1
View File
@@ -17,7 +17,7 @@ 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({ return 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,
+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 },
})
+3 -3
View File
@@ -7,9 +7,9 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@redwoodjs/auth-dbauth-setup": "8.0.0", "@redwoodjs/auth-dbauth-setup": "8.3.0",
"@redwoodjs/core": "8.0.0", "@redwoodjs/core": "8.3.0",
"@redwoodjs/project-config": "8.0.0", "@redwoodjs/project-config": "8.3.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
+20
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 = {
@@ -29,6 +31,24 @@ export default async () => {
salt, salt,
}, },
}) })
const titles = await db.titles.findFirst()
await db.titles.upsert({
where: {
id: 1,
},
create: {
titles: Array.from({ length: MAX_TITLES }).map(
(_, i) => `a title ${i + 1}`
),
},
update: {
titles:
titles?.titles ||
Array.from({ length: MAX_TITLES }).map((_, i) => `a title ${i + 1}`),
},
})
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
+13 -1
View File
@@ -142,4 +142,16 @@ 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('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',
},
},
],
}
+9 -7
View File
@@ -14,11 +14,11 @@
"@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.3.0",
"@redwoodjs/forms": "8.0.0", "@redwoodjs/forms": "8.3.0",
"@redwoodjs/router": "8.0.0", "@redwoodjs/router": "8.3.0",
"@redwoodjs/web": "8.0.0", "@redwoodjs/web": "8.3.0",
"@redwoodjs/web-server": "8.0.0", "@redwoodjs/web-server": "8.3.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,14 +27,16 @@
"@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-typed": "^2.0.12"
}, },
"devDependencies": { "devDependencies": {
"@redwoodjs/vite": "8.0.0", "@redwoodjs/vite": "8.3.0",
"@types/react": "^18.2.55", "@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.2.19",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 757 B

Binary file not shown.
+8 -1
View File
@@ -12,7 +12,14 @@ 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,6 +36,7 @@ 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 {
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useLayoutEffect } from 'react'
import SocialLinksCell from 'src/components/Social/SocialLinksCell' import SocialLinksCell from 'src/components/Social/SocialLinksCell'
@@ -12,7 +12,7 @@ const ContactCard = ({ portraitUrl }: ContactCardProps) => {
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 +56,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
@@ -11,16 +11,14 @@ 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 ContactCard from 'src/components/ContactCard/ContactCard/ContactCard' import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard'
export const QUERY: TypedDocumentNode< export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
FindPortrait, gql`
FindPortraitVariables query ContactCardPortrait {
> = gql` portrait: portrait {
query ContactCardPortrait { fileId
portrait: portrait { }
fileId
} }
} `
`
export const Loading = () => <CellLoading /> export const Loading = () => <CellLoading />
@@ -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>
))} ))}
@@ -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-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,160 @@
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>{project.description}</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),
} }
+72 -98
View File
@@ -1,111 +1,85 @@
import type { import { mdiLinkVariant } from '@mdi/js'
DeleteProjectMutation, import Icon from '@mdi/react'
DeleteProjectMutationVariables, import { format, isAfter, startOfToday } from 'date-fns'
FindProjectById, import type { FindProjectById } from 'types/graphql'
} 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 && <p>{project.description}</p>}
{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,9 +1,14 @@
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 { 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 {
@@ -16,13 +21,14 @@ import {
} from '@redwoodjs/forms' } 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 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
@@ -40,6 +46,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 +82,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: data.description,
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)
@@ -162,6 +198,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 +207,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>
+104 -70
View File
@@ -1,5 +1,6 @@
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 type { import type {
DeleteProjectMutation, DeleteProjectMutation,
DeleteProjectMutationVariables, DeleteProjectMutationVariables,
@@ -12,7 +13,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 +37,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 +51,113 @@ 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">{truncate(project.description)}</td>
<td>{timeTag(project.date)}</td> <td className="max-w-36">{timeTag(project.date)}</td>
<td className="space-x-2 space-y-2"> <td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td>
{project.links.map((link, i) => ( <td>
<a <div className="flex flex-wrap gap-2">
href={link} {project.tags.map((tag, i) => (
target="_blank" <div
className="badge badge-ghost text-nowrap" key={i}
key={i} className="badge whitespace-nowrap"
rel="noreferrer" style={{
> backgroundColor: tag.color,
{link} color:
</a> calculateLuminance(tag.color) > 0.5
))} ? 'black'
</td> : 'white',
<td> }}
<nav className="hidden justify-end space-x-2 sm:flex"> >
{actionButtons} {tag.tag}
</nav> </div>
<div className="dropdown dropdown-end flex justify-end sm:hidden"> ))}
<div
tabIndex={0}
role="button"
className="btn btn-square btn-ghost btn-sm lg:hidden"
>
<Icon
path={mdiDotsVertical}
className="text-base-content-100 size-6"
/>
</div> </div>
<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,108 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { format, isAfter, startOfToday } from 'date-fns'
import { FindProjects } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import AutoCarousel from 'src/components/AutoCarousel/AutoCarousel'
import { calculateLuminance } from 'src/lib/color'
const CARD_WIDTH = 384
const ProjectsShowcase = ({ projects }: FindProjects) => {
const ref = useRef<HTMLDivElement>(null)
const [columns, setColumns] = useState<number>(
Math.max(
Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
1
)
)
useLayoutEffect(() => {
const handleResize = () =>
setColumns(
Math.max(
Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
1
)
)
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return (
<div ref={ref} className="flex flex-wrap justify-center gap-2">
{split(
projects
.slice()
.sort((a, b) =>
isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1
),
columns
).map((projectChunk, i) => (
<div className="flex flex-col gap-2" key={i}>
{projectChunk.map((project, j) => (
<Link key={`${i}-${j}`} to={routes.project({ id: project.id })}>
<div className="card card-compact bg-base-100 w-96 h-fit shadow-xl transition-all hover:-translate-y-1 hover:shadow-2xl">
{project.images.length > 0 && (
<AutoCarousel images={project.images} />
)}
<div className="card-body">
<div className="card-title overflow-auto">
<p className="whitespace-nowrap">{project.title}</p>
</div>
<div className="line-clamp-5">{project.description}</div>
<div className="card-actions justify-between">
<div className="flex gap-2">
{isAfter(new Date(project.date), startOfToday()) && (
<div className="badge badge-info">planned</div>
)}
<div className="badge badge-ghost">
{format(project.date, 'yyyy-MM-dd')}
</div>
</div>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag, i) => (
<div
key={i}
className="badge whitespace-nowrap"
style={{
backgroundColor: tag.color,
color:
calculateLuminance(tag.color) > 0.5
? 'black'
: 'white',
}}
>
{tag.tag}
</div>
))}
</div>
</div>
</div>
</div>
</Link>
))}
</div>
))}
</div>
)
}
export default ProjectsShowcase
function split<T>(arr: T[], chunks: number): T[][] {
const result: T[][] = []
const chunkSize = Math.ceil(arr.length / chunks)
for (let i = 0; i < arr.length; i += chunkSize) {
result.push(arr.slice(i, i + chunkSize))
}
return result
}
@@ -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,26 @@
import { useState } from 'react'
import { Resume as ResumeType } from 'types/graphql'
interface ResumeProps {
resume?: ResumeType
}
const Resume = ({ resume }: ResumeProps) => {
const [fileId] = useState<string>(resume?.fileId)
return (
<object
data={fileId}
type="application/pdf"
aria-label="Resume PDF"
style={{
width: 'calc(100vw - 1rem)',
height: 'calc(100vh - 6rem)',
}}
className="rounded-xl"
/>
)
}
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,196 @@
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 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">
<object
data={resume?.fileId}
type="application/pdf"
aria-label="Resume PDF"
style={{
width: 'calc(100vw - 1rem)',
height: 'calc(100vh - 10rem)',
}}
className="rounded-xl"
/>
<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"
/>
</>
) : (
<object
data={fileId}
type="application/pdf"
aria-label="Resume PDF"
style={{
width: 'calc(100vw - 1rem)',
height: 'calc(100vh - 10rem)',
}}
className="rounded-xl"
/>
)}
{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
@@ -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']>
@@ -233,19 +233,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 { FindSocials } 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 }: FindSocials) => (
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
+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}
/>
)
+9 -11
View File
@@ -3,14 +3,17 @@ 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') ?? 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 +34,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"
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)
}) })
+4
View File
@@ -15,3 +15,7 @@
.w-52 .react-colorful { .w-52 .react-colorful {
width: 13rem; width: 13rem;
} }
.image-full-no-overlay::before {
background: none !important;
}
@@ -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} />
+23 -1
View File
@@ -20,7 +20,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/',
@@ -76,4 +76,26 @@ 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',
'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 */
}
}
}
}
@@ -72,7 +72,7 @@ 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">Submit</Submit>
</div> </div>
</Form> </Form>
</div> </div>
+35 -7
View File
@@ -1,11 +1,39 @@
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 (
<> const HomePage = () => (
<Metadata title="Home" /> <>
</> <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>
</>
)
export default HomePage export default HomePage
@@ -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 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
+940 -989
View File
File diff suppressed because it is too large Load Diff