Update to RW 8.3.0 + Project CRUD complete

This commit is contained in:
Ahmed Al-Taiar
2024-09-27 22:52:41 -04:00
parent 430a2da835
commit 5c41588249
21 changed files with 1395 additions and 1170 deletions

View File

@ -0,0 +1,14 @@
/*
Warnings:
- You are about to drop the `ProjectImage` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ProjectImage" DROP CONSTRAINT "ProjectImage_projectId_fkey";
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "images" TEXT[];
-- DropTable
DROP TABLE "ProjectImage";

View File

@ -65,19 +65,11 @@ model Tag {
projects Project[]
}
model ProjectImage {
id Int @id @default(autoincrement())
fileId String
Project Project? @relation(fields: [projectId], references: [id])
projectId Int?
}
model Project {
id Int @id @default(autoincrement())
title String
description String @default("No description provided")
images ProjectImage[]
images String[]
date DateTime
links String[] @default([])
tags Tag[]

View File

@ -5,10 +5,10 @@
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/rate-limit": "^9.1.0",
"@redwoodjs/api": "8.0.0",
"@redwoodjs/api-server": "8.0.0",
"@redwoodjs/auth-dbauth-api": "8.0.0",
"@redwoodjs/graphql-server": "8.0.0",
"@redwoodjs/api": "8.3.0",
"@redwoodjs/api-server": "8.3.0",
"@redwoodjs/auth-dbauth-api": "8.3.0",
"@redwoodjs/graphql-server": "8.3.0",
"@tus/file-store": "^1.4.0",
"@tus/server": "^1.7.0",
"graphql-scalars": "^1.23.0",

View File

@ -1,33 +0,0 @@
export const schema = gql`
type ProjectImage {
id: Int!
fileId: URL!
Project: Project
projectId: Int
}
type Query {
projectImages: [ProjectImage!]! @requireAuth
projectImage(id: Int!): ProjectImage @requireAuth
}
input CreateProjectImageInput {
fileId: URL!
projectId: Int
}
input UpdateProjectImageInput {
fileId: URL
projectId: Int
}
type Mutation {
createProjectImage(input: CreateProjectImageInput!): ProjectImage!
@requireAuth
updateProjectImage(
id: Int!
input: UpdateProjectImageInput!
): ProjectImage! @requireAuth
deleteProjectImage(id: Int!): ProjectImage! @requireAuth
}
`

View File

@ -3,7 +3,7 @@ export const schema = gql`
id: Int!
title: String!
description: String!
images: [ProjectImage]!
images: [String]!
date: DateTime!
links: [URL]!
tags: [Tag]!
@ -19,6 +19,8 @@ export const schema = gql`
description: String!
date: DateTime!
links: [URL]!
images: [URL]!
tags: [Int!]
}
input UpdateProjectInput {
@ -26,6 +28,9 @@ export const schema = gql`
description: String
date: DateTime
links: [URL]!
images: [URL]!
tags: [Int!]
removeTags: [Int!]
}
type Mutation {

View File

@ -1,49 +0,0 @@
import type {
QueryResolvers,
MutationResolvers,
ProjectImageRelationResolvers,
} from 'types/graphql'
import { db } from 'src/lib/db'
export const projectImages: QueryResolvers['projectImages'] = () => {
return db.projectImage.findMany()
}
export const projectImage: QueryResolvers['projectImage'] = ({ id }) => {
return db.projectImage.findUnique({
where: { id },
})
}
export const createProjectImage: MutationResolvers['createProjectImage'] = ({
input,
}) => {
return db.projectImage.create({
data: input,
})
}
export const updateProjectImage: MutationResolvers['updateProjectImage'] = ({
id,
input,
}) => {
return db.projectImage.update({
data: input,
where: { id },
})
}
export const deleteProjectImage: MutationResolvers['deleteProjectImage'] = ({
id,
}) => {
return db.projectImage.delete({
where: { id },
})
}
export const ProjectImage: ProjectImageRelationResolvers = {
Project: (_obj, { root }) => {
return db.projectImage.findUnique({ where: { id: root?.id } }).Project()
},
}

View File

@ -15,15 +15,34 @@ export const project: QueryResolvers['project'] = ({ id }) =>
export const createProject: MutationResolvers['createProject'] = ({ input }) =>
db.project.create({
data: input,
data: {
title: input.title,
description: input.description,
date: input.date,
links: input.links,
images: input.images,
tags: {
connect: input.tags.map((tagId) => ({ id: tagId })),
},
},
})
export const updateProject: MutationResolvers['updateProject'] = ({
export const updateProject: MutationResolvers['updateProject'] = async ({
id,
input,
}) =>
db.project.update({
data: input,
data: {
title: input.title,
description: input.description,
date: input.date,
links: input.links,
images: input.images,
tags: {
disconnect: input.removeTags?.map((tagId) => ({ id: tagId })),
connect: input.tags?.map((tagId) => ({ id: tagId })),
},
},
where: { id },
})
@ -33,8 +52,6 @@ export const deleteProject: MutationResolvers['deleteProject'] = ({ id }) =>
})
export const Project: ProjectRelationResolvers = {
images: (_obj, { root }) =>
db.project.findUnique({ where: { id: root?.id } }).images(),
tags: (_obj, { root }) =>
db.project.findUnique({ where: { id: root?.id } }).tags(),
}

View File

@ -7,9 +7,9 @@
]
},
"devDependencies": {
"@redwoodjs/auth-dbauth-setup": "8.0.0",
"@redwoodjs/core": "8.0.0",
"@redwoodjs/project-config": "8.0.0",
"@redwoodjs/auth-dbauth-setup": "8.3.0",
"@redwoodjs/core": "8.3.0",
"@redwoodjs/project-config": "8.3.0",
"prettier-plugin-tailwindcss": "0.4.1"
},
"eslintConfig": {

View File

@ -14,11 +14,11 @@
"@icons-pack/react-simple-icons": "^10.0.0",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@redwoodjs/auth-dbauth-web": "8.0.0",
"@redwoodjs/forms": "8.0.0",
"@redwoodjs/router": "8.0.0",
"@redwoodjs/web": "8.0.0",
"@redwoodjs/web-server": "8.0.0",
"@redwoodjs/auth-dbauth-web": "8.3.0",
"@redwoodjs/forms": "8.3.0",
"@redwoodjs/router": "8.3.0",
"@redwoodjs/web": "8.3.0",
"@redwoodjs/web-server": "8.3.0",
"@uppy/compressor": "^2.0.1",
"@uppy/core": "^4.1.0",
"@uppy/dashboard": "^4.0.2",
@ -34,7 +34,7 @@
"react-dom": "18.3.1"
},
"devDependencies": {
"@redwoodjs/vite": "8.0.0",
"@redwoodjs/vite": "8.3.0",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"autoprefixer": "^10.4.20",

View File

@ -15,6 +15,7 @@ import { TypedDocumentNode, useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import Uploader from 'src/components/Uploader/Uploader'
import { deleteFile, handleBeforeUnload } from 'src/lib/tus'
interface PortraitFormProps {
portrait?: Portrait
@ -56,7 +57,7 @@ const CREATE_PORTRAIT_MUTATION: TypedDocumentNode<
const PortraitForm = (props: PortraitFormProps) => {
const [fileId, _setFileId] = useState<string>(props.portrait?.fileId)
const fileIdRef = useRef(fileId)
const fileIdRef = useRef<string>(fileId)
const setFileId = (fileId: string) => {
_setFileId(fileId)
@ -89,31 +90,18 @@ const PortraitForm = (props: PortraitFormProps) => {
}
)
const handleBeforeUnload = (_e: BeforeUnloadEvent) => {
deleteFile(fileIdRef.current)
if (navigator.userAgent.match(/firefox|fxios/i)) {
const firefoxVer = Number(navigator.userAgent.match(/Firefox\/(\d+)/)[1])
// One day dom.fetchKeepalive.enabled becomes true by default... until then!
if (firefoxVer < 129) {
const time = Date.now()
while (Date.now() - time < 500) {
/* empty */
}
}
}
}
const onUploadComplete = (
result: UploadResult<Meta, Record<string, never>>
) => {
setFileId(result.successful[0]?.uploadURL)
window.addEventListener('beforeunload', handleBeforeUnload, {
window.addEventListener(
'beforeunload',
(e) => handleBeforeUnload(e, [fileIdRef.current]),
{
once: true,
signal: unloadAbortController.signal,
})
}
)
}
if (props.portrait?.fileId)
@ -202,14 +190,4 @@ const PortraitForm = (props: PortraitFormProps) => {
)
}
const deleteFile = async (fileId: string) => {
await fetch(fileId, {
method: 'DELETE',
headers: {
'Tus-Resumable': '1.0.0',
},
keepalive: true,
})
}
export default PortraitForm

