Titles with a cool effect

This commit is contained in:
Ahmed Al-Taiar
2024-10-04 23:13:44 -04:00
parent 8671f47e91
commit e5f9bbd462
19 changed files with 367 additions and 91 deletions

View 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")
);

View File

@ -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 {

View File

@ -0,0 +1,18 @@
export const schema = gql`
type Titles {
id: Int!
titles: [String]!
}
type Query {
titles: Titles! @skipAuth
}
input UpdateTitlesInput {
titles: [String]!
}
type Mutation {
updateTitles(input: UpdateTitlesInput!): Titles! @requireAuth
}
`

View File

@ -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
}
`

View File

@ -0,0 +1,11 @@
import type { QueryResolvers, MutationResolvers } from 'types/graphql'
import { db } from 'src/lib/db'
export const titles: QueryResolvers['titles'] = () => db.titles.findFirst()
export const updateTitles: MutationResolvers['updateTitles'] = ({ input }) =>
db.titles.update({
data: input,
where: { id: 1 },
})

View File

@ -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 },
})

View File

@ -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)
}

View File

@ -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>

View File

@ -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} />
)

View File

@ -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}
/>

View 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>
)

View 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&apos;m {`${process.env.FIRST_NAME}`}
{titlesFiltered.length > 0 && (
<>
, <br />
<ReactTyped
className={className}
strings={titlesFiltered}
typeSpeed={50}
backSpeed={40}
backDelay={1000}
startWhenVisible
loop
/>
</>
)}
</h1>
</>
)
}

View 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} />
)

View 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&apos;m {`${process.env.FIRST_NAME}`},{' '}
<span className="text-primary">{states[i][0]}</span>
</p>
</div>
)}
</Label>
))}
<nav className="my-2 flex justify-center space-x-2">
<button
type="button"
className="btn btn-sm"
onClick={() => setPreview(!preview)}
>
{preview ? 'Hide' : 'Show'} Preview
</button>
<Submit
disabled={updateLoading}
className="btn btn-primary btn-sm uppercase"
>
Save
</Submit>
</nav>
</Form>
)
}
export default TitlesForm

View File

@ -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>

View File

@ -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>

View File

@ -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>
</>
)
}

View File

@ -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}

View 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