Skip to content

Commit

Permalink
✨ added simple selection system
Browse files Browse the repository at this point in the history
  • Loading branch information
loloToster committed Dec 10, 2023
1 parent 46d8ddf commit 75bd89b
Show file tree
Hide file tree
Showing 12 changed files with 284 additions and 55 deletions.
59 changes: 56 additions & 3 deletions app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const db = new DataBase(dbPath, err => {
const apiRouter = Router()

const upload = multer({ dest: tmpFileDir })
const jsonParser = express.json()

function addFiles(files: Express.Multer.File[], ip: string) {
return new Promise<FileJson[]>(async (res, rej) => {
Expand Down Expand Up @@ -118,7 +119,7 @@ apiRouter.post("/file", upload.array("files"), async (req, res) => {
res.send(newItems)
})

apiRouter.post("/text", express.json(), async (req, res) => {
apiRouter.post("/text", jsonParser, async (req, res) => {
if (typeof req.body.text !== "string")
return res.status(400).send()

Expand Down Expand Up @@ -158,11 +159,11 @@ apiRouter.get("/item/:id", async (req, res) => {
})

const supportedPatchFields: Record<string, string | undefined> = {
title: "string",
title: "string",
text: "string"
}

apiRouter.patch("/item/:id", express.json(), async (req, res) => {
apiRouter.patch("/item/:id", jsonParser, async (req, res) => {
const { id } = req.params

const { field, value } = req.body
Expand Down Expand Up @@ -231,6 +232,58 @@ apiRouter.get("/items", async (req, res) => {
)
})

function validateIdsBody(body: Record<string, unknown>) {
return Array.isArray(body?.ids) &&
body.ids.every(id => typeof id === "string") &&
body.ids.length
}

apiRouter.patch(["/items/trash", "/items/restore"], jsonParser, (req, res) => {
if (!validateIdsBody(req.body)) {
return res.status(400).send()
}

const trashed = req.path.includes("trash") ? 1 : 0
const { ids } = req.body
const idsPlaceholders = "?, ".repeat(ids.length).slice(0, -2)

db.run(
`UPDATE items SET trashed=${trashed} WHERE id IN (${idsPlaceholders})`,
ids,
err => {
if (err) return res.status(500).send()
res.send()
}
)
})

apiRouter.delete("/items", jsonParser, (req, res) => {
if (!validateIdsBody(req.body)) {
return res.status(400).send()
}

const { ids } = req.body
const idsPlaceholders = "?, ".repeat(ids.length).slice(0, -2)

db.run(
`DELETE FROM items WHERE id IN (${idsPlaceholders})`,
ids,
async err => {
if (err) return res.status(500).send()

try {
for (const id of ids) {
await removeAsync(`${cdnDir}/${id}`, { force: true })
}

res.send()
} catch {
res.status(500).send()
}
}
)
})

app.use("/api", apiRouter)

// cdn
Expand Down
11 changes: 9 additions & 2 deletions client/src/components/FileItem/FileItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
padding: 25%;
}

&--selected {
$outline-width: 4px;
outline: $outline-width c.$download-blue solid;
outline-offset: -$outline-width;
}

$options-size: 50px;
$options-gap: 5px;

Expand All @@ -30,7 +36,7 @@
padding: $options-gap;
padding-bottom: 0;

&>* {
& > * {
height: $options-size;
background-color: #212121dd;
border-radius: 100vh;
Expand All @@ -53,12 +59,13 @@

&__title {
line-height: 110%;
color: white;
overflow: hidden;
text-overflow: ellipsis;
}

&__user {
font-size: .8rem;
font-size: 0.8rem;
line-height: 110%;
color: #909090;
overflow: hidden;
Expand Down
22 changes: 15 additions & 7 deletions client/src/components/FileItem/FileItem.test.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,41 @@
import { render, screen } from "@testing-library/react"
import { BrowserRouter } from "react-router-dom"
import { FileJson } from "@backend-types/types"
import { ClientFileJson } from "@backend-types/types"

import FileItem from "./FileItem"

function createDummyFileItemData(): FileJson {
function createDummyFileItemData(): ClientFileJson {
return {
type: "img",
id: "123",
ip: "0.0.0.0",
title: "dummy.png",
created_at: new Date(),
trashed: 0
trashed: 0,
selected: false
}
}

const MockFileItem = (
{ fileItem, onDelete, onRestore }: {
fileItem: FileJson,
{ fileItem, onDelete, onRestore, onClick }: {
fileItem: ClientFileJson,
onDelete?: Function,
onRestore?: Function
onRestore?: Function,
onClick?: Function
}
) => {
const handleDelete = onDelete || (() => { })
const handleRestore = onRestore || (() => { })
const handleSelect = () => { }

return (
<BrowserRouter >
<FileItem fileItem={fileItem} onDelete={handleDelete} onRestore={handleRestore} />
<FileItem
fileItem={fileItem}
onDelete={handleDelete}
onRestore={handleRestore}
onSelect={handleSelect}
onRangeSelect={handleSelect} />
</BrowserRouter>
)
}
Expand Down
38 changes: 33 additions & 5 deletions client/src/components/FileItem/FileItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,36 @@ import { Link } from "react-router-dom"

import "./FileItem.scss"

import { FileJson } from "@backend-types/types"
import { ClientFileJson } from "@backend-types/types"

function FileItem(props: { fileItem: FileJson, onDelete: Function, onRestore: Function }) {
const { fileItem, onDelete, onRestore } = props
function FileItem(props: {
fileItem: ClientFileJson,
onDelete: Function,
onRestore: Function,
onSelect: Function,
onRangeSelect: Function
}) {
const {
fileItem,
onDelete,
onRestore,
onSelect,
onRangeSelect
} = props

const handleClick = (e: React.MouseEvent) => {
if (!e.ctrlKey && !e.shiftKey) return

if (e.ctrlKey) {
onSelect(fileItem.id)
} else if (e.shiftKey) {
onRangeSelect(fileItem.id)
} else {
return
}

e.preventDefault()
}

const handleRestore = async (e: React.MouseEvent) => {
e.preventDefault()
Expand All @@ -22,8 +48,10 @@ function FileItem(props: { fileItem: FileJson, onDelete: Function, onRestore: Fu
const icon = isImg ? `/cdn/${fileItem.id}` : `/icon/${fileItem.title}`

return (
<Link to={`/file/${fileItem.id}`}
className={`file-item ${isImg ? "" : "file-item--with-icon"}`}
<Link
onClick={e => handleClick(e)}
to={`/file/${fileItem.id}`}
className={`item file-item ${isImg ? "" : "file-item--with-icon"} ${fileItem.selected ? "file-item--selected" : ""}`}
key={fileItem.id}>
<img alt="icon" src={icon} className="file-item__icon" />
<div className="file-item__options">
Expand Down
101 changes: 89 additions & 12 deletions client/src/components/ItemList/ItemList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react"
import { Item } from "@backend-types/types"
import { useEffect, useRef, useState } from "react"
import { ClientItem } from "@backend-types/types"

import "./ItemList.scss"

Expand All @@ -18,8 +18,8 @@ export type UploadContent = {
}

interface Props {
items: Item[],
setItems: React.Dispatch<React.SetStateAction<Item[]>>,
items: ClientItem[],
setItems: React.Dispatch<React.SetStateAction<ClientItem[]>>,
loading?: boolean,
showQuickActions?: boolean
}
Expand All @@ -28,6 +28,21 @@ function ItemList(props: Props) {
const { items, setItems, loading, showQuickActions = true } = props

const [uploads, setUploads] = useState<Upload[]>([])
const shiftSelectBorderItem = useRef<string | null>(null)

useEffect(() => {
const onWindowClick = (e: MouseEvent) => { // todo: remove magic string
const clickOnItem = e.composedPath().some(el => (el as HTMLElement).classList?.contains("item"))

if (clickOnItem) return

setItems(prev => prev.map(i => ({ ...i, selected: false })))
shiftSelectBorderItem.current = null
}

window.addEventListener("click", onWindowClick)
return () => window.removeEventListener("click", onWindowClick)
}, [setItems])

const handleUpload = async (content: UploadContent) => {
if (content.isText) {
Expand Down Expand Up @@ -74,21 +89,81 @@ function ItemList(props: Props) {
}
}

const rmItem = (id: string) => setItems(items.filter(i => i.id !== id))
const rmItem = (id: string) => setItems(prev => prev.filter(i => i.id !== id))
const rmItems = (ids: string[]) => setItems(prev => prev.filter(i => !ids.includes(i.id)))

const selectItem = (id: string) => setItems(prev => prev.map(i => i.id === id ? ({ ...i, selected: !i.selected }) : i))

const beforeSelect = () => {
if (items.every(i => i.id !== shiftSelectBorderItem.current))
shiftSelectBorderItem.current = null
}

const handleSelect = (id: string) => {
beforeSelect()

shiftSelectBorderItem.current = id
selectItem(id)
}

const handleRangeSelect = (id: string) => {
beforeSelect()

if (!shiftSelectBorderItem.current) {
shiftSelectBorderItem.current = id
selectItem(id)
return
} else if (shiftSelectBorderItem.current === id) {
setItems(prev => prev.map(i => ({ ...i, selected: i.id === id })))
return
}

let inRange = false

setItems(prev => prev.map(i => {
let lastOrFirst = false

if (i.id === shiftSelectBorderItem.current || i.id === id) {
inRange = !inRange
lastOrFirst = true
}

return { ...i, selected: inRange || lastOrFirst }
}))
}

const handleItemRemoval = async (id: string, type: "restore" | "trash" | "delete") => {
const hardDelete = type === "delete"
const method = hardDelete ? "DELETE" : "PATCH"
const apiPath = hardDelete ? "" : type

if (items.find(i => i.id === id)?.selected) {
const selectedIds = items.filter(i => i.selected).map(i => i.id)

let res = await fetch(`/api/items/${apiPath}`, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ids: selectedIds })
})

if (res.ok) rmItems(selectedIds)
} else {
let res = await fetch(`/api/item/${id}/${apiPath}`, { method })
if (res.ok) rmItem(id)
}

}

const handleTrash = async (id: string) => {
let res = await fetch(`/api/item/${id}/trash`, { method: "PATCH" })
if (res.ok) rmItem(id)
handleItemRemoval(id, "trash")
}

const handleRestore = async (id: string) => {
let res = await fetch(`/api/item/${id}/restore`, { method: "PATCH" })
if (res.ok) rmItem(id)
handleItemRemoval(id, "restore")
}

const handleDelete = async (id: string) => {
let res = await fetch("/api/item/" + id, { method: "DELETE" })
if (res.ok) rmItem(id)
handleItemRemoval(id, "delete")
}

return (
Expand All @@ -104,7 +179,9 @@ function ItemList(props: Props) {
const itemProps = {
key: item.id,
onRestore: handleRestore,
onDelete: item.trashed ? handleDelete : handleTrash
onDelete: item.trashed ? handleDelete : handleTrash,
onSelect: handleSelect,
onRangeSelect: handleRangeSelect
}

if (item.type === "text")
Expand Down
Loading

0 comments on commit 75bd89b

Please sign in to comment.