View File

@ -25,6 +25,12 @@ export const QUERY: TypedDocumentNode<EditProjectById> = gql`
description
date
links
images
tags {
id
tag
color
}
}
}
`

View File

@ -9,7 +9,9 @@ import { useMutation } from '@redwoodjs/web'
import type { TypedDocumentNode } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { calculateLuminance } from 'src/lib/color'
import { timeTag } from 'src/lib/formatters'
import { batchDelete } from 'src/lib/tus'
const DELETE_PROJECT_MUTATION: TypedDocumentNode<
DeleteProjectMutation,
@ -36,9 +38,11 @@ const Project = ({ project }: Props) => {
})
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => {
if (confirm('Are you sure you want to delete project ' + id + '?'))
if (confirm('Are you sure you want to delete project ' + id + '?')) {
batchDelete(project.images)
deleteProject({ variables: { id } })
}
}
return (
<div className="flex w-full justify-center">
@ -70,9 +74,50 @@ const Project = ({ project }: Props) => {
<th>Date</th>
<td>{timeTag(project.date)}</td>
</tr>
<tr>
<th>Images</th>
<td>
<div className="flex flex-wrap gap-2">
{project.images.map((image, i) => (
<a
key={i}
href={image}
target="_blank"
className="btn btn-sm btn-square"
rel="noreferrer"
>
{i + 1}
</a>
))}
</div>
</td>
</tr>
<tr>
<th>Tags</th>
<td>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag, i) => (
<div
key={i}
className="badge"
style={{
backgroundColor: tag.color,
color:
calculateLuminance(tag.color) > 0.5
? 'black'
: 'white',
}}
>
{tag.tag}
</div>
))}
</div>
</td>
</tr>
<tr>
<th>Links</th>
<td className="space-x-2 space-y-2">
<td>
<div className="flex flex-wrap gap-2">
{project.links.map((link, i) => (
<a
href={link}
@ -84,6 +129,7 @@ const Project = ({ project }: Props) => {
{link}
</a>
))}
</div>
</td>
</tr>
</tbody>

View File

@ -22,6 +22,12 @@ export const QUERY: TypedDocumentNode<
description
date
links
images
tags {
id
tag
color
}
}
}
`

View File

@ -1,9 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { mdiCalendar, mdiFormatTitle, mdiLinkVariant } from '@mdi/js'
import { mdiCalendar, mdiDelete, mdiFormatTitle, mdiLinkVariant } from '@mdi/js'
import Icon from '@mdi/react'
import { Meta, UploadResult } from '@uppy/core'
import { format, isAfter, startOfToday } from 'date-fns'
import type { EditProjectById, UpdateProjectInput } from 'types/graphql'
import type {
EditProjectById,
FindTags,
UpdateProjectInput,
} from 'types/graphql'
import type { RWGqlError } from '@redwoodjs/forms'
import {
@ -16,13 +21,14 @@ import {
} from '@redwoodjs/forms'
import { toast } from '@redwoodjs/web/toast'
import DatePicker from 'src/components/DatePicker/DatePicker'
import FormTextList from 'src/components/FormTextList/FormTextList'
import DatePicker from 'src/components/DatePicker'
import FormTextList from 'src/components/FormTextList'
import TagsSelectorCell from 'src/components/Tag/TagsSelectorCell'
import Uploader from 'src/components/Uploader'
import { batchDelete } from 'src/lib/tus'
type FormProject = NonNullable<EditProjectById['project']>
// TODO: add project images
interface ProjectFormProps {
project?: EditProjectById['project']
onSave: (data: UpdateProjectInput, id?: FormProject['id']) => void
@ -40,6 +46,23 @@ const ProjectForm = (props: ProjectFormProps) => {
props.project?.date ? new Date(props.project.date) : today
)
const [month, setMonth] = useState<string>(format(today, 'MMMM yyyy'))
const [fileIds, setFileIds] = useState<string[]>(props.project?.images || [])
const [selectedTags, setSelectedTags] = useState<FindTags['tags']>(
props.project?.tags || []
)
const [appendUploader, setAppendUploader] = useState<boolean>(false)
const [toDelete, setToDelete] = useState<string[]>([])
const onUploadComplete = (
result: UploadResult<Meta, Record<string, never>>
) => {
setFileIds(
appendUploader
? [...fileIds, ...result.successful.map((file) => file.uploadURL)]
: result.successful.map((file) => file.uploadURL)
)
setAppendUploader(false)
}
const urlRegex = useMemo(
() =>
@ -59,9 +82,22 @@ const ProjectForm = (props: ProjectFormProps) => {
if (errorsExist) return toast.error(`${errorCount} links invalid`)
if (emptyCount > 0) return toast.error(`${emptyCount} links empty`)
data.links = links
data.date = date.toISOString()
props.onSave(data, props?.project?.id)
batchDelete(toDelete)
props.onSave(
{
title: data.title,
description: data.description,
date: date.toISOString(),
links: links.filter((link) => link.trim().length > 0),
images: fileIds,
tags: selectedTags.map((tag) => tag.id),
removeTags: props.project?.tags
.filter((tag) => !selectedTags.some((st) => st.id === tag.id))
.map((tag) => tag.id),
},
props?.project?.id
)
}
const titleRef = useRef<HTMLInputElement>(null)
@ -170,6 +206,82 @@ const ProjectForm = (props: ProjectFormProps) => {
/>
</div>
<TagsSelectorCell
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
<div className="py-2 space-y-2">
<div className="flex space-x-2 justify-between items-center">
<p className="font-semibold">Images</p>
{fileIds.length > 0 &&
(appendUploader ? (
<button
className="btn btn-sm"
type="button"
onClick={() => setAppendUploader(false)}
>
Nevermind
</button>
) : (
<button
className="btn btn-sm"
type="button"
onClick={() => setAppendUploader(true)}
>
Upload More
</button>
))}
</div>
{fileIds.length > 0 ? (
<>
{fileIds.map((fileId, i) => (
<div
key={i}
className="card rounded-xl image-full image-full-no-overlay"
>
<figure>
<img src={fileId} alt={i.toString()} />
</figure>
<div className="card-body p-2 rounded-xl">
<div className="card-actions rounded-md justify-end">
<button
type="button"
className="btn btn-square btn-sm shadow-xl"
onClick={() => {
setToDelete([...toDelete, fileId])
setFileIds(fileIds.filter((id) => id !== fileId))
}}
>
<Icon path={mdiDelete} className="size-4 text-error" />
</button>
</div>
</div>
</div>
))}
{appendUploader && (
<Uploader
onComplete={onUploadComplete}
width="20rem"
height="30rem"
className="flex justify-center"
maxFiles={10}
disabled={props.loading}
/>
)}
</>
) : (
<Uploader
onComplete={onUploadComplete}
width="20rem"
height="30rem"
className="flex justify-center pt-3"
maxFiles={10}
disabled={props.loading}
/>
)}
</div>
{isAfter(date, today) && (
<div className="flex justify-center py-2">
<p>Project will be marked as</p>

View File

@ -12,7 +12,9 @@ import type { TypedDocumentNode } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { QUERY } from 'src/components/Project/ProjectsCell'
import { calculateLuminance } from 'src/lib/color'
import { timeTag, truncate } from 'src/lib/formatters'
import { batchDelete } from 'src/lib/tus'
const DELETE_PROJECT_MUTATION: TypedDocumentNode<
DeleteProjectMutation,
@ -34,9 +36,11 @@ const ProjectsList = ({ projects }: FindProjects) => {
})
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => {
if (confirm('Are you sure you want to delete project ' + id + '?'))
if (confirm('Are you sure you want to delete project ' + id + '?')) {
batchDelete(projects.find((project) => project.id === id).images)
deleteProject({ variables: { id } })
}
}
return (
<div className="w-full overflow-hidden overflow-x-auto rounded-xl bg-base-100">
@ -46,6 +50,8 @@ const ProjectsList = ({ projects }: FindProjects) => {
<th>Title</th>
<th>Description</th>
<th>Date</th>
<th>Images</th>
<th>Tags</th>
<th>Links</th>
<th className="w-0">&nbsp;</th>
</tr>
@ -82,9 +88,30 @@ const ProjectsList = ({ projects }: FindProjects) => {
return (
<tr key={project.id}>
<td>{truncate(project.title)}</td>
<td>{truncate(project.description)}</td>
<td>{timeTag(project.date)}</td>
<td className="space-x-2 space-y-2">
<td className="max-w-72">{truncate(project.description)}</td>
<td className="max-w-36">{timeTag(project.date)}</td>
<td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td>
<td>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag, i) => (
<div
key={i}
className="badge"
style={{
backgroundColor: tag.color,
color:
calculateLuminance(tag.color) > 0.5
? 'black'
: 'white',
}}
>
{tag.tag}
</div>
))}
</div>
</td>
<td>
<div className="flex flex-wrap gap-2">
{project.links.map((link, i) => (
<a
href={link}
@ -96,12 +123,13 @@ const ProjectsList = ({ projects }: FindProjects) => {
{link}
</a>
))}
</div>
</td>
<td>
<nav className="hidden justify-end space-x-2 sm:flex">
<nav className="hidden justify-end space-x-2 md:flex">
{actionButtons}
</nav>
<div className="dropdown dropdown-end flex justify-end sm:hidden">
<div className="dropdown dropdown-end flex justify-end md:hidden">
<div
tabIndex={0}
role="button"

View File

@ -18,8 +18,14 @@ export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> =
id
title
description
images
date
links
tags {
id
tag
color
}
}
}
`

