Social handles CRUD (admin side, nothing user-facing)

This commit is contained in:
Ahmed Al-Taiar
2024-08-19 23:20:32 -04:00
parent 1c46a8e963
commit c7d87e36f2
39 changed files with 1229 additions and 480 deletions

View File

@@ -0,0 +1,12 @@
-- CreateEnum
CREATE TYPE "Handle" AS ENUM ('x', 'threads', 'instagram', 'facebook', 'tiktok', 'youtube', 'linkedin', 'github', 'email', 'custom');
-- CreateTable
CREATE TABLE "Social" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"type" "Handle" NOT NULL,
"username" TEXT NOT NULL,
CONSTRAINT "Social_pkey" PRIMARY KEY ("id")
);

View File

@@ -14,6 +14,19 @@ generator client {
binaryTargets = "native"
}
enum Handle {
x
threads
instagram
facebook
tiktok
youtube
linkedin
github
email
custom
}
model User {
id Int @id @default(autoincrement())
username String @unique
@@ -23,3 +36,10 @@ model User {
resetToken String?
resetTokenExpiresAt DateTime?
}
model Social {
id Int @id @default(autoincrement())
name String
type Handle
username String
}

View File

@@ -0,0 +1,44 @@
export const schema = gql`
type Social {
id: Int!
name: String!
type: Handle!
username: String!
}
enum Handle {
x
threads
instagram
facebook
tiktok
youtube
linkedin
github
email
custom
}
type Query {
socials: [Social!]! @requireAuth
social(id: Int!): Social @requireAuth
}
input CreateSocialInput {
name: String!
type: Handle!
username: String!
}
input UpdateSocialInput {
name: String
type: Handle
username: String
}
type Mutation {
createSocial(input: CreateSocialInput!): Social! @requireAuth
updateSocial(id: Int!, input: UpdateSocialInput!): Social! @requireAuth
deleteSocial(id: Int!): Social! @requireAuth
}
`

View File

@@ -0,0 +1,59 @@
import type {
QueryResolvers,
MutationResolvers,
CreateSocialInput,
UpdateSocialInput,
} from 'types/graphql'
import { ValidationError } from '@redwoodjs/graphql-server'
import { db } from 'src/lib/db'
const urlRegex =
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i
const emailRegex =
/^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/
export const socials: QueryResolvers['socials'] = () => db.social.findMany()
export const social: QueryResolvers['social'] = ({ id }) =>
db.social.findUnique({
where: { id },
})
export const deleteSocial: MutationResolvers['deleteSocial'] = ({ id }) =>
db.social.delete({
where: { id },
})
export const createSocial: MutationResolvers['createSocial'] = ({ input }) => {
validateInput(input)
return db.social.create({
data: input,
})
}
export const updateSocial: MutationResolvers['updateSocial'] = ({
id,
input,
}) => {
validateInput(input)
return db.social.update({
data: input,
where: { id },
})
}
const validateInput = (input: CreateSocialInput | UpdateSocialInput) => {
if (!input.name || input.name.trim().length === 0)
throw new ValidationError('Name is required')
if (!input.type) throw new ValidationError('Type is required')
if (input.type === 'custom' && !urlRegex.test(input.username))
throw new ValidationError('Invalid URL')
else if (input.type === 'email' && !emailRegex.test(input.username))
throw new ValidationError('Invalid Email')
else if (input.username.trim().length === 0)
throw new ValidationError('Username is required')
}

View File

@@ -1,7 +1,37 @@
import {
SiXHex,
SiThreadsHex,
SiInstagramHex,
SiFacebookHex,
SiTiktokHex,
SiYoutubeHex,
SiLinkedinHex,
SiGithubHex,
} from '@icons-pack/react-simple-icons'
const invertColor = (hex) => {
if (hex.startsWith('#')) hex = hex.slice(1)
if (hex.length !== 6) throw new Error('Invalid hex color code')
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
const invertedR = (255 - r).toString(16).padStart(2, '0')
const invertedG = (255 - g).toString(16).padStart(2, '0')
const invertedB = (255 - b).toString(16).padStart(2, '0')
return `#${invertedR}${invertedG}${invertedB}`
}
/** @type {import('tailwindcss').Config} */
export const content = ['src/**/*.{js,jsx,ts,tsx}']
export const theme = {
extend: {
width: {
46: '11.5rem',
},
fontFamily: {
syne: ['Syne', 'sans-serif'],
inter: ['Inter', 'sans-serif'],
@@ -22,8 +52,43 @@ export const theme = {
'100%': { transform: 'scale(0.75) translateY(-110%)', opacity: 0 },
},
},
colors: {
x: {
light: SiXHex,
dark: invertColor(SiXHex),
},
threads: {
light: SiThreadsHex,
dark: invertColor(SiThreadsHex),
},
instagram: {
light: SiInstagramHex,
dark: SiInstagramHex,
},
facebook: {
light: SiFacebookHex,
dark: SiFacebookHex,
},
tiktok: {
light: SiTiktokHex,
dark: invertColor(SiTiktokHex),
},
youtube: {
light: SiYoutubeHex,
dark: SiYoutubeHex,
},
linkedin: {
light: SiLinkedinHex,
dark: SiLinkedinHex,
},
github: {
light: SiGithubHex,
dark: invertColor(SiGithubHex),
},
},
},
}
export const darkMode = ['class', '[data-theme="dark"]']
export const plugins = [require('daisyui')]
export const daisyui = { themes: ['light', 'dark'] }

View File

@@ -11,6 +11,7 @@
]
},
"dependencies": {
"@icons-pack/react-simple-icons": "^10.0.0",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@redwoodjs/auth-dbauth-web": "7.7.4",
@@ -26,6 +27,7 @@
"@uppy/progress-bar": "^4.0.0",
"@uppy/react": "^4.0.1",
"@uppy/tus": "^4.0.0",
"humanize-string": "2.1.0",
"react": "18.2.0",
"react-dom": "18.2.0"
},

