Compare commits
17 Commits
430a2da835
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6873c5c026 | |||
| 1b7e79c765 | |||
| 6e401cf2b3 | |||
| b89a5ee1b8 | |||
| 3c2b944bf4 | |||
| 11783069a8 | |||
| 835d895fc0 | |||
| 73ec75c167 | |||
| 49c943c9f3 | |||
| fb542bb5b5 | |||
| e5f9bbd462 | |||
| 8671f47e91 | |||
| 4a94b6807e | |||
| 9c0dee7d54 | |||
| 38168db452 | |||
| c9227cf9b9 | |||
| 5c41588249 |
+4
-3
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
export const schema = gql`
|
|
||||||
type ProjectImage {
|
|
||||||
id: Int!
|
|
||||||
fileId: URL!
|
|
||||||
Project: Project
|
|
||||||
projectId: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
type Query {
|
|
||||||
projectImages: [ProjectImage!]! @requireAuth
|
|
||||||
projectImage(id: Int!): ProjectImage @requireAuth
|
|
||||||
}
|
|
||||||
|
|
||||||
input CreateProjectImageInput {
|
|
||||||
fileId: URL!
|
|
||||||
projectId: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
input UpdateProjectImageInput {
|
|
||||||
fileId: URL
|
|
||||||
projectId: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
type Mutation {
|
|
||||||
createProjectImage(input: CreateProjectImageInput!): ProjectImage!
|
|
||||||
@requireAuth
|
|
||||||
updateProjectImage(
|
|
||||||
id: Int!
|
|
||||||
input: UpdateProjectImageInput!
|
|
||||||
): ProjectImage! @requireAuth
|
|
||||||
deleteProjectImage(id: Int!): ProjectImage! @requireAuth
|
|
||||||
}
|
|
||||||
`
|
|
||||||
@@ -3,15 +3,15 @@ export const schema = gql`
|
|||||||
id: Int!
|
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -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
@@ -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()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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> </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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> </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>
|
||||||
|
|||||||
@@ -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"> </th>
|
<th className="w-0"> </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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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> </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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
@@ -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'm {`${process.env.FIRST_NAME}`}
|
||||||
|
{titlesFiltered.length > 0 && (
|
||||||
|
<>
|
||||||
|
, <br />
|
||||||
|
<ReactTyped
|
||||||
|
className={className}
|
||||||
|
strings={titlesFiltered}
|
||||||
|
typeSpeed={50}
|
||||||
|
backSpeed={40}
|
||||||
|
backDelay={1000}
|
||||||
|
startWhenVisible
|
||||||
|
loop
|
||||||
|
onStringTyped={(pos, self) => {
|
||||||
|
if (pos === 0) {
|
||||||
|
self.stop()
|
||||||
|
setTimeout(() => self.start(), 2500)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { TitlesQuery, TitlesQueryVariables } from 'types/graphql'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CellSuccessProps,
|
||||||
|
CellFailureProps,
|
||||||
|
TypedDocumentNode,
|
||||||
|
} from '@redwoodjs/web'
|
||||||
|
|
||||||
|
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||||
|
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||||
|
|
||||||
|
import { Titles } from '../Titles/Titles'
|
||||||
|
|
||||||
|
export const QUERY: TypedDocumentNode<TitlesQuery, TitlesQueryVariables> = gql`
|
||||||
|
query TitlesQuery {
|
||||||
|
titles {
|
||||||
|
titles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
interface TitleProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const beforeQuery = ({ className = '' }: TitleProps) => {
|
||||||
|
return {
|
||||||
|
variables: {
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Loading = () => <CellLoading />
|
||||||
|
export const Failure = ({ error }: CellFailureProps<TitlesQueryVariables>) => (
|
||||||
|
<CellFailure error={error} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Success = ({
|
||||||
|
titles: { titles },
|
||||||
|
className = '',
|
||||||
|
}: CellSuccessProps<TitlesQuery> & TitleProps) => (
|
||||||
|
<Titles className={className} titles={titles} />
|
||||||
|
)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { mdiFormatTitle } from '@mdi/js'
|
||||||
|
import Icon from '@mdi/react'
|
||||||
|
import { Titles, UpdateTitlesInput } from 'types/graphql'
|
||||||
|
|
||||||
|
import { Form, Label, Submit, TextField } from '@redwoodjs/forms'
|
||||||
|
import { TypedDocumentNode, useMutation } from '@redwoodjs/web'
|
||||||
|
import { toast } from '@redwoodjs/web/toast'
|
||||||
|
|
||||||
|
const MAX_TITLES = 5
|
||||||
|
|
||||||
|
interface TitlesFormProps {
|
||||||
|
titles?: Titles
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPDATE_TITLES_MUTATION: TypedDocumentNode<UpdateTitlesInput> = gql`
|
||||||
|
mutation UpdateTitlesMutation($input: UpdateTitlesInput!) {
|
||||||
|
updateTitles(input: $input) {
|
||||||
|
titles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const TitlesForm = ({ titles }: TitlesFormProps) => {
|
||||||
|
const title1ref = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const [preview, setPreview] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const states = [
|
||||||
|
useState<string>(titles?.titles[0]),
|
||||||
|
useState<string>(titles?.titles[1]),
|
||||||
|
useState<string>(titles?.titles[2]),
|
||||||
|
useState<string>(titles?.titles[3]),
|
||||||
|
useState<string>(titles?.titles[4]),
|
||||||
|
]
|
||||||
|
|
||||||
|
useEffect(() => title1ref.current?.focus(), [])
|
||||||
|
|
||||||
|
const [updateTitles, { loading: updateLoading }] = useMutation(
|
||||||
|
UPDATE_TITLES_MUTATION,
|
||||||
|
{
|
||||||
|
onCompleted: () => toast.success('Titles saved'),
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const onSubmit = (data: Record<string, string>) =>
|
||||||
|
updateTitles({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
titles: Object.values(data).map((value) => value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={onSubmit} className="max-w-80 space-y-2">
|
||||||
|
<p className="text-center opacity-70">
|
||||||
|
The first one gets displayed for longer
|
||||||
|
</p>
|
||||||
|
{Array.from({ length: MAX_TITLES }).map((_, i) => (
|
||||||
|
<Label key={i} name={`title${i}`} className="form-control w-full">
|
||||||
|
<Label
|
||||||
|
name={`title${i}`}
|
||||||
|
className="input input-bordered flex items-center gap-2"
|
||||||
|
errorClassName="input input-bordered flex items-center gap-2 input-error"
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
name={`title${i}`}
|
||||||
|
className="size-4 opacity-70"
|
||||||
|
errorClassName="size-4 text-error"
|
||||||
|
>
|
||||||
|
<Icon path={mdiFormatTitle} />
|
||||||
|
</Label>
|
||||||
|
<TextField
|
||||||
|
name={`title${i}`}
|
||||||
|
ref={i === 0 ? title1ref : null}
|
||||||
|
placeholder={`Title ${i + 1}`}
|
||||||
|
defaultValue={states[i][0]}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => states[i][1](e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
{preview && (
|
||||||
|
<div className="label">
|
||||||
|
<p>
|
||||||
|
Hey 👋, I'm {`${process.env.FIRST_NAME}`},{' '}
|
||||||
|
<span className="text-primary">{states[i][0]}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<nav className="my-2 flex justify-center space-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => setPreview(!preview)}
|
||||||
|
>
|
||||||
|
{preview ? 'Hide' : 'Show'} Preview
|
||||||
|
</button>
|
||||||
|
<Submit
|
||||||
|
disabled={updateLoading}
|
||||||
|
className="btn btn-primary btn-sm uppercase"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Submit>
|
||||||
|
</nav>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TitlesForm
|
||||||
@@ -5,11 +5,15 @@ import Uppy from '@uppy/core'
|
|||||||
import type { UploadResult, Meta } from '@uppy/core'
|
import 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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user