View File

@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import { FindTags } from 'types/graphql'
import { calculateLuminance } from 'src/lib/color'
interface TagsSelectorProps {
selectedTags: FindTags['tags']
setSelectedTags: React.Dispatch<React.SetStateAction<FindTags['tags']>>
}
const TagsSelector = ({
tags: _tags,
selectedTags,
setSelectedTags,
}: FindTags & TagsSelectorProps) => {
const [tags, setTags] = useState<FindTags['tags']>(
_tags.filter((tag) => !selectedTags.some((t) => t.id === tag.id))
)
useEffect(() => {
const newTags = _tags.filter(
(tag) => !selectedTags.some((t) => t.id === tag.id)
)
setTags(newTags)
}, [selectedTags, _tags])
return (
<div className="w-80 space-y-2">
{tags.length > 0 && (
<>
<p className="font-semibold">Tags</p>
<div className="flex flex-wrap gap-2 ">
{tags.map((tag, i) => (
<button
key={i}
type="button"
className="badge active:scale-95"
style={{
backgroundColor: tag.color,
color:
calculateLuminance(tag.color) > 0.5 ? 'black' : 'white',
}}
onClick={() => setSelectedTags([...selectedTags, tag])}
>
{tag.tag}
</button>
))}
</div>
</>
)}
{selectedTags.length > 0 && (
<>
<p className="font-semibold">Selected</p>
<div className="flex flex-wrap gap-2 ">
{selectedTags.map((tag, i) => (
<button
key={i}
type="button"
className="badge active:scale-95"
style={{
backgroundColor: tag.color,
color:
calculateLuminance(tag.color) > 0.5 ? 'black' : 'white',
}}
onClick={() =>
setSelectedTags(selectedTags.filter((t) => t.id !== tag.id))
}
>
{tag.tag}
</button>
))}
</div>
</>
)}
</div>
)
}
export default TagsSelector

