Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
d48cfe12f2 | |||
3a9cc20f86 | |||
54a34ef5ee | |||
f097d7761d | |||
03717113f4 | |||
284a4c5520 | |||
f03faabbee | |||
353fb3899e | |||
62ce137bcb | |||
f8987b08da | |||
cbf75acbeb |
@ -18,6 +18,12 @@ PRISMA_HIDE_UPDATE_MESSAGE=true
|
||||
FIRST_NAME=firstname
|
||||
LAST_NAME=lastname
|
||||
|
||||
DEFAULT_THEME=light
|
||||
|
||||
COUNTRY=US
|
||||
STATE=New York
|
||||
CITY=Manhattan
|
||||
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
|
@ -6,6 +6,12 @@
|
||||
FIRST_NAME=firstname
|
||||
LAST_NAME=lastname
|
||||
|
||||
DEFAULT_THEME=light
|
||||
|
||||
COUNTRY=US
|
||||
STATE=New York
|
||||
CITY=Manhattan
|
||||
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
|
@ -62,6 +62,10 @@ FROM api_build as web_build_with_prerender
|
||||
|
||||
ARG FIRST_NAME
|
||||
ARG LAST_NAME
|
||||
ARG COUNTRY
|
||||
ARG STATE
|
||||
ARG CITY
|
||||
ARG DEFAULT_THEME
|
||||
ARG API_ADDRESS_PROD
|
||||
ARG API_ADDRESS_DEV
|
||||
|
||||
@ -74,6 +78,10 @@ FROM base as web_build
|
||||
|
||||
ARG FIRST_NAME
|
||||
ARG LAST_NAME
|
||||
ARG COUNTRY
|
||||
ARG STATE
|
||||
ARG CITY
|
||||
ARG DEFAULT_THEME
|
||||
ARG API_ADDRESS_PROD
|
||||
ARG API_ADDRESS_DEV
|
||||
|
||||
|
14
README.md
14
README.md
@ -21,9 +21,13 @@ services:
|
||||
- API_PROXY_TARGET=http://localhost:8911
|
||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||
- DATABASE_URL=postgresql://redwood:changeme@db:5432/portfolio
|
||||
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||
- FIRST_NAME=first name # Your first name
|
||||
- LAST_NAME=lastname # Your last name
|
||||
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||
- STATE=New York # Optional, state or province
|
||||
- CITY=Manhattan # Optional
|
||||
- DEFAULT_THEME=light # 'light' or 'dark'
|
||||
- SMTP_HOST=smtp.example.com
|
||||
- SMTP_PORT=465
|
||||
- SMTP_SECURE=true
|
||||
@ -58,8 +62,6 @@ services:
|
||||
- POSTGRES_USER=redwood
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
- POSTGRES_DB=portfolio
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
@ -75,9 +77,9 @@ sudo docker exec -u root portfolio chown -R node:node /home/node/app/api/files_p
|
||||
## Logging In
|
||||
- Once the container is up and running, head to `/login` (`https://portfolio.example.com/login`), default credentials are below
|
||||
- If you would like to change the password, head to `/forgot-password` (`https://portfolio.example.com/forgot-password`), the username is `admin`
|
||||
- If you correctly set up the Gmail app password, you should receive an email from yourself
|
||||
- It contains the link needed to change your password
|
||||
- If you correctly configured [SMTP](#smtp), you should receive an Email from [`EMAIL_FROM`](#docker-compose) to [`EMAIL_TO`](#docker-compose)
|
||||
- The Email contains the link needed to change your password
|
||||
### Default Credentials
|
||||
**Username:** `admin`
|
||||
|
||||
**Password:** [`SMTP_PASSWORD`](#smtp)
|
||||
**Password:** [`SMTP_PASSWORD`](#docker-compose)
|
||||
|
@ -11,6 +11,7 @@
|
||||
"@redwoodjs/graphql-server": "8.4.0",
|
||||
"@tus/file-store": "^1.4.0",
|
||||
"@tus/server": "^1.7.0",
|
||||
"countries-list": "^3.1.1",
|
||||
"graphql-scalars": "^1.23.0",
|
||||
"nodemailer": "^6.9.14"
|
||||
},
|
||||
|
@ -9,7 +9,25 @@ import { createServer } from '@redwoodjs/api-server'
|
||||
|
||||
import { logger } from 'src/lib/logger'
|
||||
import { handleTusUpload } from 'src/lib/tus'
|
||||
|
||||
enum Theme {
|
||||
light = 'light',
|
||||
dark = 'dark',
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
const { countries } = await import('countries-list')
|
||||
|
||||
if (!Object.keys(countries).includes(process.env.COUNTRY))
|
||||
throw new Error(
|
||||
'Invalid COUNTRY environment variable, please select a valid ISO-3166-1 alpha-2 country code\n See https://en.wikipedia.org/wiki/ISO_3166-1#Codes'
|
||||
)
|
||||
|
||||
if (!(process.env.DEFAULT_THEME.toLowerCase() in Theme))
|
||||
throw new Error(
|
||||
'Invalid DEFAULT_THEME environment variable, please select either light or dark'
|
||||
)
|
||||
|
||||
const server = await createServer({
|
||||
logger,
|
||||
configureApiServer: async (server) => {
|
||||
|
@ -9,9 +9,13 @@ services:
|
||||
- API_PROXY_TARGET=http://localhost:8911
|
||||
- MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
- SESSION_SECRET=super_secret_session_key_change_me_in_production_please
|
||||
- DATABASE_URL=postgresql://redwood:changeme@db:5432/portfolio
|
||||
- DATABASE_URL=postgresql://redwood:changeme@db/portfolio
|
||||
- FIRST_NAME=first name # Your first name
|
||||
- LAST_NAME=lastname # Your last name
|
||||
- COUNTRY=US # ISO-3166-1 alpha-2 country code, https://en.wikipedia.org/wiki/ISO_3166-1#Codes
|
||||
- STATE=New York # Optional, state or province
|
||||
- CITY=Manhattan # Optional
|
||||
- DEFAULT_THEME=light # 'light' or 'dark'
|
||||
- SMTP_HOST=smtp.example.com
|
||||
- SMTP_PORT=465
|
||||
- SMTP_SECURE=true
|
||||
@ -46,8 +50,6 @@ services:
|
||||
- POSTGRES_USER=redwood
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
- POSTGRES_DB=portfolio
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
|
@ -9,7 +9,7 @@
|
||||
title = "${FIRST_NAME} ${LAST_NAME}"
|
||||
port = 8910
|
||||
apiUrl = "/api"
|
||||
includeEnvironmentVariables = ["FIRST_NAME", "LAST_NAME", "API_ADDRESS_PROD", "API_ADDRESS_DEV"]
|
||||
includeEnvironmentVariables = ["FIRST_NAME", "LAST_NAME", "COUNTRY", "STATE", "CITY", "DEFAULT_THEME", "API_ADDRESS_PROD", "API_ADDRESS_DEV"]
|
||||
[generate]
|
||||
tests = false
|
||||
stories = false
|
||||
|
@ -3,8 +3,6 @@ import { db } from 'api/src/lib/db'
|
||||
|
||||
import { hashPassword } from '@redwoodjs/auth-dbauth-api'
|
||||
|
||||
const MAX_TITLES = 5
|
||||
|
||||
export default async () => {
|
||||
try {
|
||||
const admin = {
|
||||
@ -44,9 +42,7 @@ export default async () => {
|
||||
if (!titles)
|
||||
await db.titles.create({
|
||||
data: {
|
||||
titles: Array.from({ length: MAX_TITLES }).map(
|
||||
(_, i) => `a title ${i + 1}`
|
||||
),
|
||||
titles: Array.from({ length: 3 }).map((_, i) => `title ${i + 1}`),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
@ -35,13 +35,13 @@
|
||||
"@uppy/react": "^4.0.1",
|
||||
"@uppy/tus": "^4.0.0",
|
||||
"@uppy/webcam": "^4.0.1",
|
||||
"countries-list": "^3.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"humanize-string": "2.1.0",
|
||||
"react": "18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-html-parser": "^2.0.2",
|
||||
"react-typed": "^2.0.12"
|
||||
"react-html-parser": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redwoodjs/vite": "8.4.0",
|
||||
|
@ -31,11 +31,11 @@ const Routes = () => {
|
||||
<Route path="/admin/titles" page={TitleTitlesPage} name="titles" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={ScaffoldLayout} title="Resume" titleTo="adminResume">
|
||||
<Set wrap={ScaffoldLayout} title="Résumé" 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="adminProjects" buttonLabel="New Project" buttonTo="newProject">
|
||||
<Route path="/admin/projects/new" page={ProjectNewProjectPage} name="newProject" />
|
||||
<Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" />
|
||||
<Route path="/admin/projects/{id:Int}" page={ProjectAdminProjectPage} name="adminProject" />
|
||||
|
@ -1,56 +0,0 @@
|
||||
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
|
@ -9,15 +9,12 @@ interface ColorPickerProps {
|
||||
setColor: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
|
||||
return (
|
||||
<div className="flex flex-col space-y-2 bg-base-100 p-2 rounded-xl w-min">
|
||||
<section className="w-52">
|
||||
const ColorPicker = ({ color, setColor }: ColorPickerProps) => (
|
||||
<div className="flex flex-col space-y-2 bg-base-100 p-2 rounded-xl">
|
||||
<HexColorPicker color={color} onChange={setColor} />
|
||||
</section>
|
||||
<div className="flex space-x-2 w-52">
|
||||
<div className="flex space-x-2">
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label className="input input-bordered flex items-center gap-2 input-sm w-full grow">
|
||||
<label className="input input-bordered flex items-center gap-2 input-sm grow">
|
||||
<Icon path={mdiPound} className="size-4 opacity-70" />
|
||||
<HexColorInput color={color} className="w-16" />
|
||||
</label>
|
||||
@ -57,6 +54,5 @@ const ColorPicker = ({ color, setColor }: ColorPickerProps) => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColorPicker
|
||||
|
@ -10,8 +10,8 @@ interface ContactCardProps {
|
||||
}
|
||||
|
||||
const ContactCard = ({ portraitUrl, socials }: ContactCardProps) => {
|
||||
const [width, setWidth] = useState()
|
||||
const [height, setHeight] = useState()
|
||||
const [width, setWidth] = useState<number>(0)
|
||||
const [height, setHeight] = useState<number>(0)
|
||||
|
||||
const observedDiv = useRef(null)
|
||||
|
||||
@ -53,24 +53,18 @@ const ContactCard = ({ portraitUrl, socials }: ContactCardProps) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-[calc(100vh-6rem)] items-center justify-center">
|
||||
<div className="card bg-base-100 shadow-xl md:card-side">
|
||||
<figure>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="card card-compact bg-base-100 shadow-xl md:card-side">
|
||||
<img
|
||||
className="contact-me-image aspect-portrait object-cover"
|
||||
className="contact-me-image rounded-box aspect-portrait p-2 object-cover"
|
||||
src={portraitUrl}
|
||||
alt={`${process.env.FIRST_NAME} ${process.env.LAST_NAME}`}
|
||||
/>
|
||||
</figure>
|
||||
<div
|
||||
className="card-body mx-auto h-fit w-fit md:mx-0"
|
||||
ref={observedDiv}
|
||||
>
|
||||
<h2 className="card-title justify-center text-3xl md:justify-start">
|
||||
<div className="card-body mx-auto w-fit md:mx-0" ref={observedDiv}>
|
||||
<h2 className="card-title justify-center text-3xl pb-2 md:justify-start">
|
||||
Contact Me
|
||||
</h2>
|
||||
<p className="p-2"></p>
|
||||
<div className="card-actions">
|
||||
<div className="card-actions rounded-btn">
|
||||
<SocialLinks socials={socials} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,10 +3,12 @@ import type {
|
||||
ContactCardPortraitVariables,
|
||||
} from 'types/graphql'
|
||||
|
||||
import type {
|
||||
TypedDocumentNode,
|
||||
CellFailureProps,
|
||||
CellSuccessProps,
|
||||
import { routes } from '@redwoodjs/router'
|
||||
import {
|
||||
type TypedDocumentNode,
|
||||
type CellFailureProps,
|
||||
type CellSuccessProps,
|
||||
Metadata,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||
@ -43,5 +45,20 @@ export const Success = ({
|
||||
portrait,
|
||||
socials,
|
||||
}: CellSuccessProps<ContactCardPortrait, ContactCardPortraitVariables>) => (
|
||||
<>
|
||||
<Metadata
|
||||
title="Contact"
|
||||
og={{
|
||||
title: 'Contact',
|
||||
type: 'website',
|
||||
image: {
|
||||
url: portrait.fileId,
|
||||
type: 'image/webp',
|
||||
alt: `${process.env.FIRST_NAME} ${process.env.LAST_NAME}`,
|
||||
},
|
||||
url: routes.contact(),
|
||||
}}
|
||||
/>
|
||||
<ContactCard portraitUrl={portrait.fileId} socials={socials} />
|
||||
</>
|
||||
)
|
||||
|
@ -1,19 +1,37 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { mdiAlertOutline } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
interface PDFProps {
|
||||
url: string
|
||||
form?: boolean
|
||||
}
|
||||
|
||||
const PDF = ({ url, form = false }: PDFProps) => (
|
||||
<embed
|
||||
const PDF = ({ url, form = false }: PDFProps) => {
|
||||
const [error, setError] = useState<boolean>(false)
|
||||
|
||||
return error ? (
|
||||
<div role="alert" className="alert alert-warning">
|
||||
<Icon path={mdiAlertOutline} className="size-7" />
|
||||
<span>
|
||||
Could not load PDF, this is common in in-app browsers, try opening this
|
||||
page in a regular browser
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={url}
|
||||
title="PDF"
|
||||
type="application/pdf"
|
||||
style={{
|
||||
width: 'calc(100vw - 1rem)',
|
||||
height: `calc(100vh - ${form ? '8.5rem' : '6rem'})`,
|
||||
}}
|
||||
className="rounded-xl"
|
||||
onError={() => setError(true)}
|
||||
onLoad={() => setError(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PDF
|
||||
|
@ -127,14 +127,13 @@ const PortraitForm = ({ portrait }: PortraitFormProps) => {
|
||||
)
|
||||
else
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
<div className="mx-auto max-w-prose space-y-2">
|
||||
{!fileId ? (
|
||||
<>
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="22rem"
|
||||
height="34.5rem"
|
||||
className="flex justify-center"
|
||||
width="auto"
|
||||
height="30rem"
|
||||
/>
|
||||
<p className="text-center">
|
||||
High quality, 4:5 aspect ratio image recommended
|
||||
|
@ -46,29 +46,28 @@ const AdminProject = ({ project }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="flex justify-center">
|
||||
<div>
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th className="w-0">
|
||||
<th colSpan={2}>
|
||||
Project {project.id}: {project.title}
|
||||
</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th className="text-right">ID</th>
|
||||
<td>{project.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th className="text-right">Title</th>
|
||||
<td>{project.title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th className="text-right">Description</th>
|
||||
<td>
|
||||
<article className="prose">
|
||||
{parseHtml(project.description)}
|
||||
@ -76,11 +75,11 @@ const AdminProject = ({ project }: Props) => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th className="text-right">Date</th>
|
||||
<td>{timeTag(project.date)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Images</th>
|
||||
<th className="text-right">Images</th>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.images.map((image, i) => (
|
||||
@ -88,7 +87,7 @@ const AdminProject = ({ project }: Props) => {
|
||||
key={i}
|
||||
href={image}
|
||||
target="_blank"
|
||||
className="btn btn-sm btn-square"
|
||||
className={`btn btn-sm btn-square ${i === 0 && 'btn-primary'}`}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{i + 1}
|
||||
@ -98,7 +97,7 @@ const AdminProject = ({ project }: Props) => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Tags</th>
|
||||
<th className="text-right">Tags</th>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag, i) => (
|
||||
@ -120,19 +119,30 @@ const AdminProject = ({ project }: Props) => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Links</th>
|
||||
<th className="text-right">Links</th>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.links.map((link, i) => (
|
||||
<>
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="badge badge-ghost text-nowrap"
|
||||
className="hidden sm:flex badge badge-ghost text-nowrap"
|
||||
key={i}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="btn btn-sm btn-square sm:hidden"
|
||||
key={i}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{i + 1}
|
||||
</a>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
|
@ -73,12 +73,12 @@ export const Success = ({ project }: CellSuccessProps<EditProjectById>) => {
|
||||
) => updateProject({ variables: { id, input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<div className="flex mx-auto max-w-prose justify-center">
|
||||
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th className="w-0">Edit Project {project.id}</th>
|
||||
<th>Edit Project {project.id}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -38,9 +38,9 @@ const NewProject = () => {
|
||||
createProject({ variables: { input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<div className="flex mx-auto max-w-prose justify-center">
|
||||
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th>New Project</th>
|
||||
|
@ -69,16 +69,19 @@ const Project = ({ project }: Props) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{project.images.length > 0 && (
|
||||
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 items-center pt-8 justify-center">
|
||||
{project.images.map((image, i) => (
|
||||
<div className="flex flex-wrap gap-4 pt-8 justify-center h-fit sm:p-8">
|
||||
{project.images.length > 0 &&
|
||||
project.images.map((image, i) => (
|
||||
<a
|
||||
href={image}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
key={i}
|
||||
className="rounded-xl"
|
||||
className="rounded-box"
|
||||
>
|
||||
<img src={image} alt="" className="rounded-xl" />
|
||||
</a>
|
||||
|
@ -1,9 +1,11 @@
|
||||
import type { FindProjectById, FindProjectByIdVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
CellSuccessProps,
|
||||
CellFailureProps,
|
||||
TypedDocumentNode,
|
||||
import { routes } from '@redwoodjs/router'
|
||||
import {
|
||||
type CellSuccessProps,
|
||||
type CellFailureProps,
|
||||
type TypedDocumentNode,
|
||||
Metadata,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||
@ -40,5 +42,23 @@ export const Failure = ({
|
||||
export const Success = ({
|
||||
project,
|
||||
}: CellSuccessProps<FindProjectById, FindProjectByIdVariables>) => (
|
||||
<>
|
||||
<Metadata
|
||||
title={project.title}
|
||||
og={{
|
||||
title: project.title,
|
||||
type: 'website',
|
||||
image:
|
||||
project.images.length > 0
|
||||
? {
|
||||
url: project.images[0],
|
||||
type: 'image/webp',
|
||||
alt: 'Image 1',
|
||||
}
|
||||
: undefined,
|
||||
url: routes.project({ id: project.id }),
|
||||
}}
|
||||
/>
|
||||
<Project project={project} />
|
||||
</>
|
||||
)
|
||||
|
@ -119,7 +119,7 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
<Form<FormProject>
|
||||
onSubmit={onSubmit}
|
||||
error={props.error}
|
||||
className="space-y-2 w-80"
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label name="title" className="form-control w-full">
|
||||
<Label
|
||||
@ -242,7 +242,14 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
<img src={fileId} alt={i.toString()} />
|
||||
</figure>
|
||||
<div className="card-body p-2 rounded-xl">
|
||||
<div className="card-actions rounded-md justify-end">
|
||||
<div
|
||||
className={`card-actions rounded-md ${i === 0 ? 'justify-between' : 'justify-end'}`}
|
||||
>
|
||||
{i === 0 && (
|
||||
<div className="btn btn-sm shadow-xl no-animation">
|
||||
Cover Image
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm btn-error shadow-xl"
|
||||
@ -261,9 +268,8 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
{appendUploader && (
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="20rem"
|
||||
width="auto"
|
||||
height="30rem"
|
||||
className="flex justify-center"
|
||||
maxFiles={10}
|
||||
disabled={props.loading}
|
||||
/>
|
||||
@ -272,9 +278,8 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
) : (
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="20rem"
|
||||
width="auto"
|
||||
height="30rem"
|
||||
className="flex justify-center pt-3"
|
||||
maxFiles={10}
|
||||
disabled={props.loading}
|
||||
/>
|
||||
|
@ -1,113 +0,0 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { format, isAfter, startOfToday } from 'date-fns'
|
||||
import parseHtml from 'react-html-parser'
|
||||
import { FindProjects } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import AutoCarousel from 'src/components/AutoCarousel/AutoCarousel'
|
||||
import { calculateLuminance } from 'src/lib/color'
|
||||
|
||||
const CARD_WIDTH = 384
|
||||
|
||||
const ProjectsShowcase = ({ projects }: FindProjects) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [columns, setColumns] = useState<number>(
|
||||
Math.max(
|
||||
Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
|
||||
1
|
||||
)
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const handleResize = () =>
|
||||
setColumns(
|
||||
Math.max(
|
||||
Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
|
||||
1
|
||||
)
|
||||
)
|
||||
|
||||
handleResize()
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex flex-wrap justify-center gap-2">
|
||||
{split(
|
||||
projects
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1
|
||||
),
|
||||
columns
|
||||
).map((projectChunk, i) => (
|
||||
<div className="flex flex-col gap-2" key={i}>
|
||||
{projectChunk.map((project, j) => (
|
||||
<Link key={`${i}-${j}`} to={routes.project({ id: project.id })}>
|
||||
<div className="card card-compact bg-base-100 w-96 h-fit shadow-xl transition-all hover:-translate-y-1 hover:shadow-2xl">
|
||||
{project.images.length > 0 && (
|
||||
<AutoCarousel images={project.images} />
|
||||
)}
|
||||
<div className="card-body">
|
||||
<div className="card-title overflow-auto">
|
||||
<p className="whitespace-nowrap">{project.title}</p>
|
||||
</div>
|
||||
<div className="line-clamp-5">
|
||||
<article className="prose text-sm">
|
||||
{parseHtml(project.description)}
|
||||
</article>
|
||||
</div>
|
||||
<div className="card-actions justify-between">
|
||||
<div className="flex gap-2">
|
||||
{isAfter(new Date(project.date), startOfToday()) && (
|
||||
<div className="badge badge-info">planned</div>
|
||||
)}
|
||||
<div className="badge badge-ghost">
|
||||
{format(project.date, 'yyyy-MM-dd')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="badge whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color:
|
||||
calculateLuminance(tag.color) > 0.5
|
||||
? 'black'
|
||||
: 'white',
|
||||
}}
|
||||
>
|
||||
{tag.tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectsShowcase
|
||||
|
||||
function split<T>(arr: T[], chunks: number): T[][] {
|
||||
const result: T[][] = []
|
||||
const chunkSize = Math.ceil(arr.length / chunks)
|
||||
|
||||
for (let i = 0; i < arr.length; i += chunkSize) {
|
||||
result.push(arr.slice(i, i + chunkSize))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
@ -1,16 +1,17 @@
|
||||
import type { FindProjects, FindProjectsVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
CellSuccessProps,
|
||||
CellFailureProps,
|
||||
TypedDocumentNode,
|
||||
import { routes } from '@redwoodjs/router'
|
||||
import {
|
||||
type CellSuccessProps,
|
||||
type CellFailureProps,
|
||||
type TypedDocumentNode,
|
||||
Metadata,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
|
||||
import ProjectsShowcase from '../ProjectsShowcase/ProjectsShowcase'
|
||||
import ProjectsShowcaseList from 'src/components/ProjectsShowcaseList/ProjectsShowcaseList'
|
||||
|
||||
export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> =
|
||||
gql`
|
||||
@ -40,5 +41,16 @@ export const Failure = ({ error }: CellFailureProps<FindProjectsVariables>) => (
|
||||
export const Success = ({
|
||||
projects,
|
||||
}: CellSuccessProps<FindProjects, FindProjectsVariables>) => (
|
||||
<ProjectsShowcase projects={projects} />
|
||||
<>
|
||||
<Metadata
|
||||
title="Projects"
|
||||
og={{
|
||||
title: 'Projects',
|
||||
type: 'website',
|
||||
description: `${projects.length} projects`,
|
||||
url: routes.projects(),
|
||||
}}
|
||||
/>
|
||||
<ProjectsShowcaseList projects={projects} />
|
||||
</>
|
||||
)
|
||||
|
@ -0,0 +1,72 @@
|
||||
import { format, isAfter, startOfToday } from 'date-fns'
|
||||
import parseHtml from 'react-html-parser'
|
||||
import { FindProjects } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import { calculateLuminance } from 'src/lib/color'
|
||||
|
||||
const ProjectsShowcaseList = ({ projects }: FindProjects) => (
|
||||
<div className="flex flex-col gap-4 w-fit mx-auto">
|
||||
{projects
|
||||
.slice()
|
||||
.sort((a, b) => (isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1))
|
||||
.map((project, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
to={routes.project({ id: project.id })}
|
||||
className="bg-base-100 flex flex-col sm:flex-row p-2 gap-2 shadow-xl rounded-box hover:shadow-2xl transition-all hover:-translate-y-1 sm:max-h-64 sm:max-w-5xl"
|
||||
>
|
||||
{project.images.length > 0 && (
|
||||
<img
|
||||
src={project.images[0]}
|
||||
alt={`${i}`}
|
||||
className="object-cover rounded-lg sm:max-w-[33.33%]"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div className="card-title overflow-auto">
|
||||
<p className="whitespace-nowrap">{project.title}</p>
|
||||
</div>
|
||||
<div className="line-clamp-5 mb-auto">
|
||||
<article className="prose text-sm max-w-none">
|
||||
{parseHtml(project.description)}
|
||||
</article>
|
||||
</div>
|
||||
<div className="card-actions justify-between">
|
||||
<div className="flex gap-2">
|
||||
{isAfter(new Date(project.date), startOfToday()) && (
|
||||
<div className="badge badge-info">planned</div>
|
||||
)}
|
||||
<div className="badge badge-ghost">
|
||||
{format(project.date, 'yyyy-MM-dd')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.slice(0, 3).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>
|
||||
))}
|
||||
{project.tags.length > 3 && (
|
||||
<div key={i} className="badge badge-ghost whitespace-nowrap">
|
||||
+{project.tags.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ProjectsShowcaseList
|
@ -1,9 +1,11 @@
|
||||
import type { FindResume, FindResumeVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
CellSuccessProps,
|
||||
CellFailureProps,
|
||||
TypedDocumentNode,
|
||||
import { routes } from '@redwoodjs/router'
|
||||
import {
|
||||
type CellSuccessProps,
|
||||
type CellFailureProps,
|
||||
type TypedDocumentNode,
|
||||
Metadata,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||
@ -29,5 +31,15 @@ export const Failure = ({ error }: CellFailureProps<FindResumeVariables>) => (
|
||||
export const Success = ({
|
||||
resume,
|
||||
}: CellSuccessProps<FindResume, FindResumeVariables>) => (
|
||||
<>
|
||||
<Metadata
|
||||
title="Contact"
|
||||
og={{
|
||||
title: 'Resume',
|
||||
type: 'website',
|
||||
url: routes.resume(),
|
||||
}}
|
||||
/>
|
||||
<Resume resume={resume} />
|
||||
</>
|
||||
)
|
||||
|
@ -123,14 +123,13 @@ const ResumeForm = ({ resume }: ResumeFormProps) => {
|
||||
)
|
||||
else
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
<div className="mx-auto max-w-prose space-y-2">
|
||||
{!fileId ? (
|
||||
<>
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="22rem"
|
||||
height="11.5rem"
|
||||
className="flex justify-center"
|
||||
width="auto"
|
||||
height="30rem"
|
||||
type="pdf"
|
||||
/>
|
||||
</>
|
||||
|
@ -43,7 +43,8 @@ const RichTextEditor = ({ editor }: RichTextEditorProps) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<div className="flex justify-center flex-wrap gap-2">
|
||||
<div className="flex gap-2 h-min justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('link') ? 'btn-primary' : ''}`}
|
||||
@ -97,7 +98,7 @@ const RichTextEditor = ({ editor }: RichTextEditorProps) => {
|
||||
<Icon path={mdiRedoVariant} className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<div className="flex gap-2 h-min justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-square ${editor.isActive('bold') ? 'btn-primary' : ''}`}
|
||||
@ -154,9 +155,10 @@ const RichTextEditor = ({ editor }: RichTextEditorProps) => {
|
||||
<Icon path={mdiFormatQuoteClose} className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="textarea textarea-bordered font-normal prose"
|
||||
className="textarea textarea-bordered font-normal prose max-w-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
@ -71,9 +71,9 @@ export const Success = ({ social }: CellSuccessProps<EditSocialById>) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl justify-center">
|
||||
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th className="w-0">Edit Social {social.id}</th>
|
||||
|
@ -38,9 +38,9 @@ const NewSocial = () => {
|
||||
createSocial({ variables: { input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl justify-center">
|
||||
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th>New Social</th>
|
||||
|
@ -44,31 +44,32 @@ const Social = ({ social }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="w-80">
|
||||
<div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl justify-center">
|
||||
<div className="w-full">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<th className="w-0">
|
||||
<tr>
|
||||
<th colSpan={2}>
|
||||
Social {social.id}: {social.name}
|
||||
</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th className="text-right">ID</th>
|
||||
<td>{social.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th className="text-right">Type</th>
|
||||
<td>{getLogoComponent(social.type)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th className="text-right">Name</th>
|
||||
<td>{social.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th className="text-right">Username</th>
|
||||
<td>{social.username}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -33,6 +33,6 @@ export const Failure = ({
|
||||
|
||||
export const Success = ({
|
||||
social,
|
||||
}: CellSuccessProps<FindSocialById, FindSocialByIdVariables>) => {
|
||||
return <Social social={social} />
|
||||
}
|
||||
}: CellSuccessProps<FindSocialById, FindSocialByIdVariables>) => (
|
||||
<Social social={social} />
|
||||
)
|
||||
|
@ -99,7 +99,7 @@ const SocialForm = (props: SocialFormProps) => {
|
||||
<Form<FormSocial>
|
||||
onSubmit={onSubmit}
|
||||
error={props.error}
|
||||
className="h-128 max-w-80 space-y-2"
|
||||
className="h-128 space-y-2"
|
||||
>
|
||||
<Label name="name" className="form-control w-full">
|
||||
<Label
|
||||
|
@ -3,11 +3,13 @@ import { ContactCardPortrait } from 'types/graphql'
|
||||
import { baseUrls, getLogoComponent, sortOrder } from 'src/lib/handle'
|
||||
|
||||
const SocialLinks = ({ socials }: ContactCardPortrait) => (
|
||||
<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 rounded-btn`}
|
||||
>
|
||||
{[...socials]
|
||||
.sort((a, b) => sortOrder.indexOf(a.type) - sortOrder.indexOf(b.type))
|
||||
.map((social, i) => (
|
||||
<div key={i} className="tooltip" data-tip={social.name}>
|
||||
<div key={i} className="tooltip rounded-btn" data-tip={social.name}>
|
||||
<a
|
||||
className="btn btn-square"
|
||||
href={`${baseUrls[social.type]}${social.username}`}
|
||||
|
@ -59,8 +59,8 @@ export const Success = ({ tag }: CellSuccessProps<EditTagById>) => {
|
||||
updateTag({ variables: { id, input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl justify-center">
|
||||
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
|
@ -34,8 +34,8 @@ const NewTag = () => {
|
||||
const onSave = (input: CreateTagInput) => createTag({ variables: { input } })
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl justify-center">
|
||||
<div className="overflow-hidden w-full overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
|
@ -42,8 +42,8 @@ const Tag = ({ tag }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="w-80">
|
||||
<div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl justify-center">
|
||||
<div className="w-full">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
@ -54,15 +54,15 @@ const Tag = ({ tag }: Props) => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th className="text-right">ID</th>
|
||||
<td>{tag.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th className="text-right">Tag</th>
|
||||
<td>{tag.tag}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Color</th>
|
||||
<th className="text-right">Color</th>
|
||||
<td>
|
||||
<div
|
||||
className="badge whitespace-nowrap"
|
||||
|
@ -35,7 +35,7 @@ const TagForm = (props: TagFormProps) => {
|
||||
<Form<FormTag>
|
||||
onSubmit={onSubmit}
|
||||
error={props.error}
|
||||
className="max-w-56 space-y-2"
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label name="tag" className="form-control w-full">
|
||||
<Label
|
||||
|
@ -50,7 +50,7 @@ const TagsList = ({ tags }: FindTags) => {
|
||||
<th className="w-0"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="overflow-y-scroll">
|
||||
{tags.map((tag, i) => {
|
||||
const actionButtons = (
|
||||
<>
|
||||
|
@ -26,7 +26,7 @@ const TagsSelector = ({
|
||||
}, [selectedTags, _tags])
|
||||
|
||||
return (
|
||||
<div className="w-80 space-y-2">
|
||||
<div className="space-y-2">
|
||||
{tags.length > 0 && (
|
||||
<>
|
||||
<p className="font-semibold">Tags</p>
|
||||
|
@ -8,7 +8,10 @@ const DARK_THEME = 'dark'
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const [theme, setTheme] = useState(
|
||||
localStorage.getItem('theme') ?? LIGHT_THEME
|
||||
localStorage.getItem('theme') ||
|
||||
([LIGHT_THEME, DARK_THEME].includes(process.env.DEFAULT_THEME)
|
||||
? process.env.DEFAULT_THEME
|
||||
: LIGHT_THEME)
|
||||
)
|
||||
|
||||
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
@ -29,9 +29,10 @@ export const Failure = ({
|
||||
}: CellFailureProps<AdminTitlesQueryVariables>) => <CellFailure error={error} />
|
||||
|
||||
export const Success = ({ titles }: CellSuccessProps<AdminTitlesQuery>) => (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="flex mx-auto max-w-80 sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl 2xl:max-w-2xl justify-center">
|
||||
<div className="w-full">
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th className="w-0">Titles</th>
|
||||
@ -47,4 +48,5 @@ export const Success = ({ titles }: CellSuccessProps<AdminTitlesQuery>) => (
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -1,38 +1,28 @@
|
||||
import { ReactTyped } from 'react-typed'
|
||||
|
||||
interface TitlesProps {
|
||||
titles: string[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Titles = ({ titles, className }: TitlesProps) => {
|
||||
export const Titles = ({ titles }: 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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
{titlesFiltered.length >= 3 && (
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-0 justify-center items-center">
|
||||
<span className="badge md:badge-lg badge-ghost">
|
||||
{titlesFiltered[0]}
|
||||
</span>
|
||||
<div className="hidden sm:divider sm:divider-horizontal" />
|
||||
<span className="badge md:badge-lg badge-ghost">
|
||||
{titlesFiltered[1]}
|
||||
</span>
|
||||
<div className="hidden sm:divider sm:divider-horizontal" />
|
||||
<span className="badge md:badge-lg badge-ghost">
|
||||
{titlesFiltered[2]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</h1>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -8,8 +8,6 @@ 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
|
||||
}
|
||||
@ -25,14 +23,10 @@ const UPDATE_TITLES_MUTATION: TypedDocumentNode<UpdateTitlesInput> = gql`
|
||||
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(), [])
|
||||
@ -55,11 +49,8 @@ const TitlesForm = ({ titles }: TitlesFormProps) => {
|
||||
})
|
||||
|
||||
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) => (
|
||||
<Form onSubmit={onSubmit} className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Label key={i} name={`title${i}`} className="form-control w-full">
|
||||
<Label
|
||||
name={`title${i}`}
|
||||
@ -83,25 +74,24 @@ const TitlesForm = ({ titles }: TitlesFormProps) => {
|
||||
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>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-0 justify-center items-center py-2">
|
||||
<span className="badge badge-lg badge-ghost whitespace-nowrap">
|
||||
{states[0][0]}
|
||||
</span>
|
||||
<div className="hidden sm:divider sm:divider-horizontal" />
|
||||
<span className="badge badge-lg badge-ghost whitespace-nowrap">
|
||||
{states[1][0]}
|
||||
</span>
|
||||
<div className="hidden sm:divider sm:divider-horizontal" />
|
||||
<span className="badge badge-lg badge-ghost whitespace-nowrap">
|
||||
{states[2][0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="my-2 flex justify-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm uppercase"
|
||||
onClick={() => setPreview(!preview)}
|
||||
>
|
||||
{preview ? 'Hide' : 'Show'} Preview
|
||||
</button>
|
||||
<Submit
|
||||
disabled={updateLoading}
|
||||
className="btn btn-primary btn-sm uppercase"
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { countries } from 'countries-list'
|
||||
import { hydrateRoot, createRoot } from 'react-dom/client'
|
||||
|
||||
import App from 'src/App'
|
||||
@ -7,6 +8,11 @@ import App from 'src/App'
|
||||
* rather than replacing it.
|
||||
* https://react.dev/reference/react-dom/client/hydrateRoot
|
||||
*/
|
||||
enum Theme {
|
||||
light = 'light',
|
||||
dark = 'dark',
|
||||
}
|
||||
|
||||
const redwoodAppElement = document.getElementById('redwood-app')
|
||||
|
||||
if (!redwoodAppElement)
|
||||
@ -15,6 +21,16 @@ if (!redwoodAppElement)
|
||||
"exists in your 'web/src/index.html' file."
|
||||
)
|
||||
|
||||
if (!Object.keys(countries).includes(process.env.COUNTRY))
|
||||
throw new Error(
|
||||
'Invalid COUNTRY environment variable, please select a valid ISO-3166-1 alpha-2 country code\n See https://en.wikipedia.org/wiki/ISO_3166-1#Codes'
|
||||
)
|
||||
|
||||
if (!(process.env.DEFAULT_THEME.toLowerCase() in Theme))
|
||||
throw new Error(
|
||||
'Invalid DEFAULT_THEME environment variable, please select either light or dark'
|
||||
)
|
||||
|
||||
if (redwoodAppElement.children?.length > 0)
|
||||
hydrateRoot(redwoodAppElement, <App />)
|
||||
else {
|
||||
|
@ -23,3 +23,7 @@
|
||||
.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.w-full .react-colorful {
|
||||
width: auto;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { mdiMenu, mdiLogout, mdiCog } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { Link, routes, useRoutePath, useRoutePaths } from '@redwoodjs/router'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
|
||||
@ -17,6 +17,9 @@ type NavbarLayoutProps = {
|
||||
}
|
||||
|
||||
const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
const routePaths = useRoutePaths()
|
||||
const routePath = useRoutePath()
|
||||
|
||||
const { isAuthenticated, logOut } = useAuth()
|
||||
|
||||
const navbarRoutes: NavbarRoute[] = [
|
||||
@ -25,7 +28,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
path: routes.projects(),
|
||||
},
|
||||
{
|
||||
name: 'Resume',
|
||||
name: 'Résumé',
|
||||
path: routes.resume(),
|
||||
},
|
||||
{
|
||||
@ -56,7 +59,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
path: routes.titles(),
|
||||
},
|
||||
{
|
||||
name: 'Resume',
|
||||
name: 'Résumé',
|
||||
path: routes.adminResume(),
|
||||
},
|
||||
]
|
||||
@ -80,12 +83,14 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
<ToasterWrapper />
|
||||
<div className="sticky top-0 z-50 p-2">
|
||||
<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 ${!isAuthenticated && routePath === routePaths.home ? 'first:space-x-0' : 'lg:first:space-x-0'}`}
|
||||
>
|
||||
<div className="dropdown">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="btn btn-square btn-ghost lg:hidden"
|
||||
className={`btn btn-square btn-ghost ${!isAuthenticated && routePath === routePaths.home ? 'hidden' : 'lg:hidden'}`}
|
||||
>
|
||||
<Icon path={mdiMenu} className="text-base-content-100 size-8" />
|
||||
</div>
|
||||
@ -94,12 +99,12 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content -ml-2 mt-4 w-36 gap-2 rounded-box bg-base-200 shadow-xl"
|
||||
>
|
||||
{isAuthenticated && (
|
||||
{isAuthenticated && routePath !== routePaths.home && (
|
||||
<p className="btn btn-active no-animation btn-sm btn-block">
|
||||
Public
|
||||
</p>
|
||||
)}
|
||||
{navbarButtons()}
|
||||
{routePath !== routePaths.home && navbarButtons()}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<p className="btn btn-active no-animation btn-sm btn-block">
|
||||
@ -129,7 +134,9 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="navbar-center hidden lg:flex">
|
||||
<div className="space-x-2 font-syne">{navbarButtons()}</div>
|
||||
<div className="space-x-2 font-syne">
|
||||
{routePath !== routePaths.home && navbarButtons()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="navbar-end space-x-2">
|
||||
{isAuthenticated && (
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ContactCardCell from 'src/components/ContactCard/ContactCardCell'
|
||||
|
||||
const ContactPage = () => {
|
||||
@ -29,13 +27,9 @@ const ContactPage = () => {
|
||||
}, [width, height])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Contact" />
|
||||
|
||||
<div className="flex min-h-[calc(100vh-6rem)] items-center justify-center">
|
||||
<div className="flex min-h-[calc(100dvh-6rem)] items-center justify-center">
|
||||
<ContactCardCell />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { mdiCompass, mdiContacts } from '@mdi/js'
|
||||
import { mdiCodeBraces, mdiContacts, mdiFileDocument } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import type { TCountryCode } from 'countries-list'
|
||||
import { getCountryData } from 'countries-list'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
@ -9,24 +11,52 @@ import { getLogoComponent } from 'src/lib/handle'
|
||||
|
||||
const HomePage = () => (
|
||||
<>
|
||||
<Metadata title="Home" />
|
||||
<Metadata
|
||||
title="Home"
|
||||
og={{
|
||||
title: `${process.env.FIRST_NAME} ${process.env.LAST_NAME}`,
|
||||
description: 'Check out my portfolio!',
|
||||
type: 'website',
|
||||
url: routes.home(),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="hero min-h-[calc(100vh-6rem)]">
|
||||
<div className="hero min-h-[calc(100dvh-6rem)]">
|
||||
<div className="hero-content flex flex-col gap-8">
|
||||
<div className="text-center">
|
||||
<TitlesCell className="text-primary" />
|
||||
<div className="flex flex-col text-center gap-8">
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold">
|
||||
Hey 👋, I'm {`${process.env.FIRST_NAME}`}
|
||||
</h1>
|
||||
<TitlesCell />
|
||||
<h3 className="text-xl">
|
||||
📍{' '}
|
||||
{[
|
||||
process.env.CITY,
|
||||
process.env.STATE,
|
||||
getCountryData(process.env.COUNTRY as TCountryCode).name,
|
||||
]
|
||||
.filter((s) => s && s !== '')
|
||||
.join(', ')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Link
|
||||
to={routes.projects()}
|
||||
className="btn btn-primary btn-outline sm:btn-wide btn-lg"
|
||||
className="btn btn-primary btn-outline sm:w-40 sm:btn-lg"
|
||||
>
|
||||
<Icon path={mdiCompass} className="size-6" />
|
||||
Explore
|
||||
<Icon path={mdiCodeBraces} className="size-6" />
|
||||
Projects
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.resume()}
|
||||
className="btn btn-primary btn-outline sm:w-40 sm:btn-lg"
|
||||
>
|
||||
<Icon path={mdiFileDocument} className="size-6" />
|
||||
Résumé
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.contact()}
|
||||
className="btn btn-primary btn-outline sm:btn-wide btn-lg"
|
||||
className="btn btn-primary btn-outline sm:w-40 sm:btn-lg"
|
||||
>
|
||||
<Icon path={mdiContacts} className="size-6" />
|
||||
Contact
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ProjectCell from 'src/components/Project/ProjectCell'
|
||||
|
||||
interface ProjectPageProps {
|
||||
@ -7,13 +5,7 @@ interface ProjectPageProps {
|
||||
}
|
||||
|
||||
const ProjectPage = ({ id }: ProjectPageProps) => {
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Project" />
|
||||
|
||||
<ProjectCell id={id} />
|
||||
</>
|
||||
)
|
||||
return <ProjectCell id={id} />
|
||||
}
|
||||
|
||||
export default ProjectPage
|
||||
|
@ -1,19 +1,15 @@
|
||||
import mobile from 'is-mobile'
|
||||
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ProjectsShowcaseCell from 'src/components/Project/ProjectsShowcaseCell'
|
||||
|
||||
const ProjectsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<p className="mt-8">
|
||||
{mobile({
|
||||
tablet: true,
|
||||
})
|
||||
@ -24,7 +20,6 @@ const ProjectsPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectsShowcaseCell />
|
||||
</>
|
||||
)
|
||||
|
@ -5,7 +5,7 @@ import AdminResumeCell from 'src/components/Resume/AdminResumeCell/AdminResumeCe
|
||||
const ResumePage = () => {
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Resume" />
|
||||
<Metadata title="Résumé" />
|
||||
<AdminResumeCell />
|
||||
</>
|
||||
)
|
||||
|
@ -1,15 +1,7 @@
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ResumeCell from 'src/components/Resume/ResumeCell'
|
||||
|
||||
const ResumePage = () => {
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Resume" />
|
||||
|
||||
<ResumeCell />
|
||||
</>
|
||||
)
|
||||
return <ResumeCell />
|
||||
}
|
||||
|
||||
export default ResumePage
|
||||
|
28
yarn.lock
28
yarn.lock
@ -7336,6 +7336,7 @@ __metadata:
|
||||
"@tus/file-store": "npm:^1.4.0"
|
||||
"@tus/server": "npm:^1.7.0"
|
||||
"@types/nodemailer": "npm:^6.4.15"
|
||||
countries-list: "npm:^3.1.1"
|
||||
graphql-scalars: "npm:^1.23.0"
|
||||
nodemailer: "npm:^6.9.14"
|
||||
languageName: unknown
|
||||
@ -9061,6 +9062,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"countries-list@npm:^3.1.1":
|
||||
version: 3.1.1
|
||||
resolution: "countries-list@npm:3.1.1"
|
||||
checksum: 10c0/26734d5927ee9dafa30e4da214b6ab7ecf7cdd93516e5bb077d6fdb1f9f37b4fd8698987b0062c1f3b0c95d18fe947f941ef0ab8c3cc486ef33f1c3dca2678b7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"crc-32@npm:^1.2.0":
|
||||
version: 1.2.2
|
||||
resolution: "crc-32@npm:1.2.2"
|
||||
@ -16595,17 +16603,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-typed@npm:^2.0.12":
|
||||
version: 2.0.12
|
||||
resolution: "react-typed@npm:2.0.12"
|
||||
dependencies:
|
||||
typed.js: "npm:^2.1.0"
|
||||
peerDependencies:
|
||||
react: ">16.8.0"
|
||||
checksum: 10c0/31b0cb66c0245e74058f17a8a6d7496af51a3964a4a4af888312992883833090e0b1d4a0afb91c2ef1c93d3d1d476ad79ff5e9c44f7acfd24f3c75c634e2155e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:18.3.1":
|
||||
version: 18.3.1
|
||||
resolution: "react@npm:18.3.1"
|
||||
@ -18691,13 +18688,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typed.js@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "typed.js@npm:2.1.0"
|
||||
checksum: 10c0/a85339064b0d96d01622ea4729436a36172aa0b634cf8e07c1735a5dd50bb35e92b97db412c307fa3364dc2c106efbf9ab7a0bd47a5268f5f7034b4a5effdab2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typescript@npm:5.6.2":
|
||||
version: 5.6.2
|
||||
resolution: "typescript@npm:5.6.2"
|
||||
@ -19234,6 +19224,7 @@ __metadata:
|
||||
"@uppy/tus": "npm:^4.0.0"
|
||||
"@uppy/webcam": "npm:^4.0.1"
|
||||
autoprefixer: "npm:^10.4.20"
|
||||
countries-list: "npm:^3.1.1"
|
||||
daisyui: "npm:^4.12.10"
|
||||
date-fns: "npm:^4.1.0"
|
||||
humanize-string: "npm:2.1.0"
|
||||
@ -19243,7 +19234,6 @@ __metadata:
|
||||
react-colorful: "npm:^5.6.1"
|
||||
react-dom: "npm:18.3.1"
|
||||
react-html-parser: "npm:^2.0.2"
|
||||
react-typed: "npm:^2.0.12"
|
||||
tailwindcss: "npm:^3.4.8"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
Reference in New Issue
Block a user