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[]
|
||||
}
|
||||
|
||||
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())
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
description String @default("No description provided")
|
||||
images ProjectImage[]
|
||||
description String @default("No description provided")
|
||||
images String[]
|
||||
date DateTime
|
||||
links String[] @default([])
|
||||
links String[] @default([])
|
||||
tags Tag[]
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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!
|
||||
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 {
|
||||
|
@ -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 }) =>
|
||||
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(),
|
||||
}
|
||||
|
@ -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": {
|
||||
|
@ -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",
|
||||
|
@ -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, {
|
||||
once: true,
|
||||
signal: unloadAbortController.signal,
|
||||
})
|
||||
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
|
||||
|
@ -25,6 +25,12 @@ export const QUERY: TypedDocumentNode<EditProjectById> = gql`
|
||||
description
|
||||
date
|
||||
links
|
||||
images
|
||||
tags {
|
||||
id
|
||||
tag
|
||||
color
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
@ -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,8 +38,10 @@ 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 (
|
||||
@ -70,20 +74,62 @@ 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">
|
||||
{project.links.map((link, i) => (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="badge badge-ghost text-nowrap"
|
||||
key={i}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
))}
|
||||
<td>
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -22,6 +22,12 @@ export const QUERY: TypedDocumentNode<
|
||||
description
|
||||
date
|
||||
links
|
||||
images
|
||||
tags {
|
||||
id
|
||||
tag
|
||||
color
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
@ -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>
|
||||
|
@ -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,8 +36,10 @@ 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 (
|
||||
@ -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"> </th>
|
||||
</tr>
|
||||
@ -82,26 +88,48 @@ 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">
|
||||
{project.links.map((link, i) => (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="badge badge-ghost text-nowrap"
|
||||
key={i}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
))}
|
||||
<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>
|
||||
<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}
|
||||
</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"
|
||||
|
@ -18,8 +18,14 @@ export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> =
|
||||
id
|
||||
title
|
||||
description
|
||||
images
|
||||
date
|
||||
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 {
|
||||
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