View File

@ -0,0 +1,72 @@
import type { FindTags, FindTagsVariables } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import type {
CellSuccessProps,
CellFailureProps,
TypedDocumentNode,
} from '@redwoodjs/web'
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
import TagsSelector from '../TagsSelector/TagsSelector'
export const QUERY: TypedDocumentNode<FindTags, FindTagsVariables> = gql`
query FindTags {
tags {
id
tag
color
}
}
`
interface TagsSelectorCellProps {
selectedTags: FindTags['tags']
setSelectedTags: React.Dispatch<React.SetStateAction<FindTags['tags']>>
}
export const beforeQuery = (props: TagsSelectorCellProps) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { selectedTags, setSelectedTags } = props
return {
variables: {
selectedTags,
setSelectedTags,
},
fetchPolicy: 'cache-and-network',
}
}
export const Loading = () => <CellLoading />
export const Empty = () => (
<div className="w-80 space-y-2">
<p className="font-semibold">Tags</p>
<p className="font-normal opacity-60">
No tags yet,{' '}
<Link
className="link link-primary link-hover"
target="_blank"
to={routes.newTag()}
>
create one?
</Link>
</p>
</div>
)
export const Failure = ({ error }: CellFailureProps<FindTags>) => (
<CellFailure error={error} />
)
export const Success = ({
tags,
selectedTags,
setSelectedTags,
}: CellSuccessProps<FindTags, FindTagsVariables> & TagsSelectorCellProps) => (
<TagsSelector
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
tags={tags}
/>
)

View File

@ -15,3 +15,7 @@
.w-52 .react-colorful {
width: 13rem;
}
.image-full-no-overlay::before {
background: none !important;
}

30
web/src/lib/tus.ts Normal file
View File

@ -0,0 +1,30 @@
export const deleteFile = async (fileId: string) => {
await fetch(fileId, {
method: 'DELETE',
headers: {
'Tus-Resumable': '1.0.0',
},
keepalive: true,
})
}
export const handleBeforeUnload = (_e: BeforeUnloadEvent, files: string[]) => {
batchDelete(files)
}
export const batchDelete = (files: string[]) => {
for (const file of files) deleteFile(file)
if (navigator.userAgent.match(/firefox|fxios/i)) {
const firefoxVer = Number(navigator.userAgent.match(/Firefox\/(\d+)/)[1])
// One day dom.fetchKeepalive.enabled becomes true by default... until then!
if (firefoxVer < 129) {
const time = Date.now()
while (Date.now() - time < 500) {
/* empty */
}
}
}
}

1883
yarn.lock

File diff suppressed because it is too large Load Diff