Update to RW 8.3.0 + Project CRUD complete
This commit is contained in:
14
api/db/migrations/20240927031102_/migration.sql
Normal file
14
api/db/migrations/20240927031102_/migration.sql
Normal 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";
|
@ -65,20 +65,12 @@ model Tag {
|
|||||||
projects Project[]
|
projects Project[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProjectImage {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
fileId String
|
|
||||||
|
|
||||||
Project Project? @relation(fields: [projectId], references: [id])
|
|
||||||
projectId Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
model Project {
|
model Project {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
title String
|
title String
|
||||||
description String @default("No description provided")
|
description String @default("No description provided")
|
||||||
images ProjectImage[]
|
images String[]
|
||||||
date DateTime
|
date DateTime
|
||||||
links String[] @default([])
|
links String[] @default([])
|
||||||
tags Tag[]
|
tags Tag[]
|
||||||
}
|
}
|
||||||
|
@ -5,10 +5,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^9.0.1",
|
||||||
"@fastify/rate-limit": "^9.1.0",
|
"@fastify/rate-limit": "^9.1.0",
|
||||||
"@redwoodjs/api": "8.0.0",
|
"@redwoodjs/api": "8.3.0",
|
||||||
"@redwoodjs/api-server": "8.0.0",
|
"@redwoodjs/api-server": "8.3.0",
|
||||||
"@redwoodjs/auth-dbauth-api": "8.0.0",
|
"@redwoodjs/auth-dbauth-api": "8.3.0",
|
||||||
"@redwoodjs/graphql-server": "8.0.0",
|
"@redwoodjs/graphql-server": "8.3.0",
|
||||||
"@tus/file-store": "^1.4.0",
|
"@tus/file-store": "^1.4.0",
|
||||||
"@tus/server": "^1.7.0",
|
"@tus/server": "^1.7.0",
|
||||||
"graphql-scalars": "^1.23.0",
|
"graphql-scalars": "^1.23.0",
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
`
|
|
@ -3,7 +3,7 @@ export const schema = gql`
|
|||||||
id: Int!
|
id: Int!
|
||||||
title: String!
|
title: String!
|
||||||
description: String!
|
description: String!
|
||||||
images: [ProjectImage]!
|
images: [String]!
|
||||||
date: DateTime!
|
date: DateTime!
|
||||||
links: [URL]!
|
links: [URL]!
|
||||||
tags: [Tag]!
|
tags: [Tag]!
|
||||||
@ -19,6 +19,8 @@ export const schema = gql`
|
|||||||
description: String!
|
description: String!
|
||||||
date: DateTime!
|
date: DateTime!
|
||||||
links: [URL]!
|
links: [URL]!
|
||||||
|
images: [URL]!
|
||||||
|
tags: [Int!]
|
||||||
}
|
}
|
||||||
|
|
||||||
input UpdateProjectInput {
|
input UpdateProjectInput {
|
||||||
@ -26,6 +28,9 @@ export const schema = gql`
|
|||||||
description: String
|
description: String
|
||||||
date: DateTime
|
date: DateTime
|
||||||
links: [URL]!
|
links: [URL]!
|
||||||
|
images: [URL]!
|
||||||
|
tags: [Int!]
|
||||||
|
removeTags: [Int!]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
@ -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()
|
|
||||||
},
|
|
||||||
}
|
|
@ -15,15 +15,34 @@ export const project: QueryResolvers['project'] = ({ id }) =>
|
|||||||
|
|
||||||
export const createProject: MutationResolvers['createProject'] = ({ input }) =>
|
export const createProject: MutationResolvers['createProject'] = ({ input }) =>
|
||||||
db.project.create({
|
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,
|
id,
|
||||||
input,
|
input,
|
||||||
}) =>
|
}) =>
|
||||||
db.project.update({
|
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 },
|
where: { id },
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -33,8 +52,6 @@ export const deleteProject: MutationResolvers['deleteProject'] = ({ id }) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const Project: ProjectRelationResolvers = {
|
export const Project: ProjectRelationResolvers = {
|
||||||
images: (_obj, { root }) =>
|
|
||||||
db.project.findUnique({ where: { id: root?.id } }).images(),
|
|
||||||
tags: (_obj, { root }) =>
|
tags: (_obj, { root }) =>
|
||||||
db.project.findUnique({ where: { id: root?.id } }).tags(),
|
db.project.findUnique({ where: { id: root?.id } }).tags(),
|
||||||
}
|
}
|
||||||
|
@ -7,9 +7,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@redwoodjs/auth-dbauth-setup": "8.0.0",
|
"@redwoodjs/auth-dbauth-setup": "8.3.0",
|
||||||
"@redwoodjs/core": "8.0.0",
|
"@redwoodjs/core": "8.3.0",
|
||||||
"@redwoodjs/project-config": "8.0.0",
|
"@redwoodjs/project-config": "8.3.0",
|
||||||
"prettier-plugin-tailwindcss": "0.4.1"
|
"prettier-plugin-tailwindcss": "0.4.1"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
@ -14,11 +14,11 @@
|
|||||||
"@icons-pack/react-simple-icons": "^10.0.0",
|
"@icons-pack/react-simple-icons": "^10.0.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@redwoodjs/auth-dbauth-web": "8.0.0",
|
"@redwoodjs/auth-dbauth-web": "8.3.0",
|
||||||
"@redwoodjs/forms": "8.0.0",
|
"@redwoodjs/forms": "8.3.0",
|
||||||
"@redwoodjs/router": "8.0.0",
|
"@redwoodjs/router": "8.3.0",
|
||||||
"@redwoodjs/web": "8.0.0",
|
"@redwoodjs/web": "8.3.0",
|
||||||
"@redwoodjs/web-server": "8.0.0",
|
"@redwoodjs/web-server": "8.3.0",
|
||||||
"@uppy/compressor": "^2.0.1",
|
"@uppy/compressor": "^2.0.1",
|
||||||
"@uppy/core": "^4.1.0",
|
"@uppy/core": "^4.1.0",
|
||||||
"@uppy/dashboard": "^4.0.2",
|
"@uppy/dashboard": "^4.0.2",
|
||||||
@ -34,7 +34,7 @@
|
|||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@redwoodjs/vite": "8.0.0",
|
"@redwoodjs/vite": "8.3.0",
|
||||||
"@types/react": "^18.2.55",
|
"@types/react": "^18.2.55",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^18.2.19",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
@ -15,6 +15,7 @@ import { TypedDocumentNode, useMutation } from '@redwoodjs/web'
|
|||||||
import { toast } from '@redwoodjs/web/toast'
|
import { toast } from '@redwoodjs/web/toast'
|
||||||
|
|
||||||
import Uploader from 'src/components/Uploader/Uploader'
|
import Uploader from 'src/components/Uploader/Uploader'
|
||||||
|
import { deleteFile, handleBeforeUnload } from 'src/lib/tus'
|
||||||
|
|
||||||
interface PortraitFormProps {
|
interface PortraitFormProps {
|
||||||
portrait?: Portrait
|
portrait?: Portrait
|
||||||
@ -56,7 +57,7 @@ const CREATE_PORTRAIT_MUTATION: TypedDocumentNode<
|
|||||||
|
|
||||||
const PortraitForm = (props: PortraitFormProps) => {
|
const PortraitForm = (props: PortraitFormProps) => {
|
||||||
const [fileId, _setFileId] = useState<string>(props.portrait?.fileId)
|
const [fileId, _setFileId] = useState<string>(props.portrait?.fileId)
|
||||||
const fileIdRef = useRef(fileId)
|
const fileIdRef = useRef<string>(fileId)
|
||||||
|
|
||||||
const setFileId = (fileId: string) => {
|
const setFileId = (fileId: string) => {
|
||||||
_setFileId(fileId)
|
_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 = (
|
const onUploadComplete = (
|
||||||
result: UploadResult<Meta, Record<string, never>>
|
result: UploadResult<Meta, Record<string, never>>
|
||||||
) => {
|
) => {
|
||||||
setFileId(result.successful[0]?.uploadURL)
|
setFileId(result.successful[0]?.uploadURL)
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload, {
|
window.addEventListener(
|
||||||
once: true,
|
'beforeunload',
|
||||||
signal: unloadAbortController.signal,
|
(e) => handleBeforeUnload(e, [fileIdRef.current]),
|
||||||
})
|
{
|
||||||
|
once: true,
|
||||||
|
signal: unloadAbortController.signal,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.portrait?.fileId)
|
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
|
export default PortraitForm
|
||||||
|
@ -25,6 +25,12 @@ export const QUERY: TypedDocumentNode<EditProjectById> = gql`
|
|||||||
description
|
description
|
||||||
date
|
date
|
||||||
links
|
links
|
||||||
|
images
|
||||||
|
tags {
|
||||||
|
id
|
||||||
|
tag
|
||||||
|
color
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -9,7 +9,9 @@ import { useMutation } from '@redwoodjs/web'
|
|||||||
import type { TypedDocumentNode } from '@redwoodjs/web'
|
import type { TypedDocumentNode } from '@redwoodjs/web'
|
||||||
import { toast } from '@redwoodjs/web/toast'
|
import { toast } from '@redwoodjs/web/toast'
|
||||||
|
|
||||||
|
import { calculateLuminance } from 'src/lib/color'
|
||||||
import { timeTag } from 'src/lib/formatters'
|
import { timeTag } from 'src/lib/formatters'
|
||||||
|
import { batchDelete } from 'src/lib/tus'
|
||||||
|
|
||||||
const DELETE_PROJECT_MUTATION: TypedDocumentNode<
|
const DELETE_PROJECT_MUTATION: TypedDocumentNode<
|
||||||
DeleteProjectMutation,
|
DeleteProjectMutation,
|
||||||
@ -36,8 +38,10 @@ const Project = ({ project }: Props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => {
|
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 } })
|
deleteProject({ variables: { id } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -70,20 +74,62 @@ const Project = ({ project }: Props) => {
|
|||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<td>{timeTag(project.date)}</td>
|
<td>{timeTag(project.date)}</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<th>Links</th>
|
<th>Links</th>
|
||||||
<td className="space-x-2 space-y-2">
|
<td>
|
||||||
{project.links.map((link, i) => (
|
<div className="flex flex-wrap gap-2">
|
||||||
<a
|
{project.links.map((link, i) => (
|
||||||
href={link}
|
<a
|
||||||
target="_blank"
|
href={link}
|
||||||
className="badge badge-ghost text-nowrap"
|
target="_blank"
|
||||||
key={i}
|
className="badge badge-ghost text-nowrap"
|
||||||
rel="noreferrer"
|
key={i}
|
||||||
>
|
rel="noreferrer"
|
||||||
{link}
|
>
|
||||||
</a>
|
{link}
|
||||||
))}
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -22,6 +22,12 @@ export const QUERY: TypedDocumentNode<
|
|||||||
description
|
description
|
||||||
date
|
date
|
||||||
links
|
links
|
||||||
|
images
|
||||||
|
tags {
|
||||||
|
id
|
||||||
|
tag
|
||||||
|
color
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
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 Icon from '@mdi/react'
|
||||||
|
import { Meta, UploadResult } from '@uppy/core'
|
||||||
import { format, isAfter, startOfToday } from 'date-fns'
|
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 type { RWGqlError } from '@redwoodjs/forms'
|
||||||
import {
|
import {
|
||||||
@ -16,13 +21,14 @@ import {
|
|||||||
} from '@redwoodjs/forms'
|
} from '@redwoodjs/forms'
|
||||||
import { toast } from '@redwoodjs/web/toast'
|
import { toast } from '@redwoodjs/web/toast'
|
||||||
|
|
||||||
import DatePicker from 'src/components/DatePicker/DatePicker'
|
import DatePicker from 'src/components/DatePicker'
|
||||||
import FormTextList from 'src/components/FormTextList/FormTextList'
|
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']>
|
type FormProject = NonNullable<EditProjectById['project']>
|
||||||
|
|
||||||
// TODO: add project images
|
|
||||||
|
|
||||||
interface ProjectFormProps {
|
interface ProjectFormProps {
|
||||||
project?: EditProjectById['project']
|
project?: EditProjectById['project']
|
||||||
onSave: (data: UpdateProjectInput, id?: FormProject['id']) => void
|
onSave: (data: UpdateProjectInput, id?: FormProject['id']) => void
|
||||||
@ -40,6 +46,23 @@ const ProjectForm = (props: ProjectFormProps) => {
|
|||||||
props.project?.date ? new Date(props.project.date) : today
|
props.project?.date ? new Date(props.project.date) : today
|
||||||
)
|
)
|
||||||
const [month, setMonth] = useState<string>(format(today, 'MMMM yyyy'))
|
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(
|
const urlRegex = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -59,9 +82,22 @@ const ProjectForm = (props: ProjectFormProps) => {
|
|||||||
if (errorsExist) return toast.error(`${errorCount} links invalid`)
|
if (errorsExist) return toast.error(`${errorCount} links invalid`)
|
||||||
if (emptyCount > 0) return toast.error(`${emptyCount} links empty`)
|
if (emptyCount > 0) return toast.error(`${emptyCount} links empty`)
|
||||||
|
|
||||||
data.links = links
|
batchDelete(toDelete)
|
||||||
data.date = date.toISOString()
|
|
||||||
props.onSave(data, props?.project?.id)
|
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)
|
const titleRef = useRef<HTMLInputElement>(null)
|
||||||
@ -170,6 +206,82 @@ const ProjectForm = (props: ProjectFormProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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) && (
|
{isAfter(date, today) && (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
<p>Project will be marked as</p>
|
<p>Project will be marked as</p>
|
||||||
|
@ -12,7 +12,9 @@ import type { TypedDocumentNode } from '@redwoodjs/web'
|
|||||||
import { toast } from '@redwoodjs/web/toast'
|
import { toast } from '@redwoodjs/web/toast'
|
||||||
|
|
||||||
import { QUERY } from 'src/components/Project/ProjectsCell'
|
import { QUERY } from 'src/components/Project/ProjectsCell'
|
||||||
|
import { calculateLuminance } from 'src/lib/color'
|
||||||
import { timeTag, truncate } from 'src/lib/formatters'
|
import { timeTag, truncate } from 'src/lib/formatters'
|
||||||
|
import { batchDelete } from 'src/lib/tus'
|
||||||
|
|
||||||
const DELETE_PROJECT_MUTATION: TypedDocumentNode<
|
const DELETE_PROJECT_MUTATION: TypedDocumentNode<
|
||||||
DeleteProjectMutation,
|
DeleteProjectMutation,
|
||||||
@ -34,8 +36,10 @@ const ProjectsList = ({ projects }: FindProjects) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => {
|
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 } })
|
deleteProject({ variables: { id } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -46,6 +50,8 @@ const ProjectsList = ({ projects }: FindProjects) => {
|
|||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
|
<th>Images</th>
|
||||||
|
<th>Tags</th>
|
||||||
<th>Links</th>
|
<th>Links</th>
|
||||||
<th className="w-0"> </th>
|
<th className="w-0"> </th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -82,26 +88,48 @@ const ProjectsList = ({ projects }: FindProjects) => {
|
|||||||
return (
|
return (
|
||||||
<tr key={project.id}>
|
<tr key={project.id}>
|
||||||
<td>{truncate(project.title)}</td>
|
<td>{truncate(project.title)}</td>
|
||||||
<td>{truncate(project.description)}</td>
|
<td className="max-w-72">{truncate(project.description)}</td>
|
||||||
<td>{timeTag(project.date)}</td>
|
<td className="max-w-36">{timeTag(project.date)}</td>
|
||||||
<td className="space-x-2 space-y-2">
|
<td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td>
|
||||||
{project.links.map((link, i) => (
|
<td>
|
||||||
<a
|
<div className="flex flex-wrap gap-2">
|
||||||
href={link}
|
{project.tags.map((tag, i) => (
|
||||||
target="_blank"
|
<div
|
||||||
className="badge badge-ghost text-nowrap"
|
key={i}
|
||||||
key={i}
|
className="badge"
|
||||||
rel="noreferrer"
|
style={{
|
||||||
>
|
backgroundColor: tag.color,
|
||||||
{link}
|
color:
|
||||||
</a>
|
calculateLuminance(tag.color) > 0.5
|
||||||
))}
|
? 'black'
|
||||||
|
: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.tag}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<nav className="hidden justify-end space-x-2 sm:flex">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.links.map((link, i) => (
|
||||||
|
<a
|
||||||
|
href={link}
|
||||||
|
target="_blank"
|
||||||
|
className="badge badge-ghost text-nowrap"
|
||||||
|
key={i}
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{link}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<nav className="hidden justify-end space-x-2 md:flex">
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="dropdown dropdown-end flex justify-end sm:hidden">
|
<div className="dropdown dropdown-end flex justify-end md:hidden">
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
|
@ -18,8 +18,14 @@ export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> =
|
|||||||
id
|
id
|
||||||
title
|
title
|
||||||
description
|
description
|
||||||
|
images
|
||||||
date
|
date
|
||||||
links
|
links
|
||||||
|
tags {
|
||||||
|
id
|
||||||
|
tag
|
||||||
|
color
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
80
web/src/components/Tag/TagsSelector/TagsSelector.tsx
Normal file
80
web/src/components/Tag/TagsSelector/TagsSelector.tsx
Normal 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
|
72
web/src/components/Tag/TagsSelectorCell/TagsSelectorCell.tsx
Normal file
72
web/src/components/Tag/TagsSelectorCell/TagsSelectorCell.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
@ -15,3 +15,7 @@
|
|||||||
.w-52 .react-colorful {
|
.w-52 .react-colorful {
|
||||||
width: 13rem;
|
width: 13rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-full-no-overlay::before {
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
30
web/src/lib/tus.ts
Normal file
30
web/src/lib/tus.ts
Normal 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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user