Social handles CRUD (admin side, nothing user-facing)
This commit is contained in:
12
api/db/migrations/20240819213158_social/migration.sql
Normal file
12
api/db/migrations/20240819213158_social/migration.sql
Normal 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")
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
44
api/src/graphql/socials.sdl.ts
Normal file
44
api/src/graphql/socials.sdl.ts
Normal 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
|
||||
}
|
||||
`
|
||||
59
api/src/services/socials/socials.ts
Normal file
59
api/src/services/socials/socials.ts
Normal 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')
|
||||
}
|
||||
@@ -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'] }
|
||||
|
||||
@@ -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
7
web/quick-lint-js.config
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"globals": {
|
||||
"gql": {
|
||||
"writable": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
9
web/src/components/Cell/CellEmpty/CellEmpty.tsx
Normal file
9
web/src/components/Cell/CellEmpty/CellEmpty.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
const CellEmpty = () => (
|
||||
<div className="flex justify-center">
|
||||
<div className="alert w-auto">
|
||||
<p className="text-center font-inter">It's empty in here...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default CellEmpty
|
||||
17
web/src/components/Cell/CellFailure/CellFailure.tsx
Normal file
17
web/src/components/Cell/CellFailure/CellFailure.tsx
Normal 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
|
||||
7
web/src/components/Cell/CellLoading/CellLoading.tsx
Normal file
7
web/src/components/Cell/CellLoading/CellLoading.tsx
Normal 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
|
||||
96
web/src/components/Social/EditSocialCell/EditSocialCell.tsx
Normal file
96
web/src/components/Social/EditSocialCell/EditSocialCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
web/src/components/Social/NewSocial/NewSocial.tsx
Normal file
61
web/src/components/Social/NewSocial/NewSocial.tsx
Normal 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
|
||||
101
web/src/components/Social/Social/Social.tsx
Normal file
101
web/src/components/Social/Social/Social.tsx
Normal 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> </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
|
||||
40
web/src/components/Social/SocialCell/SocialCell.tsx
Normal file
40
web/src/components/Social/SocialCell/SocialCell.tsx
Normal 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} />
|
||||
}
|
||||
210
web/src/components/Social/SocialForm/SocialForm.tsx
Normal file
210
web/src/components/Social/SocialForm/SocialForm.tsx
Normal 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
|
||||
127
web/src/components/Social/Socials/Socials.tsx
Normal file
127
web/src/components/Social/Socials/Socials.tsx
Normal 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"> </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
|
||||
37
web/src/components/Social/SocialsCell/SocialsCell.tsx
Normal file
37
web/src/components/Social/SocialsCell/SocialsCell.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 />)
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 = {}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
65
web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx
Normal file
65
web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx
Normal 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
|
||||
58
web/src/lib/formatters.tsx
Normal file
58
web/src/lib/formatters.tsx
Normal 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
49
web/src/lib/handle.tsx
Normal 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]
|
||||
@@ -10,7 +10,7 @@ const HomePage = () => {
|
||||
<>
|
||||
<Metadata title="Home" description="Home page" />
|
||||
|
||||
{isAuthenticated ? <Uploader /> : <></>}
|
||||
{isAuthenticated && <Uploader />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
11
web/src/pages/Social/EditSocialPage/EditSocialPage.tsx
Normal file
11
web/src/pages/Social/EditSocialPage/EditSocialPage.tsx
Normal 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
|
||||
7
web/src/pages/Social/NewSocialPage/NewSocialPage.tsx
Normal file
7
web/src/pages/Social/NewSocialPage/NewSocialPage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import NewSocial from 'src/components/Social/NewSocial'
|
||||
|
||||
const NewSocialPage = () => {
|
||||
return <NewSocial />
|
||||
}
|
||||
|
||||
export default NewSocialPage
|
||||
11
web/src/pages/Social/SocialPage/SocialPage.tsx
Normal file
11
web/src/pages/Social/SocialPage/SocialPage.tsx
Normal 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
|
||||
7
web/src/pages/Social/SocialsPage/SocialsPage.tsx
Normal file
7
web/src/pages/Social/SocialsPage/SocialsPage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import SocialsCell from 'src/components/Social/SocialsCell'
|
||||
|
||||
const SocialsPage = () => {
|
||||
return <SocialsCell />
|
||||
}
|
||||
|
||||
export default SocialsPage
|
||||
@@ -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;
|
||||
}
|
||||
11
yarn.lock
11
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user