Titles with a cool effect
This commit is contained in:
16
api/db/migrations/20241005014130_/migration.sql
Normal file
16
api/db/migrations/20241005014130_/migration.sql
Normal file
@ -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")
|
||||
);
|
@ -63,9 +63,9 @@ model Resume {
|
||||
fileId String
|
||||
}
|
||||
|
||||
model Title {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
model Titles {
|
||||
id Int @id @default(autoincrement())
|
||||
titles String[] @default([])
|
||||
}
|
||||
|
||||
model Tag {
|
||||
|
18
api/src/graphql/title.sdl.ts
Normal file
18
api/src/graphql/title.sdl.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export const schema = gql`
|
||||
type Titles {
|
||||
id: Int!
|
||||
titles: [String]!
|
||||
}
|
||||
|
||||
type Query {
|
||||
titles: Titles! @skipAuth
|
||||
}
|
||||
|
||||
input UpdateTitlesInput {
|
||||
titles: [String]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
updateTitles(input: UpdateTitlesInput!): Titles! @requireAuth
|
||||
}
|
||||
`
|
@ -1,25 +0,0 @@
|
||||
export const schema = gql`
|
||||
type Title {
|
||||
id: Int!
|
||||
title: String!
|
||||
}
|
||||
|
||||
type Query {
|
||||
titles: [Title!]! @skipAuth
|
||||
title(id: Int!): Title @skipAuth
|
||||
}
|
||||
|
||||
input CreateTitleInput {
|
||||
title: String!
|
||||
}
|
||||
|
||||
input UpdateTitleInput {
|
||||
title: String
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createTitle(input: CreateTitleInput!): Title! @requireAuth
|
||||
updateTitle(id: Int!, input: UpdateTitleInput!): Title! @requireAuth
|
||||
deleteTitle(id: Int!): Title! @requireAuth
|
||||
}
|
||||
`
|
11
api/src/services/title/title.ts
Normal file
11
api/src/services/title/title.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { QueryResolvers, MutationResolvers } from 'types/graphql'
|
||||
|
||||
import { db } from 'src/lib/db'
|
||||
|
||||
export const titles: QueryResolvers['titles'] = () => db.titles.findFirst()
|
||||
|
||||
export const updateTitles: MutationResolvers['updateTitles'] = ({ input }) =>
|
||||
db.titles.update({
|
||||
data: input,
|
||||
where: { id: 1 },
|
||||
})
|
@ -1,26 +0,0 @@
|
||||
import type { QueryResolvers, MutationResolvers } from 'types/graphql'
|
||||
|
||||
import { db } from 'src/lib/db'
|
||||
|
||||
export const titles: QueryResolvers['titles'] = () => db.title.findMany()
|
||||
|
||||
export const title: QueryResolvers['title'] = ({ id }) =>
|
||||
db.title.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
export const createTitle: MutationResolvers['createTitle'] = ({ input }) =>
|
||||
db.title.create({
|
||||
data: input,
|
||||
})
|
||||
|
||||
export const updateTitle: MutationResolvers['updateTitle'] = ({ id, input }) =>
|
||||
db.title.update({
|
||||
data: input,
|
||||
where: { id },
|
||||
})
|
||||
|
||||
export const deleteTitle: MutationResolvers['deleteTitle'] = ({ id }) =>
|
||||
db.title.delete({
|
||||
where: { id },
|
||||
})
|
@ -3,6 +3,8 @@ import { db } from 'api/src/lib/db'
|
||||
|
||||
import { hashPassword } from '@redwoodjs/auth-dbauth-api'
|
||||
|
||||
const MAX_TITLES = 5
|
||||
|
||||
export default async () => {
|
||||
try {
|
||||
const admin = {
|
||||
@ -29,6 +31,24 @@ export default async () => {
|
||||
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) {
|
||||
console.error(error)
|
||||
}
|
||||
|
@ -27,6 +27,10 @@ const Routes = () => {
|
||||
<Route path="/admin/portrait" page={PortraitPortraitPage} name="portrait" />
|
||||
</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>
|
||||
|
@ -22,9 +22,7 @@ export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
|
||||
export const Empty = () => <CellEmpty />
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps<FindPortraitVariables>) => (
|
||||
<CellFailure error={error} />
|
||||
)
|
||||
|
@ -3,14 +3,17 @@ import { useState, useEffect } from 'react'
|
||||
import { mdiWeatherSunny, mdiWeatherNight } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
const LIGHT_THEME = 'light'
|
||||
const DARK_THEME = 'dark'
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const [theme, setTheme] = useState(
|
||||
localStorage.getItem('theme') ? localStorage.getItem('theme') : 'light'
|
||||
localStorage.getItem('theme') ? localStorage.getItem('theme') : LIGHT_THEME
|
||||
)
|
||||
|
||||
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) setTheme('dark')
|
||||
else setTheme('light')
|
||||
if (e.target.checked) setTheme(DARK_THEME)
|
||||
else setTheme(LIGHT_THEME)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -31,7 +34,7 @@ const ThemeToggle = () => {
|
||||
<input
|
||||
type="checkbox"
|
||||
className="theme-controller"
|
||||
checked={theme === 'dark'}
|
||||
checked={theme === DARK_THEME}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
|
||||
|
50
web/src/components/Title/AdminTitlesCell/AdminTitlesCell.tsx
Normal file
50
web/src/components/Title/AdminTitlesCell/AdminTitlesCell.tsx
Normal file
@ -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>
|
||||
)
|
32
web/src/components/Title/Titles/Titles.tsx
Normal file
32
web/src/components/Title/Titles/Titles.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
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
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
</>
|
||||
)
|
||||
}
|
45
web/src/components/Title/TitlesCell/TitlesCell.tsx
Normal file
45
web/src/components/Title/TitlesCell/TitlesCell.tsx
Normal file
@ -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} />
|
||||
)
|
113
web/src/components/Title/TitlesForm/TitlesForm.tsx
Normal file
113
web/src/components/Title/TitlesForm/TitlesForm.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
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">
|
||||
{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
|
@ -51,6 +51,10 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
name: 'Portrait',
|
||||
path: routes.portrait(),
|
||||
},
|
||||
{
|
||||
name: 'Titles',
|
||||
path: routes.titles(),
|
||||
},
|
||||
{
|
||||
name: 'Resume',
|
||||
path: routes.adminResume(),
|
||||
@ -66,11 +70,9 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
|
||||
const navbarAdminButtons = () =>
|
||||
navbarAdminRoutes.map((route, i) => (
|
||||
<li key={i}>
|
||||
<Link to={route.path} className="btn btn-ghost btn-sm">
|
||||
{route.name}
|
||||
</Link>
|
||||
</li>
|
||||
<Link key={i} to={route.path} className="btn btn-ghost btn-sm">
|
||||
{route.name}
|
||||
</Link>
|
||||
))
|
||||
|
||||
return (
|
||||
@ -90,31 +92,24 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
<div
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
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()}
|
||||
{isAuthenticated && (
|
||||
<div className="dropdown sm:hidden">
|
||||
<div
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content -ml-2 mt-4 w-36 space-y-2 rounded-box bg-base-200 shadow-xl"
|
||||
>
|
||||
<p className="btn btn-active no-animation btn-sm btn-block">
|
||||
Admin
|
||||
</p>
|
||||
|
||||
{navbarAdminButtons()}
|
||||
<li>
|
||||
<button
|
||||
onClick={logOut}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<p className="btn btn-active no-animation btn-sm btn-block">
|
||||
Admin
|
||||
</p>
|
||||
{navbarAdminButtons()}
|
||||
<button onClick={logOut} className="btn btn-error btn-sm">
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -138,7 +133,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
</div>
|
||||
<div className="navbar-end space-x-2">
|
||||
{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}>
|
||||
<Icon path={mdiLogout} className="size-8" />
|
||||
</button>
|
||||
@ -153,7 +148,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
<ul
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
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()}
|
||||
</ul>
|
||||
|
@ -72,7 +72,7 @@ const ForgotPasswordPage = () => {
|
||||
<FieldError name="username" className="text-sm text-error" />
|
||||
|
||||
<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>
|
||||
</Form>
|
||||
</div>
|
||||
|
@ -1,9 +1,19 @@
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import TitlesCell from 'src/components/Title/TitlesCell'
|
||||
|
||||
const HomePage = () => {
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Home" />
|
||||
|
||||
<div className="hero min-h-64">
|
||||
<div className="hero-content">
|
||||
<div className="max-w-xl text-center">
|
||||
<TitlesCell className="text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -102,7 +102,7 @@ const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
|
||||
|
||||
<div className="flex w-full">
|
||||
<Submit
|
||||
className={`btn btn-primary mx-auto ${
|
||||
className={`btn btn-primary btn-sm mx-auto ${
|
||||
!enabled ? 'btn-disabled' : ''
|
||||
}`}
|
||||
disabled={!enabled}
|
||||
|
12
web/src/pages/Title/TitlesPage/TitlesPage.tsx
Normal file
12
web/src/pages/Title/TitlesPage/TitlesPage.tsx
Normal file
@ -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