7
web/quick-lint-js.config Normal file
View File

@@ -0,0 +1,7 @@
{
"globals": {
"gql": {
"writable": false
}
}
}

View File

@@ -4,7 +4,6 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
import FatalErrorPage from 'src/pages/FatalErrorPage'
import Routes from 'src/Routes'
import './scaffold.css'
import { AuthProvider, useAuth } from './auth'
import './index.css'

View File

@@ -1,4 +1,6 @@
import { Router, Route, Set } from '@redwoodjs/router'
import { Router, Route, Set, PrivateSet } from '@redwoodjs/router'
import ScaffoldLayout from 'src/layouts/ScaffoldLayout'
import { useAuth } from './auth'
import AccountbarLayout from './layouts/AccountbarLayout/AccountbarLayout'
@@ -7,6 +9,15 @@ import NavbarLayout from './layouts/NavbarLayout/NavbarLayout'
const Routes = () => {
return (
<Router useAuth={useAuth}>
<PrivateSet unauthenticated="home">
<Set wrap={ScaffoldLayout} title="Socials" titleTo="socials" buttonLabel="New Social" buttonTo="newSocial">
<Route path="/socials/new" page={SocialNewSocialPage} name="newSocial" />
<Route path="/socials/{id:Int}/edit" page={SocialEditSocialPage} name="editSocial" />
<Route path="/socials/{id:Int}" page={SocialSocialPage} name="social" />
<Route path="/socials" page={SocialSocialsPage} name="socials" />
</Set>
</PrivateSet>
<Set wrap={AccountbarLayout} title="Login">
<Route path="/login" page={LoginPage} name="login" />
</Set>

View File

@@ -0,0 +1,9 @@
const CellEmpty = () => (
<div className="flex justify-center">
<div className="alert w-auto">
<p className="text-center font-inter">It&#39;s empty in here...</p>
</div>
</div>
)
export default CellEmpty

View File

@@ -0,0 +1,17 @@
import { mdiAlert } from '@mdi/js'
import Icon from '@mdi/react'
interface CellFailureProps {
error: Error
}
const CellFailure = ({ error }: CellFailureProps) => (
<div className="flex w-auto justify-center">
<div className="alert alert-error w-auto">
<Icon path={mdiAlert} className="size-6" />
<p className="font-inter">Error! {error.message}</p>
</div>
</div>
)
export default CellFailure

View File

@@ -0,0 +1,7 @@
const CellLoading = () => (
<div className="flex w-auto justify-center">
<p className="loading loading-bars loading-lg" />
</div>
)
export default CellLoading

View File

@@ -0,0 +1,96 @@
import type {
EditSocialById,
UpdateSocialInput,
UpdateSocialMutationVariables,
} from 'types/graphql'
import { navigate, routes } from '@redwoodjs/router'
import type {
CellSuccessProps,
CellFailureProps,
TypedDocumentNode,
} from '@redwoodjs/web'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
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 SocialForm from 'src/components/Social/SocialForm'
export const QUERY: TypedDocumentNode<EditSocialById> = gql`
query EditSocialById($id: Int!) {
social: social(id: $id) {
id
name
type
username
}
}
`
const UPDATE_SOCIAL_MUTATION: TypedDocumentNode<
EditSocialById,
UpdateSocialMutationVariables
> = gql`
mutation UpdateSocialMutation($id: Int!, $input: UpdateSocialInput!) {
updateSocial(id: $id, input: $input) {
id
name
type
username
}
}
`
export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({ error }: CellFailureProps) => (
<CellFailure error={error} />
)
export const Success = ({ social }: CellSuccessProps<EditSocialById>) => {
const [updateSocial, { loading, error }] = useMutation(
UPDATE_SOCIAL_MUTATION,
{
onCompleted: () => {
toast.success('Social updated')
navigate(routes.socials())
},
onError: (error) => {
toast.error(error.message)
},
}
)
const onSave = (
input: UpdateSocialInput,
id: EditSocialById['social']['id']
) => {
updateSocial({ 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">
<thead className="bg-base-200 font-syne">
<th className="w-0">Edit Social {social.id}</th>
</thead>
<tbody>
<th>
<SocialForm
social={social}
onSave={onSave}
error={error}
loading={loading}
/>
</th>
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import type {
CreateSocialMutation,
CreateSocialInput,
CreateSocialMutationVariables,
} from 'types/graphql'
import { navigate, routes } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import type { TypedDocumentNode } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import SocialForm from 'src/components/Social/SocialForm'
const CREATE_SOCIAL_MUTATION: TypedDocumentNode<
CreateSocialMutation,
CreateSocialMutationVariables
> = gql`
mutation CreateSocialMutation($input: CreateSocialInput!) {
createSocial(input: $input) {
id
}
}
`
const NewSocial = () => {
const [createSocial, { loading, error }] = useMutation(
CREATE_SOCIAL_MUTATION,
{
onCompleted: () => {
toast.success('Social created')
navigate(routes.socials())
},
onError: (error) => {
toast.error(error.message)
},
}
)
const onSave = (input: CreateSocialInput) => {
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">
<thead className="bg-base-200 font-syne">
<th className="w-0">New Social</th>
</thead>
<tbody>
<th>
<SocialForm onSave={onSave} error={error} loading={loading} />
</th>
</tbody>
</table>
</div>
</div>
)
}
export default NewSocial

View File

@@ -0,0 +1,101 @@
import type {
DeleteSocialMutation,
DeleteSocialMutationVariables,
FindSocialById,
} 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 { getLogoComponent } from 'src/lib/handle'
const DELETE_SOCIAL_MUTATION: TypedDocumentNode<
DeleteSocialMutation,
DeleteSocialMutationVariables
> = gql`
mutation DeleteSocialMutation($id: Int!) {
deleteSocial(id: $id) {
id
}
}
`
interface Props {
social: NonNullable<FindSocialById['social']>
}
const Social = ({ social }: Props) => {
const [deleteSocial] = useMutation(DELETE_SOCIAL_MUTATION, {
onCompleted: () => {
toast.success('Social deleted')
navigate(routes.socials())
},
onError: (error) => {
toast.error(error.message)
},
})
const onDeleteClick = (
name: string,
id: DeleteSocialMutationVariables['id']
) => {
if (confirm(`Are you sure you want to delete ${name}?`))
deleteSocial({ variables: { id } })
}
return (
<div className="flex w-full justify-center">
<div className="w-80">
<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">
Social {social.id}: {social.name}
</th>
<th>&nbsp;</th>
</thead>
<tbody>
<tr>
<th>ID</th>
<td>{social.id}</td>
</tr>
<tr>
<th>Type</th>
<td>{getLogoComponent(social.type)}</td>
</tr>
<tr>
<th>Name</th>
<td>{social.name}</td>
</tr>
<tr>
<th>Username</th>
<td>{social.username}</td>
</tr>
</tbody>
</table>
</div>
<nav className="my-2 flex justify-center space-x-2">
<Link
to={routes.editSocial({ id: social.id })}
title={'Edit social ' + social.id}
className="btn btn-primary btn-sm uppercase"
>
Edit
</Link>
<button
type="button"
title={'Delete social ' + social.id}
className="btn btn-error btn-sm uppercase"
onClick={() => onDeleteClick(social.name, social.id)}
>
Delete
</button>
</nav>
</div>
</div>
)
}
export default Social

View File

@@ -0,0 +1,40 @@
import type { FindSocialById, FindSocialByIdVariables } 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 Social from 'src/components/Social/Social'
export const QUERY: TypedDocumentNode<
FindSocialById,
FindSocialByIdVariables
> = gql`
query FindSocialById($id: Int!) {
social: social(id: $id) {
id
name
type
username
}
}
`
export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({
error,
}: CellFailureProps<FindSocialByIdVariables>) => <CellFailure error={error} />
export const Success = ({
social,
}: CellSuccessProps<FindSocialById, FindSocialByIdVariables>) => {
return <Social social={social} />
}

View File

@@ -0,0 +1,210 @@
import { useEffect, useRef, useState } from 'react'
import { mdiMenuUp, mdiMenuDown, mdiAccount, mdiRename } from '@mdi/js'
import Icon from '@mdi/react'
import type { EditSocialById, UpdateSocialInput } from 'types/graphql'
import type { RWGqlError } from '@redwoodjs/forms'
import { Form, FieldError, Label, TextField, Submit } from '@redwoodjs/forms'
import { baseUrls, getLogoComponent } from 'src/lib/handle'
type FormSocial = NonNullable<EditSocialById['social']>
interface SocialFormProps {
social?: EditSocialById['social']
onSave: (data: UpdateSocialInput, id?: FormSocial['id']) => void
error: RWGqlError
loading: boolean
}
const types: FormSocial['type'][] = [
'x',
'threads',
'instagram',
'facebook',
'tiktok',
'youtube',
'linkedin',
'github',
'email',
'custom',
]
const SocialForm = (props: SocialFormProps) => {
const [type, setType] = useState<FormSocial['type']>(
props.social?.type ?? 'x'
)
const typesDropdownRef = useRef<HTMLDivElement>(null)
const [typesDropdownOpen, setTypesDropdownOpen] = useState(false)
const [username, setUsername] = useState(props.social?.username ?? '')
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
typesDropdownRef.current &&
!typesDropdownRef.current.contains(event.target as Node)
)
setTypesDropdownOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const onSubmit = (data: FormSocial) => {
data.type = type
data.name.trim()
data.username.trim()
props.onSave(data, props?.social?.id)
}
return (
<Form<FormSocial>
onSubmit={onSubmit}
error={props.error}
className="h-96 max-w-80 space-y-2"
>
<Label name="name" className="form-control w-full">
<Label
name="name"
className="input input-bordered flex items-center gap-2"
errorClassName="input input-bordered flex items-center gap-2 input-error"
>
<Label
name="name"
className="h-4 w-4 opacity-70"
errorClassName="h-4 w-4 text-error"
>
<Icon path={mdiRename} />
</Label>
<TextField
name="name"
placeholder="Name"
defaultValue={props.social?.name}
className="w-full"
validation={{
required: {
value: true,
message: 'Required',
},
}}
/>
</Label>
<div className="label">
<FieldError
name="name"
className="text-xs font-semibold text-error"
/>
</div>
</Label>
<Label name="username" className="form-control w-full">
<Label
name="username"
className="input input-bordered flex items-center gap-2"
errorClassName="input input-bordered flex items-center gap-2 input-error"
>
<Label
name="username"
className="h-4 w-4 opacity-70"
errorClassName="h-4 w-4 text-error"
>
<Icon path={mdiAccount} />
</Label>
<TextField
name="username"
placeholder={
type == 'custom' ? 'URL' : type == 'email' ? 'Email' : 'Username'
}
className="w-full"
defaultValue={username}
autoCorrect="off"
autoComplete="off"
onChange={(e) => setUsername(e.target.value)}
validation={{
required: {
value: true,
message: 'Required',
},
pattern: {
value:
type == 'email'
? /^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/
: type == 'custom' &&
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i,
message: `Invalid ${
type == 'custom' ? 'URL' : type == 'email' && 'Email'
}`,
},
}}
/>
</Label>
<div className="label">
<FieldError
name="username"
className="text-xs font-semibold text-error"
/>
{type !== 'custom' && type !== 'email' && (
<span className="label-text-alt">{`${baseUrls[type]}${username}`}</span>
)}
</div>
</Label>
<div className="dropdown mb-4" ref={typesDropdownRef}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div
tabIndex={0}
role="button"
className="btn"
onClick={() => setTypesDropdownOpen(!typesDropdownOpen)}
>
<div className="flex">
{getLogoComponent(type)}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className="swap swap-flip size-6">
<input type="checkbox" checked={typesDropdownOpen} disabled />
<Icon path={mdiMenuUp} className="swap-on -mr-2 ml-2 size-6" />
<Icon path={mdiMenuDown} className="swap-off -mr-2 ml-2 size-6" />
</label>
</div>
</div>
{typesDropdownOpen && (
<ul
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
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"
>
{types.map((type, i) => (
<li key={i}>
<button
className="btn btn-square btn-ghost"
onClick={() => {
setType(type)
setTypesDropdownOpen(false)
}}
>
{getLogoComponent(type)}
</button>
</li>
))}
</ul>
)}
</div>
<nav className="my-2 flex justify-center space-x-2">
<Submit
disabled={props.loading}
className="btn btn-primary btn-sm uppercase"
>
Save
</Submit>
</nav>
</Form>
)
}
export default SocialForm

View File

@@ -0,0 +1,127 @@
import { mdiDotsVertical } from '@mdi/js'
import Icon from '@mdi/react'
import type {
DeleteSocialMutation,
DeleteSocialMutationVariables,
FindSocials,
} from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import type { TypedDocumentNode } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { QUERY } from 'src/components/Social/SocialsCell'
import { truncate } from 'src/lib/formatters'
import { getLogoComponent } from 'src/lib/handle'
const DELETE_SOCIAL_MUTATION: TypedDocumentNode<
DeleteSocialMutation,
DeleteSocialMutationVariables
> = gql`
mutation DeleteSocialMutation($id: Int!) {
deleteSocial(id: $id) {
id
}
}
`
const SocialsList = ({ socials }: FindSocials) => {
const [deleteSocial] = useMutation(DELETE_SOCIAL_MUTATION, {
onCompleted: () => {
toast.success('Social deleted')
},
onError: (error) => {
toast.error(error.message)
},
refetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true,
})
const onDeleteClick = (
name: string,
id: DeleteSocialMutationVariables['id']
) => {
if (confirm(`Are you sure you want to delete ${name}?`))
deleteSocial({ variables: { id } })
}
return (
<div className="w-full 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">Type</th>
<th className="w-1/2">Name</th>
<th className="w-1/2">Username</th>
<th className="w-0">&nbsp;</th>
</tr>
</thead>
<tbody>
{socials.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 (
<tr key={social.id}>
<th>{getLogoComponent(social.type)}</th>
<td>{truncate(social.name)}</td>
<td>{truncate(social.username)}</td>
<td>
<nav className="hidden justify-end space-x-2 sm:flex">
{actionButtons}
</nav>
<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
// 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>
</table>
</div>
)
}
export default SocialsList

View File

@@ -0,0 +1,37 @@
import type { FindSocials, FindSocialsVariables } 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 Socials from 'src/components/Social/Socials'
export const QUERY: TypedDocumentNode<FindSocials, FindSocialsVariables> = gql`
query FindSocials {
socials {
id
name
type
username
}
}
`
export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({ error }: CellFailureProps<FindSocials>) => (
<CellFailure error={error} />
)
export const Success = ({
socials,
}: CellSuccessProps<FindSocials, FindSocialsVariables>) => {
return <Socials socials={socials} />
}

View File

@@ -8,7 +8,7 @@ const ThemeToggle = () => {
localStorage.getItem('theme') ? localStorage.getItem('theme') : 'light'
)
const handleToggle = (e) => {
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) setTheme('dark')
else setTheme('light')
}
@@ -19,6 +19,10 @@ const ThemeToggle = () => {
document
.querySelector('html')
.setAttribute('data-theme', localStorage.getItem('theme'))
document
.querySelector('html')
.setAttribute('data-uppy-theme', localStorage.getItem('theme'))
}, [theme])
return (
@@ -33,10 +37,10 @@ const ThemeToggle = () => {
<Icon
path={mdiWeatherSunny}
className="swap-off h-8 w-8 text-yellow-500"
className="swap-off size-8 text-yellow-500"
/>
<Icon path={mdiWeatherNight} className="swap-on h-8 w-8 text-blue-500" />
<Icon path={mdiWeatherNight} className="swap-on size-8 text-blue-500" />
</label>
)
}

View File

@@ -45,7 +45,7 @@ const ToastNotification = ({ t, type, message }: Props) => {
<div className="flex-1">
<p className="hyphens-auto break-words font-inter">{message}</p>
</div>
{type !== 'loading' ? (
{type !== 'loading' && (
<div className="flex flex-col items-center">
<button
onClick={() => toast.dismiss(t.id)}
@@ -54,7 +54,7 @@ const ToastNotification = ({ t, type, message }: Props) => {
<Icon path={mdiClose} className="w-4" />
</button>
</div>
) : null}
)}
</div>
)
}

View File

@@ -9,9 +9,9 @@ import Tus from '@uppy/tus'
import { isProduction } from '@redwoodjs/api/dist/logger'
import '@uppy/image-editor/dist/style.min.css'
import '@uppy/core/dist/style.min.css'
import '@uppy/dashboard/dist/style.min.css'
import '@uppy/image-editor/dist/style.min.css'
interface Props {
onComplete?(result: UploadResult<Meta, Record<string, never>>): void
@@ -51,10 +51,10 @@ const Uploader = ({ onComplete }: Props) => {
withCredentials: true,
removeFingerprintOnSuccess: true,
})
.use(ImageEditor)
.use(Compressor, {
mimeType: 'image/webp',
})
.use(ImageEditor)
return instance.on('complete', onComplete)
})

View File

@@ -9,16 +9,15 @@ import App from './App'
*/
const redwoodAppElement = document.getElementById('redwood-app')
if (!redwoodAppElement) {
if (!redwoodAppElement)
throw new Error(
"Could not find an element with ID 'redwood-app'. Please ensure it " +
"exists in your 'web/src/index.html' file."
)
}
if (redwoodAppElement.children?.length > 0) {
if (redwoodAppElement.children?.length > 0)
hydrateRoot(redwoodAppElement, <App />)
} else {
else {
const root = createRoot(redwoodAppElement)
root.render(<App />)
}

View File

@@ -1,13 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react'
import AccountbarLayout from './AccountbarLayout'
const meta: Meta<typeof AccountbarLayout> = {
component: AccountbarLayout,
}
export default meta
type Story = StoryObj<typeof AccountbarLayout>
export const Primary: Story = {}

View File

@@ -1,14 +0,0 @@
import { render } from '@redwoodjs/testing/web'
import AccountbarLayout from './AccountbarLayout'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-pages-layouts
describe('AccountbarLayout', () => {
it('renders successfully', () => {
expect(() => {
render(<AccountbarLayout />)
}).not.toThrow()
})
})

View File

@@ -25,7 +25,7 @@ const AccountbarLayout = ({ title, children }: AccountbarLayoutProps) => {
</div>
</div>
</div>
<div className="font-inter">
<div className="p-2 font-inter">
<main>{children}</main>
</div>
</>

View File

@@ -1,13 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react'
import NavbarLayout from './NavbarLayout'
const meta: Meta<typeof NavbarLayout> = {
component: NavbarLayout,
}
export default meta
type Story = StoryObj<typeof NavbarLayout>
export const Primary: Story = {}

View File

@@ -1,14 +0,0 @@
import { render } from '@redwoodjs/testing/web'
import NavbarLayout from './NavbarLayout'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-pages-layouts
describe('NavbarLayout', () => {
it('renders successfully', () => {
expect(() => {
render(<NavbarLayout />)
}).not.toThrow()
})
})

View File

@@ -1,4 +1,4 @@
import { mdiMenu, mdiLogout } from '@mdi/js'
import { mdiMenu, mdiLogout, mdiCog } from '@mdi/js'
import Icon from '@mdi/react'
import { Link, routes } from '@redwoodjs/router'
@@ -27,6 +27,13 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
},
]
const navbarAdminRoutes: NavbarRoute[] = [
{
name: 'Socials',
path: routes.socials(),
},
]
const navbarButtons = () =>
navbarRoutes.map((route, i) => (
<Link key={i} to={route.path} className="btn btn-ghost btn-sm">
@@ -34,6 +41,15 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
</Link>
))
const navbarAdminButtons = () =>
navbarAdminRoutes.map((route, i) => (
<li key={i}>
<Link to={route.path} className="btn btn-ghost btn-sm">
{route.name}
</Link>
</li>
))
return (
<>
<ToasterWrapper />
@@ -44,19 +60,39 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
<div
tabIndex={0}
role="button"
className="btn btn-ghost lg:hidden"
className="btn btn-square btn-ghost lg:hidden"
>
<Icon
path={mdiMenu}
className="text-base-content-100 h-8 w-8"
/>
<Icon path={mdiMenu} className="text-base-content-100 size-8" />
</div>
<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"
className="menu dropdown-content -ml-2 mt-4 w-36 space-y-2 rounded-box bg-base-200 shadow-xl last:space-y-0"
>
{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>
)}
</div>
</div>
<Link
@@ -78,18 +114,36 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
<div className="space-x-2 font-syne">{navbarButtons()}</div>
</div>
<div className="navbar-end space-x-2">
{isAuthenticated ? (
<button className="btn btn-square btn-ghost" onClick={logOut}>
<Icon path={mdiLogout} className="h-8 w-8" />
</button>
) : (
<></>
{isAuthenticated && (
<div className="hidden space-x-2 sm:flex">
<button className="btn btn-square btn-ghost" onClick={logOut}>
<Icon path={mdiLogout} className="size-8" />
</button>
<div className="dropdown">
<div
tabIndex={0}
role="button"
className="btn btn-square btn-ghost"
>
<Icon path={mdiCog} className="size-8" />
</div>
<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"
>
{navbarAdminButtons()}
</ul>
</div>
</div>
)}
<ThemeToggle />
</div>
</div>
</div>
<div className="font-inter">{children}</div>
<div className="p-2 font-inter">
<main>{children}</main>
</div>
</>
)
}

View File

@@ -0,0 +1,65 @@
import { mdiHome, mdiPlus } from '@mdi/js'
import Icon from '@mdi/react'
import { Link, routes } from '@redwoodjs/router'
import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
import ToasterWrapper from 'src/components/ToasterWrapper/ToasterWrapper'
type ScaffoldLayoutProps = {
title: string
titleTo: string
buttonLabel: string
buttonTo: string
children: React.ReactNode
}
const ScaffoldLayout = ({
title,
titleTo,
buttonLabel,
buttonTo,
children,
}: ScaffoldLayoutProps) => {
return (
<>
<ToasterWrapper />
<div className="sticky top-0 z-50 p-2">
<div className="navbar rounded-xl bg-base-300 font-syne shadow-xl">
<div className="navbar-start space-x-2">
<Link to={routes.home()} className="btn btn-square btn-ghost">
<Icon className="size-8" path={mdiHome} />
</Link>
<Link to={routes[titleTo]()} className="btn btn-ghost text-xl">
{title}
</Link>
</div>
<div className="navbar-end space-x-2">
<ThemeToggle />
{buttonLabel && buttonTo && (
<>
<Link
to={routes[buttonTo]()}
className="btn btn-primary hidden sm:flex"
>
<Icon className="size-6" path={mdiPlus} /> {buttonLabel}
</Link>
<Link
to={routes[buttonTo]()}
className="btn btn-square btn-primary sm:hidden"
>
<Icon className="size-8" path={mdiPlus} />
</Link>
</>
)}
</div>
</div>
</div>
<div className="p-2 font-inter">
<main>{children}</main>
</div>
</>
)
}
export default ScaffoldLayout

View File

@@ -0,0 +1,58 @@
import React from 'react'
import humanize from 'humanize-string'
const MAX_STRING_LENGTH = 150
export const formatEnum = (values: string | string[] | null | undefined) => {
let output = ''
if (Array.isArray(values)) {
const humanizedValues = values.map((value) => humanize(value))
output = humanizedValues.join(', ')
} else if (typeof values === 'string') {
output = humanize(values)
}
return output
}
export const jsonDisplay = (obj: unknown) => {
return (
<pre>
<code>{JSON.stringify(obj, null, 2)}</code>
</pre>
)
}
export const truncate = (value: string | number) => {
let output = value?.toString() ?? ''
if (output.length > MAX_STRING_LENGTH) {
output = output.substring(0, MAX_STRING_LENGTH) + '...'
}
return output
}
export const jsonTruncate = (obj: unknown) => {
return truncate(JSON.stringify(obj, null, 2))
}
export const timeTag = (dateTime?: string) => {
let output: string | JSX.Element = ''
if (dateTime) {
output = (
<time dateTime={dateTime} title={dateTime}>
{new Date(dateTime).toUTCString()}
</time>
)
}
return output
}
export const checkboxInputTag = (checked: boolean) => {
return <input type="checkbox" checked={checked} disabled />
}

49
web/src/lib/handle.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { ReactElement } from 'react'
import {
SiX,
SiThreads,
SiInstagram,
SiFacebook,
SiTiktok,
SiYoutube,
SiLinkedin,
SiGithub,
} from '@icons-pack/react-simple-icons'
import { mdiEmail, mdiLink } from '@mdi/js'
import Icon from '@mdi/react'
import type { Handle } from 'types/graphql'
export const baseUrls: Record<Handle, string> = {
x: 'https://x.com/',
facebook: 'https://www.facebook.com/',
github: 'https://github.com/',
instagram: 'https://www.instagram.com/',
linkedin: 'https://www.linkedin.com/in/',
threads: 'https://www.threads.net/@',
tiktok: 'https://www.tiktok.com/@',
youtube: 'https://www.youtube.com/@',
email: 'mailto:',
custom: '',
}
const logoComponents: Record<Handle, ReactElement> = {
x: <SiX className="text-x-light dark:text-x-dark" />,
threads: <SiThreads className="text-threads-light dark:text-threads-dark" />,
instagram: (
<SiInstagram className="text-instagram-light dark:text-instagram-dark" />
),
facebook: (
<SiFacebook className="text-facebook-light dark:text-facebook-dark" />
),
tiktok: <SiTiktok className="text-tiktok-light dark:text-tiktok-dark" />,
youtube: <SiYoutube className="text-youtube-light dark:text-youtube-dark" />,
linkedin: (
<SiLinkedin className="text-linkedin-light dark:text-linkedin-dark" />
),
github: <SiGithub className="text-github-light dark:text-github-dark" />,
email: <Icon path={mdiEmail} className="size-7" />,
custom: <Icon path={mdiLink} className="size-7" />,
}
export const getLogoComponent = (type: Handle) => logoComponents[type]

View File

@@ -10,7 +10,7 @@ const HomePage = () => {
<>
<Metadata title="Home" description="Home page" />
{isAuthenticated ? <Uploader /> : <></>}
{isAuthenticated && <Uploader />}
</>
)
}

View File

@@ -0,0 +1,11 @@
import EditSocialCell from 'src/components/Social/EditSocialCell'
type SocialPageProps = {
id: number
}
const EditSocialPage = ({ id }: SocialPageProps) => {
return <EditSocialCell id={id} />
}
export default EditSocialPage

View File

@@ -0,0 +1,7 @@
import NewSocial from 'src/components/Social/NewSocial'
const NewSocialPage = () => {
return <NewSocial />
}
export default NewSocialPage

View File

@@ -0,0 +1,11 @@
import SocialCell from 'src/components/Social/SocialCell'
type SocialPageProps = {
id: number
}
const SocialPage = ({ id }: SocialPageProps) => {
return <SocialCell id={id} />
}
export default SocialPage

View File

@@ -0,0 +1,7 @@
import SocialsCell from 'src/components/Social/SocialsCell'
const SocialsPage = () => {
return <SocialsCell />
}
export default SocialsPage

View File

@@ -1,397 +0,0 @@
/*
normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css
*/
.rw-scaffold *,
.rw-scaffold ::after,
.rw-scaffold ::before {
box-sizing: inherit;
}
.rw-scaffold main {
color: #4a5568;
display: block;
}
.rw-scaffold h1,
.rw-scaffold h2 {
margin: 0;
}
.rw-scaffold a {
background-color: transparent;
}
.rw-scaffold ul {
margin: 0;
padding: 0;
}
.rw-scaffold input {
font-family: inherit;
font-size: 100%;
overflow: visible;
}
.rw-scaffold input:-ms-input-placeholder {
color: #a0aec0;
}
.rw-scaffold input::-ms-input-placeholder {
color: #a0aec0;
}
.rw-scaffold input::placeholder {
color: #a0aec0;
}
.rw-scaffold table {
border-collapse: collapse;
}
/*
Style
*/
.rw-scaffold,
.rw-toast {
background-color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
.rw-header {
display: flex;
justify-content: space-between;
padding: 1rem 2rem 1rem 2rem;
}
.rw-main {
margin-left: 1rem;
margin-right: 1rem;
padding-bottom: 1rem;
}
.rw-segment {
border-radius: 0.5rem;
border-width: 1px;
border-color: #e5e7eb;
overflow: hidden;
width: 100%;
scrollbar-color: #a1a1aa transparent;
}
.rw-segment::-webkit-scrollbar {
height: initial;
}
.rw-segment::-webkit-scrollbar-track {
background-color: transparent;
border-color: #e2e8f0;
border-style: solid;
border-radius: 0 0 10px 10px;
border-width: 1px 0 0 0;
padding: 2px;
}
.rw-segment::-webkit-scrollbar-thumb {
background-color: #a1a1aa;
background-clip: content-box;
border: 3px solid transparent;
border-radius: 10px;
}
.rw-segment-header {
background-color: #e2e8f0;
color: #4a5568;
padding: 0.75rem 1rem;
}
.rw-segment-main {
background-color: #f7fafc;
padding: 1rem;
}
.rw-link {
color: #4299e1;
text-decoration: underline;
}
.rw-link:hover {
color: #2b6cb0;
}
.rw-forgot-link {
font-size: 0.75rem;
color: #a0aec0;
text-align: right;
margin-top: 0.1rem;
}
.rw-forgot-link:hover {
font-size: 0.75rem;
color: #4299e1;
}
.rw-heading {
font-weight: 600;
}
.rw-heading.rw-heading-primary {
font-size: 1.25rem;
}
.rw-heading.rw-heading-secondary {
font-size: 0.875rem;
}
.rw-heading .rw-link {
color: #4a5568;
text-decoration: none;
}
.rw-heading .rw-link:hover {
color: #1a202c;
text-decoration: underline;
}
.rw-cell-error {
font-size: 90%;
font-weight: 600;
}
.rw-form-wrapper {
box-sizing: border-box;
font-size: 0.875rem;
margin-top: -1rem;
}
.rw-cell-error,
.rw-form-error-wrapper {
padding: 1rem;
background-color: #fff5f5;
color: #c53030;
border-width: 1px;
border-color: #feb2b2;
border-radius: 0.25rem;
margin: 1rem 0;
}
.rw-form-error-title {
margin-top: 0;
margin-bottom: 0;
font-weight: 600;
}
.rw-form-error-list {
margin-top: 0.5rem;
list-style-type: disc;
list-style-position: inside;
}
.rw-button {
border: none;
color: #718096;
cursor: pointer;
display: flex;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 1rem;
text-transform: uppercase;
text-decoration: none;
letter-spacing: 0.025em;
border-radius: 0.25rem;
line-height: 2;
border: 0;
}
.rw-button:hover {
background-color: #718096;
color: #fff;
}
.rw-button.rw-button-small {
font-size: 0.75rem;
border-radius: 0.125rem;
padding: 0.25rem 0.5rem;
line-height: inherit;
}
.rw-button.rw-button-green {
background-color: #48bb78;
color: #fff;
}
.rw-button.rw-button-green:hover {
background-color: #38a169;
color: #fff;
}
.rw-button.rw-button-blue {
background-color: #3182ce;
color: #fff;
}
.rw-button.rw-button-blue:hover {
background-color: #2b6cb0;
}
.rw-button.rw-button-red {
background-color: #e53e3e;
color: #fff;
}
.rw-button.rw-button-red:hover {
background-color: #c53030;
}
.rw-button-icon {
font-size: 1.25rem;
line-height: 1;
margin-right: 0.25rem;
}
.rw-button-group {
display: flex;
justify-content: center;
margin: 0.75rem 0.5rem;
}
.rw-button-group .rw-button {
margin: 0 0.25rem;
}
.rw-form-wrapper .rw-button-group {
margin-top: 2rem;
margin-bottom: 0;
}
.rw-label {
display: block;
margin-top: 1.5rem;
color: #4a5568;
font-weight: 600;
text-align: left;
}
.rw-label.rw-label-error {
color: #c53030;
}
.rw-input {
display: block;
margin-top: 0.5rem;
width: 100%;
padding: 0.5rem;
border-width: 1px;
border-style: solid;
border-color: #e2e8f0;
color: #4a5568;
border-radius: 0.25rem;
outline: none;
}
.rw-check-radio-item-none {
color: #4a5568;
}
.rw-check-radio-items {
display: flex;
justify-items: center;
}
.rw-input[type='checkbox'] {
display: inline;
width: 1rem;
margin-left: 0;
margin-right: 0.5rem;
margin-top: 0.25rem;
}
.rw-input[type='radio'] {
display: inline;
width: 1rem;
margin-left: 0;
margin-right: 0.5rem;
margin-top: 0.25rem;
}
.rw-input:focus {
border-color: #a0aec0;
}
.rw-input-error {
border-color: #c53030;
color: #c53030;
}
.rw-input-error:focus {
outline: none;
border-color: #c53030;
box-shadow: 0 0 5px #c53030;
}
.rw-field-error {
display: block;
margin-top: 0.25rem;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
color: #c53030;
}
.rw-table-wrapper-responsive {
overflow-x: auto;
}
.rw-table-wrapper-responsive .rw-table {
min-width: 48rem;
}
.rw-table {
table-layout: auto;
width: 100%;
font-size: 0.875rem;
}
.rw-table th,
.rw-table td {
padding: 0.75rem;
}
.rw-table td {
background-color: #ffffff;
color: #1a202c;
}
.rw-table tr:nth-child(odd) td,
.rw-table tr:nth-child(odd) th {
background-color: #f7fafc;
}
.rw-table thead tr {
color: #4a5568;
}
.rw-table th {
font-weight: 600;
text-align: left;
}
.rw-table thead th {
background-color: #e2e8f0;
text-align: left;
}
.rw-table tbody th {
text-align: right;
}
@media (min-width: 768px) {
.rw-table tbody th {
width: 20%;
}
}
.rw-table tbody tr {
border-top-width: 1px;
}
.rw-table input {
margin-left: 0;
}
.rw-table-actions {
display: flex;
justify-content: flex-end;
align-items: center;
height: 17px;
padding-right: 0.25rem;
}
.rw-table-actions .rw-button {
background-color: transparent;
}
.rw-table-actions .rw-button:hover {
background-color: #718096;
color: #fff;
}
.rw-table-actions .rw-button-blue {
color: #3182ce;
}
.rw-table-actions .rw-button-blue:hover {
background-color: #3182ce;
color: #fff;
}
.rw-table-actions .rw-button-red {
color: #e53e3e;
}
.rw-table-actions .rw-button-red:hover {
background-color: #e53e3e;
color: #fff;
}
.rw-text-center {
text-align: center;
}
.rw-login-container {
display: flex;
align-items: center;
justify-content: center;
width: 24rem;
margin: 4rem auto;
flex-wrap: wrap;
}
.rw-login-container .rw-form-wrapper {
width: 100%;
text-align: center;
}
.rw-login-link {
margin-top: 1rem;
color: #4a5568;
font-size: 90%;
text-align: center;
flex-basis: 100%;
}
.rw-webauthn-wrapper {
margin: 1.5rem 1rem 1rem;
line-height: 1.4;
}
.rw-webauthn-wrapper h2 {
font-size: 150%;
font-weight: bold;
margin-bottom: 1rem;
}

View File

@@ -3519,6 +3519,15 @@ __metadata:
languageName: node
linkType: hard
"@icons-pack/react-simple-icons@npm:^10.0.0":
version: 10.0.0
resolution: "@icons-pack/react-simple-icons@npm:10.0.0"
peerDependencies:
react: ^16.13 || ^17 || ^18
checksum: 10c0/dd5ecbcbf8cbbc58537645223854c5e8fbb89fa5fdf4edbf18fc11a02af92c12056c091d5c0db0da5f79379cae2a9381f9d2b3e16d638de617c1e2152dc3f2d7
languageName: node
linkType: hard
"@isaacs/cliui@npm:^8.0.2":
version: 8.0.2
resolution: "@isaacs/cliui@npm:8.0.2"
@@ -21627,6 +21636,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "web@workspace:web"
dependencies:
"@icons-pack/react-simple-icons": "npm:^10.0.0"
"@mdi/js": "npm:^7.4.47"
"@mdi/react": "npm:^1.6.1"
"@redwoodjs/auth-dbauth-web": "npm:7.7.4"
@@ -21647,6 +21657,7 @@ __metadata:
"@uppy/tus": "npm:^4.0.0"
autoprefixer: "npm:^10.4.20"
daisyui: "npm:^4.12.10"
humanize-string: "npm:2.1.0"
postcss: "npm:^8.4.41"
postcss-loader: "npm:^8.1.1"
react: "npm:18.2.0"