init commit with existing code

This commit is contained in:
Triston Armstrong 2024-03-27 21:35:53 -05:00
parent 5072e5c34b
commit 96e8a5b449
54 changed files with 3904 additions and 72 deletions

20
src-tauri/Cargo.lock generated
View File

@ -2,6 +2,16 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 3
[[package]]
name = "KlectrTemplate"
version = "0.0.0"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-build",
]
[[package]] [[package]]
name = "addr2line" name = "addr2line"
version = "0.21.0" version = "0.21.0"
@ -119,16 +129,6 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "big-word"
version = "0.0.0"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-build",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"

View File

@ -1,21 +1,28 @@
import { useRef, useState } from "kaioken" import { Route, Router } from "kaioken"
import { GlobalProvider } from "./state/GlobalProvider"
import { GithubIcon } from "./components/icons/GithubIcon"
import { BoardPage } from "./BoardPage"
import { HomePage } from "./HomePage"
export function App() { export function App() {
const [text, setText] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
function _handleTextInputChange(e: Event) {
setText((e.target as HTMLInputElement).value)
if (inputRef.current) inputRef.current.value = ""
}
return ( return (
<div className="w-full min-h-screen flex items-center justify-center flex-col"> <GlobalProvider>
<h1 className="text-5xl text-center">{text}</h1> <Router>
<br /> <Route path="/" element={HomePage} />
<br /> <Route path="/boards/:boardId" element={BoardPage} />
<input ref={inputRef} className="text-center outline-none text-4xl bg-transparent border-b focus-visible:border-b focus-visible:border-blue-500" placeholder="Put Text Here" onchange={_handleTextInputChange}>Text here</input> </Router>
</div> <footer className="fixed bottom-0 right-0 p-3">
<div className="text-right flex">
<a
href="https://github.com/robby6strings/kaioken-kanban"
target="_blank"
style="color:crimson"
className="inline-flex gap-1"
>
<GithubIcon />
</a>
</div>
</footer>
</GlobalProvider>
) )
} }

20
src/BoardPage.tsx Normal file
View File

@ -0,0 +1,20 @@
import { useRef, useEffect } from "kaioken"
import { Board } from "./components/Board"
import { useGlobal } from "./state/global"
export function BoardPage({ params }: { params: Record<string, any> }) {
const rootElementRef = useRef<HTMLDivElement>(null)
const { setRootElement } = useGlobal()
useEffect(() => {
if (!rootElementRef.current) return
setRootElement(rootElementRef.current)
}, [rootElementRef.current])
const { boardId } = params
return (
<main ref={rootElementRef}>
<Board boardId={boardId} />
</main>
)
}

9
src/HomePage.css Normal file
View File

@ -0,0 +1,9 @@
.board-item {
background-color: rgba(255, 255, 255, 1);
transition: 0.15s;
}
.board-item:hover {
background-color: rgba(255, 255, 255, 1);
}

138
src/HomePage.tsx Normal file
View File

@ -0,0 +1,138 @@
import "./HomePage.css"
import { ActionMenu } from "./components/ActionMenu"
import { Button } from "./components/atoms/Button"
import { LogoIcon } from "./components/icons/LogoIcon"
import { MoreIcon } from "./components/icons/MoreIcon"
import { JsonUtils } from "./idb"
import { useGlobal } from "./state/global"
import { Board } from "./types"
import { Link, useState } from "kaioken"
function readFile(file: Blob): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader()
reader.addEventListener("load", () => resolve(reader.result as string))
reader.readAsText(file, "UTF-8")
})
}
export function HomePage() {
const [showArchived, setShowArchived] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const { boards, addBoard } = useGlobal()
const activeBoards = boards.filter((b) => !b.archived)
const archivedBoards = boards.filter((b) => b.archived)
return (
<main className="p-8">
<header className="flex gap-2 justify-between items-center">
<h1 className="text-4xl flex gap-2 items-end ">
<LogoIcon size={36} />
<span className="text-white">Kaioban</span>
</h1>
<div className="relative">
<button onclick={() => setMenuOpen((prev) => !prev)}>
<MoreIcon width="1.5rem" />
</button>
<ActionMenu
open={menuOpen}
close={() => setMenuOpen(false)}
items={[
{
text: `${showArchived ? "Hide" : "Show"} archived boards`,
onclick: () => {
setShowArchived((prev) => !prev)
setMenuOpen(false)
},
},
{
text: "Export data",
onclick: async () => {
const data = await JsonUtils.export()
const dateStr = new Date()
.toLocaleString()
.split(",")[0]
.replaceAll("/", "-")
const a = document.createElement("a")
const file = new Blob([data], { type: "application/json" })
a.href = URL.createObjectURL(file)
a.download = `kaioban-export-${dateStr}.json`
a.click()
setMenuOpen(false)
},
},
{
text: "Import data",
onclick: () => {
const confirmOverwrite = confirm(
"Continuing will overwrite your existing data. Are you sure you want to continue?"
)
if (!confirmOverwrite) return
const input = Object.assign(document.createElement("input"), {
type: "file",
accept: ".json",
onchange: async () => {
const file = input.files?.[0]
if (!file) return
const data = await readFile(file)
console.log("IMPORT", data)
await JsonUtils.import(data)
//@ts-ignore
window.location = "/"
},
})
input.click()
},
},
]}
/>
</div>
</header>
<hr
className="my-4 opacity-75"
style="border-color:crimson;border-width:2px"
/>
<section>
<h2 className="text-2xl mb-2 text-white">Boards</h2>
<div>
{activeBoards.length > 0 && (
<div className="p-4 mb-4 flex flex-wrap gap-4 bg-black bg-opacity-15 rounded">
{activeBoards.map((board) => (
<BoardCard board={board} />
))}
</div>
)}
<button onclick={addBoard} className="bg-white rounded-xl px-4 py-2 bg-opacity-50 text-white">
+ Add New Board
</button>
</div>
</section>
{showArchived && (
<>
<hr className="opacity-30 my-8" />
<section>
<h2 className="text-2xl mb-2">Archived Boards</h2>
<div className="p-4 mb-4 flex flex-wrap gap-4 bg-black bg-opacity-15 rounded">
{archivedBoards.length > 0 ? (
archivedBoards.map((board) => <BoardCard board={board} />)
) : (
<div>
<i className="text-muted">No archived boards</i>
</div>
)}
</div>
</section>
</>
)}
</main>
)
}
function BoardCard({ board }: { board: Board }) {
return (
<Link to={`/boards/${board.uuid}`} className="board-item px-4 py-3 rounded-xl">
<span className="font-bold">{board.title || "(Unnamed board)"}</span>
</Link>
)
}

View File

@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,25 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#2D79C7" stroke="none">
<path d="M430 5109 c-130 -19 -248 -88 -325 -191 -53 -71 -83 -147 -96 -247
-6 -49 -9 -813 -7 -2166 l3 -2090 22 -65 c54 -159 170 -273 328 -323 l70 -22
2140 0 2140 0 66 23 c160 55 272 169 322 327 l22 70 0 2135 0 2135 -22 70
c-49 157 -155 265 -319 327 l-59 23 -2115 1 c-1163 1 -2140 -2 -2170 -7z
m3931 -2383 c48 -9 120 -26 160 -39 l74 -23 3 -237 c1 -130 0 -237 -2 -237 -3
0 -26 14 -53 30 -61 38 -197 84 -310 106 -110 20 -293 15 -368 -12 -111 -39
-175 -110 -175 -193 0 -110 97 -197 335 -300 140 -61 309 -146 375 -189 30
-20 87 -68 126 -107 119 -117 164 -234 164 -426 0 -310 -145 -518 -430 -613
-131 -43 -248 -59 -445 -60 -243 -1 -405 24 -577 90 l-68 26 0 242 c0 175 -3
245 -12 254 -9 9 -9 12 0 12 7 0 12 -4 12 -9 0 -17 139 -102 223 -138 136 -57
233 -77 382 -76 145 0 224 19 295 68 75 52 100 156 59 242 -41 84 -135 148
-374 253 -367 161 -522 300 -581 520 -23 86 -23 253 -1 337 73 275 312 448
682 492 109 13 401 6 506 -13z m-1391 -241 l0 -205 -320 0 -320 0 0 -915 0
-915 -255 0 -255 0 0 915 0 915 -320 0 -320 0 0 205 0 205 895 0 895 0 0 -205z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,24 @@
.action-menu {
z-index: 99999;
background-color: var(--primary);
box-shadow: 0 0 10px 1px #0002;
top: 100%;
right: 0;
border-radius: calc(var(--radius) / 2);
overflow: hidden;
transition: 0.15s ease;
filter: grayscale(0.85);
}
.action-menu-item {
border-bottom: 2px solid #0002;
}
.action-menu-item button:hover {
background-color: #fff1;
}
.action-menu-item:last-child {
border-bottom: 0;
}

View File

@ -0,0 +1,81 @@
import { Transition, useEffect, useRef } from "kaioken"
import { Button } from "./atoms/Button"
import "./ActionMenu.css"
type ActionMenuItem = {
text: string
onclick: (e: Event) => void
}
interface ActionMenuProps {
items: ActionMenuItem[]
open: boolean
close: () => void
}
export function ActionMenu({ open, items, close }: ActionMenuProps) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
window.addEventListener("click", handleClickOutside)
window.addEventListener("keyup", handleEscapeKey)
return () => {
window.removeEventListener("click", handleClickOutside)
window.removeEventListener("keyup", handleEscapeKey)
}
}, [])
function handleClickOutside(e: MouseEvent) {
if (!ref.current || !e.target) return
const tgt = e.target as Node
if (!ref.current.contains(tgt)) {
close()
}
}
function handleEscapeKey(e: KeyboardEvent) {
if (e.key !== "Escape") return
if (!document.activeElement || !ref.current) return
if (!ref.current.contains(document.activeElement)) return
close()
}
return (
<>
<Transition
in={open}
timings={[40, 150, 150, 150]}
element={(state) => {
if (state == "exited") return null
const opacity = state === "entered" ? "1" : "0"
const scale = state === "entered" ? 1 : 0.85
const translateY = state === "entered" ? 0 : -25
const pointerEvents = state === "entered" ? "unset" : "none"
return (
<div
ref={ref}
className="action-menu absolute p-2"
style={{
opacity,
transform: `translateY(${translateY}%) scale(${scale})`,
pointerEvents,
}}
>
{items.map((item) => (
<div className="action-menu-item flex">
<Button
variant="primary"
className="text-xs font-normal text-nowrap px-5 py-2 flex-grow"
onclick={item.onclick}
>
{item.text}
</Button>
</div>
))}
</div>
)
}}
/>
</>
)
}

33
src/components/Board.css Normal file
View File

@ -0,0 +1,33 @@
#board {
width: fit-content;
min-width: calc(100vw - .5rem);
flex-grow: 1;
display: flex;
flex-direction: column;
}
#board * {
user-select: none;
}
#board .inner {
--lists-gap: 1rem;
min-width: 100%;
display: flex;
cursor: grab;
min-height: 100%;
flex-grow: 1;
gap: var(--lists-gap);
padding: 1rem;
}
#board .inner.dragging,
#board .inner.dragging * {
cursor: grabbing;
}
#board-selector {
width: 264px;
text-overflow: ellipsis;
white-space: nowrap;
}

245
src/components/Board.tsx Normal file
View File

@ -0,0 +1,245 @@
import "./Board.css"
import { Link, Portal, useEffect, useRef, useState } from "kaioken"
import { ItemList } from "./ItemList"
import type { Board as BoardType, Vector2 } from "../types"
import { useGlobal } from "../state/global"
import { Button } from "./atoms/Button"
import { ItemEditorModal } from "./ItemEditor"
import { ListEditorModal } from "./ListEditor"
import { ListItemClone } from "./ListItemClone"
import { ListClone } from "./ListClone"
import { MouseCtx } from "../state/mouse"
import { BoardEditorDrawer } from "./BoardEditor"
import { ChevronLeftIcon } from "./icons/ChevronLeftIcon"
import { MoreIcon } from "./icons/MoreIcon"
import { useListsStore } from "../state/lists"
import { useBoardStore } from "../state/board"
import { useItemsStore } from "../state/items"
import { ContextMenu } from "./ContextMenu"
const autoScrollSpeed = 10
export function Board({ boardId }: { boardId: string }) {
const {
rootElement,
clickedItem,
setClickedItem,
dragging,
setDragging,
itemDragTarget,
setItemDragTarget,
clickedList,
setClickedList,
listDragTarget,
setListDragTarget,
handleListDrag,
} = useGlobal()
const animFrameRef = useRef(-1)
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
const [autoScrollVec, setAutoScrollVec] = useState<Vector2>({ x: 0, y: 0 })
const {
value: { board },
selectBoard,
} = useBoardStore()
const { handleItemDrop } = useItemsStore()
const { handleListDrop } = useListsStore()
const boardInnerRef = useRef<HTMLDivElement>(null)
const { boards, boardsLoaded } = useGlobal()
const {
value: { lists },
} = useListsStore()
useEffect(() => {
if (!boardsLoaded) return
const board = boards.find(
(b) => String(b.id) === boardId || b.uuid === boardId
)
if (!board) {
// @ts-ignore
window.location = "/"
return
}
selectBoard(board)
}, [boardsLoaded])
useEffect(() => {
const v = getAutoScrollVec()
if (v.x !== autoScrollVec.x || v.y !== autoScrollVec.y) {
setAutoScrollVec(v)
}
}, [mousePos, clickedItem, clickedList])
useEffect(() => {
animFrameRef.current = requestAnimationFrame(applyAutoScroll)
return () => {
if (animFrameRef.current !== -1) {
cancelAnimationFrame(animFrameRef.current!)
animFrameRef.current = -1
}
}
}, [rootElement, autoScrollVec])
function applyAutoScroll() {
if (rootElement) {
if (autoScrollVec.x !== 0)
rootElement.scrollLeft += autoScrollVec.x * autoScrollSpeed
if (autoScrollVec.y !== 0)
rootElement.scrollTop += autoScrollVec.y * autoScrollSpeed
}
animFrameRef.current = requestAnimationFrame(applyAutoScroll)
}
function getAutoScrollVec() {
const scrollPadding = 100
const res: Vector2 = { x: 0, y: 0 }
if (!clickedItem?.dragging && !clickedList?.dragging) return res
if (mousePos.x + scrollPadding > window.innerWidth) {
res.x++
} else if (mousePos.x - scrollPadding < 0) {
res.x--
}
if (mousePos.y + scrollPadding > window.innerHeight) {
res.y++
} else if (mousePos.y - scrollPadding < 0) {
res.y--
}
return res
}
function handleMouseDown(e: MouseEvent) {
if (e.buttons !== 1) return
if (!boardInnerRef.current) return
if (e.target !== boardInnerRef.current) return
setDragging(true)
}
async function handleMouseUp() {
// item drag
clickedItem && itemDragTarget && handleItemDrop(clickedItem, itemDragTarget)
clickedItem && setClickedItem(null)
itemDragTarget && setItemDragTarget(null)
// list drag
clickedList && listDragTarget && handleListDrop(clickedList, listDragTarget)
clickedList && setClickedList(null)
listDragTarget && setListDragTarget(null)
// setAutoScrollVec({ x: 0, y: 0 })
// board drag
dragging && setDragging(false)
}
function handleMouseMove(e: MouseEvent) {
setMousePos({
x: e.clientX,
y: e.clientY,
})
if (clickedList && !clickedList.dragging) {
setClickedList({
...clickedList,
dragging: true,
})
} else if (clickedList && clickedList.dragging) {
handleListDrag(e, clickedList)
}
if (!dragging || !rootElement) return
rootElement.scrollLeft -= e.movementX
rootElement.scrollTop -= e.movementY
}
return (
<MouseCtx.Provider value={{ current: mousePos, setValue: setMousePos }}>
<Nav board={board} />
<div
id="board"
onpointerdown={handleMouseDown}
onpointerup={handleMouseUp}
onpointermove={handleMouseMove}
style={`${clickedItem
? "--selected-item-height:" +
(clickedItem.domRect?.height || 0) +
"px;"
: ""
}${clickedList
? "--selected-list-width:" + clickedList.domRect?.width + "px;"
: ""
}`}
>
<div
className={`inner ${dragging || clickedItem?.dragging || clickedList?.dragging
? "dragging"
: ""
}`}
ref={boardInnerRef}
>
{lists
.filter((list) => !list.archived)
.sort((a, b) => a.order - b.order)
.map((list) => (
<ItemList list={list} />
))}
<AddList />
</div>
<Portal container={document.getElementById("portal")!}>
{clickedItem?.dragging && <ListItemClone item={clickedItem} />}
{clickedList?.dragging && <ListClone list={clickedList} />}
<ItemEditorModal />
<ListEditorModal />
<BoardEditorDrawer />
<ContextMenu />
</Portal>
</div>
</MouseCtx.Provider>
)
}
function AddList() {
const {
value: { lists },
addList,
} = useListsStore()
const { clickedList, listDragTarget } = useGlobal()
return (
<div
style={
clickedList &&
!clickedList.dialogOpen &&
listDragTarget &&
listDragTarget.index === lists.length
? "margin-left: calc(var(--selected-list-width) + var(--lists-gap));"
: ""
}
className="add-list"
>
<button
className="bg-white bg-opacity-50 flex pl-2 py-2 text-white font-bold border-2 border-transparent"
onclick={() => addList()}
>
+ Add a list
</button>
</div>
)
}
function Nav({ board }: { board: BoardType | null }) {
const { setBoardEditorOpen } = useGlobal()
return (
<nav className="px-4 flex justify-between items-center">
<div className="flex items-center gap-2">
<Link to="/" className="p-2">
<ChevronLeftIcon />
</Link>
</div>
<h1 className="text-lg font-bold select-none">
{board?.title || "(Unnamed board)"}
</h1>
<button onclick={() => setBoardEditorOpen(true)} className="p-2">
<MoreIcon />
</button>
</nav>
)
}

View File

@ -0,0 +1,339 @@
import { useModel, useState, useEffect, ElementProps } from "kaioken"
import { loadItems, loadLists } from "../idb"
import { useBoardStore } from "../state/board"
import { List, ListItem, Tag, Board } from "../types"
import { Button } from "./atoms/Button"
import { Input } from "./atoms/Input"
import { Spinner } from "./atoms/Spinner"
import { DialogHeader } from "./dialog/DialogHeader"
import { useGlobal } from "../state/global"
import { ActionMenu } from "./ActionMenu"
import { MoreIcon } from "./icons/MoreIcon"
import { maxBoardNameLength, maxTagNameLength } from "../constants"
import { Transition } from "kaioken"
import { Drawer } from "./dialog/Drawer"
import { useListsStore } from "../state/lists"
import { useBoardTagsStore } from "../state/boardTags"
import { useItemsStore } from "../state/items"
export function BoardEditorDrawer() {
const { boardEditorOpen, setBoardEditorOpen } = useGlobal()
return (
<Transition
in={boardEditorOpen}
timings={[40, 150, 150, 150]}
element={(state) =>
state === "exited" ? null : (
<Drawer state={state} close={() => setBoardEditorOpen(false)}>
<BoardEditor />
</Drawer>
)
}
/>
)
}
function BoardEditor() {
const { setBoardEditorOpen } = useGlobal()
const {
value: { board },
deleteBoard,
archiveBoard,
restoreBoard,
updateSelectedBoard,
} = useBoardStore()
const [titleRef, title] = useModel<HTMLInputElement, string>(
board?.title || ""
)
const [ctxMenuOpen, setCtxMenuOpen] = useState(false)
useEffect(() => {
titleRef.current?.focus()
}, [])
function handleSubmit() {
updateSelectedBoard({ ...board, title })
}
async function handleDeleteClick() {
if (!board) return
await deleteBoard()
setBoardEditorOpen(false)
}
async function handleArchiveClick() {
await archiveBoard()
setBoardEditorOpen(false)
}
async function handleRestoreClick() {
if (!board) return
await restoreBoard()
setBoardEditorOpen(false)
}
return (
<>
<DialogHeader>
Board Details
<div className="relative">
<button
className="w-9 flex justify-center items-center h-full"
onclick={() => setCtxMenuOpen((prev) => !prev)}
>
<MoreIcon />
</button>
<ActionMenu
open={ctxMenuOpen}
close={() => setCtxMenuOpen(false)}
items={[
board?.archived
? {
text: "Restore",
onclick: handleRestoreClick,
}
: {
text: "Archive",
onclick: handleArchiveClick,
},
{
text: "Delete",
onclick: handleDeleteClick,
},
]}
/>
</div>
</DialogHeader>
<div className="flex gap-2">
<Input
className="bg-opacity-15 bg-black w-full border-0"
ref={titleRef}
maxLength={maxBoardNameLength}
placeholder="(Unnamed Board)"
/>
<Button
variant="primary"
onclick={handleSubmit}
disabled={title === board?.title}
>
Save
</Button>
</div>
<br />
<BoardTagsEditor board={board} />
<br />
<ArchivedLists board={board} />
<br />
<ArchivedItems board={board} />
</>
)
}
function BoardTagsEditor({ board }: { board: Board | null }) {
const {
addTag,
value: { tags },
} = useBoardTagsStore()
function handleAddTagClick() {
if (!board) return
addTag(board.id)
}
return (
<ListContainer>
<ListTitle>Board Tags</ListTitle>
<div className="mb-2">
{tags.map((tag) => (
<BoardTagEditor tag={tag} />
))}
</div>
<div className="flex">
<Button variant="link" className="ml-auto" onclick={handleAddTagClick}>
Add Tag
</Button>
</div>
</ListContainer>
)
}
function BoardTagEditor({ tag }: { tag: Tag }) {
const { updateTag, deleteTag } = useBoardTagsStore()
const handleTitleChange = (e: Event) => {
const title = (e.target as HTMLInputElement).value
updateTag({ ...tag, title })
}
const handleColorChange = (e: Event) => {
const color = (e.target as HTMLInputElement).value
updateTag({ ...tag, color })
}
const _handleDeleteTag = () => {
deleteTag(tag)
}
return (
<ListItemContainer className="items-center">
<button onclick={_handleDeleteTag}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="w-4 h-4 hover:text-red-500">
<path fillRule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z" clipRule="evenodd" />
</svg>
</button>
<Input
value={tag.title}
onchange={handleTitleChange}
placeholder="(Unnamed Tag)"
className="border-0 text-sm flex-grow"
maxLength={maxTagNameLength}
/>
<input
value={tag.color}
onchange={handleColorChange}
type="color"
className="cursor-pointer"
/>
</ListItemContainer>
)
}
function ArchivedItems({ board }: { board: Board | null }) {
const [loading, setLoading] = useState(false)
const [items, setItems] = useState<(ListItem & { list: string })[]>([])
const { restoreItem } = useItemsStore()
const {
value: { lists },
} = useListsStore()
useEffect(() => {
if (!board) return
setLoading(true)
; (async () => {
const res = await Promise.all(
lists.map(async (list) => {
return (await loadItems(list.id, true)).map((item) => ({
...item,
list: list.title,
}))
})
)
setLoading(false)
setItems(res.flat())
})()
}, [])
async function handleItemRestore(item: ListItem & { list: string }) {
const { list, ...rest } = item
await restoreItem(rest)
setItems((prev) => prev.filter((l) => l.id !== item.id))
}
return (
<ListContainer>
<ListTitle>Archived Items</ListTitle>
{loading ? (
<div className="flex justify-center">
<Spinner />
</div>
) : items.length === 0 ? (
<span className="text-sm text-gray-400">
<i>No archived items</i>
</span>
) : (
items.map((item) => (
<ListItemContainer>
<span className="text-sm">{item.title || "(Unnamed item)"}</span>
<div className="flex flex-col items-end">
<span className="text-xs align-super text-gray-400 text-nowrap mb-2">
{item.list || "(Unnamed list)"}
</span>
<Button
variant="link"
className="px-0 py-0"
onclick={() => handleItemRestore(item)}
>
Restore
</Button>
</div>
</ListItemContainer>
))
)}
</ListContainer>
)
}
function ArchivedLists({ board }: { board: Board | null }) {
const [loading, setLoading] = useState(false)
const [lists, setLists] = useState<List[]>([])
const { restoreList } = useListsStore()
useEffect(() => {
if (!board) return
setLoading(true)
; (async () => {
const res = await loadLists(board.id, true)
setLists(res)
setLoading(false)
})()
}, [])
async function handleSendToBoard(list: List) {
await restoreList(list)
setLists((prev) => prev.filter((l) => l.id !== list.id))
}
return (
<ListContainer>
<ListTitle>Archived Lists</ListTitle>
{loading ? (
<div className="flex justify-center">
<Spinner />
</div>
) : lists.length === 0 ? (
<span className="text-sm text-gray-400">
<i>No archived lists</i>
</span>
) : (
lists.map((list) => (
<ListItemContainer>
<span className="text-sm">{list.title || "(Unnamed List)"}</span>
<Button
variant="link"
className="text-sm py-0 px-0"
onclick={() => handleSendToBoard(list)}
>
Restore
</Button>
</ListItemContainer>
))
)}
</ListContainer>
)
}
function ListContainer({ children }: ElementProps<"div">) {
return <div className="p-3 bg-black bg-opacity-15 rounded-xl">{children}</div>
}
function ListTitle({ children }: ElementProps<"div">) {
return (
<h4 className="text-sm mb-2 pb-1 border-b border-white text-gray-400 border-opacity-10">
{children}
</h4>
)
}
function ListItemContainer({ children, className }: ElementProps<"div">) {
return (
<div
className={`flex gap-4 p-2 justify-between bg-white bg-opacity-5 border-b border-black border-opacity-30 last:border-b-0 ${className || ""
}`}
>
{children}
</div>
)
}

View File

@ -0,0 +1,38 @@
#context-menu {
position: absolute;
top: 0;
left: 0;
background-color: var(--dialog-background);
width: 200px;
box-shadow: 4px 4px 10px -1px rgba(0, 0, 0, 0.5);
@apply border rounded-xl p-2;
}
.context-menu-item {
text-align: left;
padding: 0.25rem;
font-size: small;
}
button.context-menu-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.context-menu-item:last-child {
border-bottom: 0;
}
.context-menu-item.tag-selector {
padding: 0;
display: flex;
flex-wrap: wrap;
}
.context-menu-item.tag-selector .header {
width: 100%;
text-align: center;
padding: 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.3);
background-color: rgba(255, 255, 255, 0.15);
}

View File

@ -0,0 +1,145 @@
import { Transition, useEffect, useMemo, useRef } from "kaioken"
import { useContextMenu } from "../state/contextMenu"
import "./ContextMenu.css"
import { useBoardTagsStore } from "../state/boardTags"
import { useItemsStore } from "../state/items"
import { useBoardStore } from "../state/board"
import { Tag } from "../types"
export function ContextMenu() {
const menuRef = useRef<HTMLDivElement>(null)
const {
value: { open, click },
setOpen,
} = useContextMenu()
useEffect(() => {
document.body.addEventListener("pointerdown", handleClickOutside)
document.body.addEventListener("keydown", handleKeydown)
return () => {
document.body.removeEventListener("pointerdown", handleClickOutside)
document.body.removeEventListener("keydown", handleKeydown)
}
}, [])
if (!open) return null
function handleClickOutside(e: PointerEvent) {
if (!menuRef.current || !e.target || !(e.target instanceof Element)) return
if (menuRef.current.contains(e.target)) return
if (useContextMenu.getState().rightClickHandled) return
setOpen(false)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return
setOpen(false)
}
return (
<Transition
timings={[30, 150, 150, 150]}
in={open}
element={(state) => {
if (state === "exited") return null
const opacity = String(state === "entered" ? 1 : 0)
return (
<div
ref={menuRef}
id="context-menu"
style={{
transform: `translate(${click.x}px, ${click.y}px)`,
transition: "all .15s",
opacity,
}}
>
<ContextMenuDisplay />
</div>
)
}}
/>
)
}
function ContextMenuDisplay() {
const { value: { item }, reset } = useContextMenu()
const { deleteItem, archiveItem } = useItemsStore()
const { value: { board } } = useBoardStore()
const {
value: { tags, itemTags: boardItemTags },
addItemTag,
removeItemTag,
} = useBoardTagsStore()
const itemTags = useMemo(() => {
if (!item) return []
return boardItemTags.filter((it) => it.itemId === item.id)
}, [boardItemTags, item?.id])
async function handleDelete() {
if (!item) return
await deleteItem(item)
reset()
}
async function handleArchive() {
if (!item) return
await archiveItem(item)
reset()
}
function handleTagToggle(tag: Tag, selected: boolean) {
if (!item || !board) return
if (selected) {
const itemTag = itemTags.find((it) => it.tagId === tag.id)
if (!itemTag) return console.error("itemTag not found")
removeItemTag(itemTag)
} else {
addItemTag({
itemId: item.id,
tagId: tag.id,
boardId: board.id,
})
}
}
return (
<div id="context-menu-inner" className="flex flex-col w-full">
<div className="flex m-l-auto w-full justify-between">
<button onclick={handleDelete} className="context-menu-item">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6 hover:text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
<button onclick={handleArchive} className="context-menu-item">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6 hover:text-green-500">
<path strokeLinecap="round" strokeLinejoin="round" d="m20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5m8.25 3v6.75m0 0-3-3m3 3 3-3M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z" />
</svg>
</button>
</div>
<hr className="mt-2" />
<div className="flex flex-col context-menu-item tag-selector mt-2">
<span className="mb-1 font-bold">Tags</span>
<div className="flex tag-selector gap-1 flex-wrap">
{tags.map((tag) => {
const selected = itemTags.some((it) => it.tagId === tag.id)
return (
<button
className="px-[4px] py-[1px] text-xs border border-black border-opacity-30 rounded-full min-w-10"
style={{
backgroundColor: selected ? tag.color : "#333",
opacity: selected ? "1" : ".5",
}}
onclick={() => handleTagToggle(tag, selected)}
>
{tag.title}
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,279 @@
import { Transition, useEffect, useModel, useRef, useState } from "kaioken"
import { Input } from "./atoms/Input"
import { DialogBody } from "./dialog/DialogBody"
import { DialogHeader } from "./dialog/DialogHeader"
import { useGlobal } from "../state/global"
import { Modal } from "./dialog/Modal"
import { MoreIcon } from "./icons/MoreIcon"
import { ActionMenu } from "./ActionMenu"
import { Button } from "./atoms/Button"
import { DialogFooter } from "./dialog/DialogFooter"
import { maxItemNameLength } from "../constants"
import { useBoardTagsStore } from "../state/boardTags"
import { useBoardStore } from "../state/board"
import { useItemsStore } from "../state/items"
export function ItemEditorModal() {
const { clickedItem, setClickedItem } = useGlobal()
if (!clickedItem) return null
const handleClose = () => {
const tgt = clickedItem.sender?.target
if (tgt && tgt instanceof HTMLElement) tgt.focus()
setClickedItem(null)
}
return (
<Transition
in={clickedItem?.dialogOpen || false}
timings={[40, 150, 150, 150]}
element={(state) => {
return (
<Modal state={state} close={handleClose}>
<ItemEditor />
</Modal>
)
}}
/>
)
}
function ItemEditor() {
const { setClickedItem, clickedItem } = useGlobal()
const {
value: { board },
} = useBoardStore()
const {
value: { tags, itemTags },
addItemTag,
removeItemTag,
} = useBoardTagsStore()
const { updateItem, deleteItem, archiveItem } = useItemsStore()
const [titleRef, title] = useModel<HTMLInputElement, string>(
clickedItem?.item.title || ""
)
const [contentRef, content] = useModel<HTMLTextAreaElement, string>(
clickedItem?.item.content || ""
)
const bannerRef = useRef<HTMLDivElement>(null)
const saveBtnRef = useRef<HTMLButtonElement>(null)
const savedTagIds =
itemTags.filter((t) => t.itemId === clickedItem?.id).map((i) => i.tagId) ??
[]
const [ctxOpen, setCtxOpen] = useState(false)
const [itemTagIds, setItemTagIds] = useState(savedTagIds)
const addedItemTagIds = itemTagIds.filter((id) => !savedTagIds.includes(id))
const removedItemTagIds = savedTagIds.filter((id) => !itemTagIds.includes(id))
const itemTagIdsChanged = addedItemTagIds.length || removedItemTagIds.length
useEffect(() => {
titleRef.current?.focus()
const savebtnPressEventHandler = function(event: KeyboardEvent) {
// If the user presses the "Enter" key on the keyboard
if (event.key === "Enter") {
// Cancel the default action, if needed
event.preventDefault();
// Trigger the button element with a click
saveBtnRef.current?.click()
}
}
document.addEventListener('keypress', savebtnPressEventHandler)
return () => document.removeEventListener('keypress', savebtnPressEventHandler)
}, [])
async function saveChanges() {
if (!clickedItem) return
if (addedItemTagIds.length || removedItemTagIds.length) {
await Promise.all([
...addedItemTagIds.map((it) =>
addItemTag({ boardId: board!.id, itemId: clickedItem.id, tagId: it })
),
...removedItemTagIds
.map(
(it) =>
itemTags.find(
(t) => t.tagId === it && t.itemId === clickedItem.id
)!.id
)
.map((id) => {
return removeItemTag(itemTags.find((it) => it.id === id)!)
}),
])
}
const blob = bannerRef.current?.style.backgroundImage.match(/^url\("(\S*)"\)/)?.[1]
if (
blob !== clickedItem.item.banner ||
content !== clickedItem.item.content ||
title !== clickedItem.item.title
) {
const newItem = { ...clickedItem.item, content, title }
if (blob) newItem.banner = blob
await updateItem(newItem)
}
setClickedItem(null)
}
async function handleCtxAction(action: "delete" | "archive") {
if (!clickedItem) return
await (action === "delete" ? deleteItem : archiveItem)(clickedItem.item)
setClickedItem(null)
}
async function handleItemTagChange(e: Event, id: number) {
const checked = (e.target as HTMLInputElement).checked
const newTagIds = checked
? [...itemTagIds, id]
: itemTagIds.filter((item) => item !== id)
setItemTagIds(newTagIds)
}
async function _handleDropImage(ev: DragEvent) {
const thisElement = ev.target as HTMLDivElement
ev.preventDefault()
if (!ev.dataTransfer) return
if (!ev.dataTransfer.items) return alert("Unsupported file drop")
if (ev.dataTransfer.items.length > 1) return alert("Too many files dropped")
const item = ev.dataTransfer.items[0]
if (!item.type.startsWith('image/')) return alert("Unsupported file type")
if (item.kind !== 'file') return alert("Unsupported file type")
const file = item.getAsFile();
if (!file) return alert("Not a valid file");
(thisElement.firstElementChild as HTMLSpanElement).style.display = 'none'
const base64 = await new Promise((resolve, _) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(file);
});
if (!base64) return alert("Oh no - something went wrong parsing image")
thisElement.classList.remove(..."border-dashed border-2 w-full h-16 flex justify-center items-center".split(' '))
thisElement.style.backgroundImage = `url(${base64})`
thisElement.style.height = '200px'
thisElement.style.backgroundPosition = 'center'
thisElement.style.backgroundSize = 'cover'
}
function _handleDragEnter(e: DragEvent) {
e.preventDefault()
const list = (e.target as HTMLDivElement).classList
list.add("border-red-700")
}
function _handleDragLeave(e: DragEvent) {
e.preventDefault()
const list = (e.target as HTMLDivElement).classList
list.remove("border-red-700")
}
return (
<>
<DialogHeader>
{/* Image section */}
<div className="flex flex-col w-full gap-4">
{!clickedItem?.item.banner && <div
ref={bannerRef}
className="border-dashed border-2 w-full h-16 rounded flex justify-center items-center"
ondrop={_handleDropImage}
ondragover={e => e.preventDefault()}
ondragenter={_handleDragEnter}
ondragleave={_handleDragLeave}
>
<span id="drop-instructions" className="block text-grey">Drop Image Here</span>
</div>}
{clickedItem?.item.banner && <img className="rounded h-[200px] object-cover" src={clickedItem?.item.banner} />}
<Input
ref={titleRef}
maxLength={maxItemNameLength}
placeholder="(Unnamed Item)"
className="w-full border-0"
onfocus={(e) => (e.target as HTMLInputElement)?.select()}
/>
</div>
{/* END Image section */}
</DialogHeader>
<DialogBody>
<div>
<label className="text-sm font-semibold">Description</label>
<textarea ref={contentRef} className="w-full border-0 resize-none rounded-xl" />
</div>
<div>
<label className="text-sm font-semibold">Tags</label>
<ul>
{tags.map((t) => (
<li className="flex items-center gap-2">
<input
id={`item-tag-${t.id}`}
type={"checkbox"}
checked={itemTagIds?.includes(t.id)}
onchange={(e) => handleItemTagChange(e, t.id)}
/>
<label
className="text-sm "
htmlFor={`item-tag-${t.id}`}
>
{t.title}
</label>
<div className="w-10 h-4 rounded-2xl" style={{
backgroundColor: t.color
}} />
</li>
))}
</ul>
</div>
</DialogBody>
<DialogFooter>
{/* menu section*/}
<div className="relative">
<button
onclick={() => setCtxOpen((prev) => !prev)}
className="w-9 flex justify-center items-center h-full"
>
<MoreIcon />
</button>
<ActionMenu
open={ctxOpen}
close={() => setCtxOpen(false)}
items={[
{ text: "Archive", onclick: () => handleCtxAction("archive") },
{ text: "Delete", onclick: () => handleCtxAction("delete") },
]}
/>
</div>
{/* END menu section*/}
<span></span>
<Button
ref={saveBtnRef}
variant="primary"
onclick={saveChanges}
disabled={
title === clickedItem?.item.title &&
content === clickedItem?.item.content &&
!itemTagIdsChanged
}
>
Save & close
</Button>
</DialogFooter>
</>
)
}

178
src/components/ItemList.css Normal file
View File

@ -0,0 +1,178 @@
.list,
.add-list {
display: flex;
flex-direction: column;
width: 270px;
transition: margin-left 0.15s ease;
}
.add-list {
display: flex;
flex-direction: column;
height: 100%;
}
.add-list button {
width: 100%;
border-radius: var(--radius);
}
.list {
background-color: #ebecf0;
overflow: hidden;
height: fit-content;
cursor: initial;
border-radius: var(--radius);
box-shadow:
0px 1px 1px #091e4240,
0px 0px 1px #091e424f;
}
#board .inner:not(.dragging) .list,
#board .inner:not(.dragging) .add-list {
transition: none;
}
.list.selected {
position: absolute;
}
.list-header {
display: flex;
padding-inline: var(--list-header-padding-x);
padding-block: var(--list-header-padding-y);
gap: 0.5rem;
align-items: center;
cursor: grab;
}
.list-header button {
cursor: pointer;
transition: 0.15s;
border-radius: 10px;
}
.list-header button:hover {
background-color: rgba(255, 255, 255, 1);
}
.list-title {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 100%;
flex-grow: 1;
border: 0;
width: 100%;
padding: 0.25rem 0.5rem;
margin: 0;
}
.list-title.editing {
cursor: unset;
}
.list-items-inner {
padding-inline: 0.5rem;
margin-top: 0.5rem;
--items-gap: 0.5rem;
position: relative;
gap: var(--items-gap);
display: flex;
flex-direction: column;
border-radius: var(--radius);
transition:
background-color 0.15s ease,
padding 0.15s ease;
}
.list-items.dragging .list-items-inner {
padding-block: 0.5rem;
}
.list .list-items.empty .list-items-inner {
padding-bottom: calc(var(--selected-item-height) + var(--items-gap));
}
.list-items.dragging .list-items-inner {
background-color: rgba(0, 0, 0, 0.1);
}
.list-items.dragging .list-items-inner {
transition:
background-color 0.15s ease,
padding 0.15s ease;
}
.list-items.dragging button {
border-color: transparent;
}
.list-items.last:not(.empty) .list-items-inner {
padding-bottom: calc(var(--selected-item-height) + var(--items-gap));
}
.list-items:not(.dragging) .list-item {
transition: none;
}
.list:hover .list-items:not(.dragging) .list-items-inner,
[inputMode="touch"] .list .list-items:not(.dragging) .list-items-inner {
transition: none;
}
.list-item {
padding: 0.5rem 1rem;
border-radius: var(--radius);
display: flex;
flex-direction: column;
justify-content: space-between;
margin-top: 0;
transition: margin-top 0.15s ease;
min-height: var(--item-height);
text-align: left;
background-color: white;
box-shadow:
0px 1px 1px #091e4240,
0px 0px 1px #091e424f;
}
.list-item.selected {
position: absolute;
width: calc(100% - 1rem);
background: rgba(255, 255, 255, 0.01);
}
.list-item * {
pointer-events: none;
}
#item-clone {
position: absolute;
z-index: 1000;
top: 0;
left: 0;
pointer-events: none;
backdrop-filter: blur(10px);
}
#item-clone button {
width: 100%;
box-shadow: 1px 1px 4px 1px rgba(5px, 5px, 0, 1);
background-color: rgba(255, 255, 255, 0.7);
}
#list-clone {
position: absolute;
z-index: 1000;
top: 0;
left: 0;
pointer-events: none;
backdrop-filter: blur(10px);
}
#list-clone .list {
margin: 0;
background-color: rgba(255, 255, 255, 0.8);
box-shadow: 4px 4px 10px -1px rgba(0, 0, 0, 0.15);
}

373
src/components/ItemList.tsx Normal file
View File

@ -0,0 +1,373 @@
import "./ItemList.css"
import { useRef, useEffect, useMemo } from "kaioken"
import { List, ListItem, Tag } from "../types"
import { useGlobal } from "../state/global"
import { MoreIcon } from "./icons/MoreIcon"
import { useItemsStore } from "../state/items"
import { useBoardTagsStore } from "../state/boardTags"
import { useContextMenu } from "../state/contextMenu"
type InteractionEvent = MouseEvent | TouchEvent | KeyboardEvent
function isTouchEvent(e: Event): boolean {
if (!Object.hasOwn(window, "TouchEvent")) return false
return e instanceof TouchEvent
}
export function ItemList({ list }: { list: List }) {
const headerRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const rect = useRef<DOMRect>(null)
const dropAreaRef = useRef<HTMLDivElement>(null)
const { addItem, getListItems } = useItemsStore()
const {
clickedItem,
setClickedItem,
itemDragTarget,
setItemDragTarget,
handleItemDrag,
clickedList,
setClickedList,
listDragTarget,
setListDragTarget,
rootElement,
} = useGlobal()
useEffect(() => {
if (!listRef.current) return
rect.current = listRef.current.getBoundingClientRect()
}, [listRef.current])
if (clickedList?.id === list.id && clickedList.dragging) {
return null
}
const items = getListItems(list.id)
function handleMouseMove(e: MouseEvent) {
if (e.buttons !== 1) return
if (!dropAreaRef.current) return
if (!clickedItem) return
if (!clickedItem.dragging) {
setClickedItem({
...clickedItem,
dragging: true,
})
}
handleItemDrag(e, dropAreaRef.current, clickedItem, list)
}
function handleMouseLeave() {
if (!clickedItem) return
setItemDragTarget(null)
}
function selectList(e: InteractionEvent) {
const element = listRef.current?.cloneNode(true) as HTMLDivElement
if (!element) return
const isMouse = e instanceof MouseEvent && !isTouchEvent(e)
if (isMouse && e.buttons !== 1) return
if (e instanceof KeyboardEvent) {
if (e.key !== "Enter" && e.key !== " ") return
e.preventDefault()
}
const rect = listRef.current!.getBoundingClientRect()
const mouseOffset =
e instanceof MouseEvent
? {
x: e.clientX - rect.x - 4,
y: e.clientY - rect.y - 8,
}
: {
x: 0,
y: 0,
}
setClickedList({
sender: e,
list,
id: list.id,
index: list.order,
dragging: false,
dialogOpen: !isMouse,
element,
domRect: rect,
mouseOffset,
})
if (isMouse) {
setListDragTarget({ index: list.order + 1 })
}
}
function getListItemsClassName() {
let className = `list-items`
const isOriginList = clickedItem?.listId === list.id
if (isOriginList) {
className += " origin"
}
if (!clickedItem?.dragging) {
if (
clickedItem &&
clickedItem.listId === list.id &&
clickedItem.index === items.length - 1 &&
!clickedItem.dialogOpen
) {
return `${className} last`
}
return className
}
const empty = items.length === 0 || (isOriginList && items.length === 1)
if (empty) {
className += " empty"
}
if (itemDragTarget?.listId !== list.id) return className
return `${className} ${clickedItem?.dragging ? "dragging" : ""} ${itemDragTarget.index === items.length && !clickedItem.dialogOpen
? "last"
: ""
}`.trim()
}
function getListClassName() {
let className = "list"
if (clickedList?.id === list.id && !clickedList.dialogOpen) {
className += " selected"
}
return className
}
function getListStyle() {
if (listDragTarget && listDragTarget.index === list.order) {
return "margin-left: calc(var(--selected-list-width) + var(--lists-gap));"
}
if (clickedList?.id !== list.id) return ""
if (clickedList.dialogOpen) return ""
if (!rootElement) return ""
// initial click state
const dropArea = document.querySelector("#board .inner")!
const dropAreaRect = dropArea.getBoundingClientRect()
const rect = listRef.current!.getBoundingClientRect()
const x = rect.left - dropAreaRect.x - rootElement.scrollLeft
const y = rect.y - dropAreaRect.y - rootElement.scrollTop
return `transform: translate(calc(${x}px - 1rem), calc(${y}px - 1rem))`
}
return (
<div
ref={listRef}
style={getListStyle()}
className={getListClassName()}
data-id={list.id}
>
<div className="list-header" ref={headerRef} onpointerdown={selectList}>
<h3 className="list-title text-base font-bold">
{list.title || `(Unnamed list)`}
</h3>
<button
className="p-2"
onkeydown={selectList}
onclick={() =>
setClickedList({
...(clickedList ?? {
list,
id: list.id,
index: list.order,
dragging: false,
}),
dialogOpen: true,
})
}
>
<MoreIcon />
</button>
</div>
<div
className={getListItemsClassName()}
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
>
<div ref={dropAreaRef} className="list-items-inner">
{items
.sort((a, b) => a.order - b.order)
.map((item, i) => (
<Item item={item} idx={i} listId={list.id} />
))}
</div>
</div>
<div className="flex p-2">
<button
className="flex flex-1 hover:bg-white text-gray-500 rounded-xl flex-grow py-2 px-4 text-sm font-semibold"
onclick={() => addItem(list.id)}
>
+ Add Item
</button>
</div>
</div>
)
}
interface ItemProps {
item: ListItem
idx: number
listId: number
}
function Item({ item, idx, listId }: ItemProps) {
const ref = useRef<HTMLButtonElement>(null)
const { clickedItem, setClickedItem, itemDragTarget, setItemDragTarget } =
useGlobal()
const {
value: { tags, itemTags },
removeItemTag
} = useBoardTagsStore((state) => [
...state.tags,
...state.itemTags.filter((it) => it.itemId === item.id),
])
const itemItemTags: Array<Tag | undefined> = useMemo(() => {
const tagsForThisItem = itemTags.filter((it) => it.itemId === item.id)
const mappedTags = tagsForThisItem.map((it) => {
const foundTag = tags.find((t) => t.id === it.tagId)
if (!foundTag) {
void removeItemTag(it)
return undefined
}
return foundTag
})
return mappedTags.filter(Boolean)
}, [itemTags, item.id])
if (clickedItem?.id === item.id && clickedItem.dragging) {
return null
}
function selectItem(e: InteractionEvent) {
const element = ref.current?.cloneNode(true) as HTMLButtonElement
if (!element) return console.error("selectItem fail, no element")
const isMouse = e instanceof MouseEvent && !isTouchEvent(e)
if (isMouse && e.buttons !== 1) {
if (e.buttons == 2) {
useContextMenu.setState({
rightClickHandled: true,
click: {
x: e.clientX,
y: e.clientY,
},
open: true,
item,
})
}
return
}
if (e instanceof KeyboardEvent) {
// check if either 'enter' or 'space' key
if (e.key !== "Enter" && e.key !== " ") return
e.preventDefault()
}
const mEvt = e as MouseEvent
const rect = ref.current!.getBoundingClientRect()
setClickedItem({
sender: e,
item,
id: item.id,
listId: listId,
index: idx,
dragging: false,
dialogOpen: !isMouse,
element,
domRect: rect,
mouseOffset: isMouse
? { x: mEvt.offsetX, y: mEvt.offsetY }
: { x: 0, y: 0 },
})
if (isMouse) {
setItemDragTarget({
index: idx + 1,
listId,
})
}
}
function handleClick() {
setClickedItem({
...(clickedItem ?? {
item,
id: item.id,
listId: listId,
index: idx,
dragging: false,
}),
dialogOpen: true,
})
}
function getStyle() {
if (itemDragTarget?.index === idx && itemDragTarget?.listId === listId)
return "margin-top: calc(var(--selected-item-height) + var(--items-gap));"
if (clickedItem?.id !== item.id) return ""
if (clickedItem.dialogOpen) return ""
const dropArea = document.querySelector(
`#board .inner .list[data-id="${listId}"] .list-items-inner`
)!
const dropAreaRect = dropArea.getBoundingClientRect()
if (!dropAreaRect) return ""
if (!ref.current) return ""
const rect = ref.current.getBoundingClientRect()
const x = rect.x - dropAreaRect.x
const y = rect.y - dropAreaRect.y
return `transform: translate(calc(${x}px - .5rem), ${y}px)`
}
function getClassName() {
let className = "list-item text-sm gap-2"
if (clickedItem?.id === item.id && !clickedItem.dialogOpen) {
className += " selected"
}
return className
}
return (
<button
ref={ref}
className={getClassName()}
style={getStyle()}
onpointerdown={selectItem}
onkeydown={selectItem}
onclick={handleClick}
data-id={item.id}
>
{!!item.banner && (
<img className="rounded" src={item.banner} />
)}
<span>{item.title || "(Unnamed Item)"}</span>
<div className="flex gap-2 flex-wrap">
{itemItemTags.map((tag) => {
return (
<span
className="px-4 py-1 text-xs rounded"
style={{ backgroundColor: tag?.color ?? '#000' }}
/>
)
})}
</div>
</button>
)
}

View File

@ -0,0 +1,21 @@
import { useRef, useEffect } from "kaioken"
import { ClickedList } from "../types"
import { useMouse } from "../state/mouse"
export function ListClone({ list }: { list: ClickedList }) {
const { current: mousePos } = useMouse()
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!ref.current) return
ref.current.innerHTML = list.element?.outerHTML || ""
}, [ref.current])
function getStyle() {
const x = mousePos.x - (list.mouseOffset?.x ?? 0)
const y = mousePos.y - (list.mouseOffset?.y ?? 0)
return `transform: translate(calc(${x}px - var(--list-header-padding-x)), calc(${y}px - var(--list-header-padding-y))); width: ${list.domRect?.width}px; height: ${list.domRect?.height}px;`
}
return <div ref={ref} id="list-clone" style={getStyle()}></div>
}

View File

@ -0,0 +1,113 @@
import { Transition, useEffect, useModel, useState } from "kaioken"
import { ClickedList } from "../types"
import { Input } from "./atoms/Input"
import { DialogHeader } from "./dialog/DialogHeader"
import { useGlobal } from "../state/global"
import { Modal } from "./dialog/Modal"
import { MoreIcon } from "./icons/MoreIcon"
import { ActionMenu } from "./ActionMenu"
import { DialogFooter } from "./dialog/DialogFooter"
import { Button } from "./atoms/Button"
import { maxListNameLength } from "../constants"
import { useListsStore } from "../state/lists"
export function ListEditorModal() {
const { clickedList, setClickedList } = useGlobal()
if (!clickedList) return null
return (
<Transition
in={clickedList?.dialogOpen || false}
timings={[40, 150, 150, 150]}
element={(state) => (
<Modal
state={state}
close={() => {
const tgt = clickedList.sender?.target
if (tgt && tgt instanceof HTMLElement) tgt.focus()
setClickedList(null)
}}
>
<ListEditor clickedList={clickedList} />
</Modal>
)}
/>
)
}
function ListEditor({ clickedList }: { clickedList: ClickedList | null }) {
const { setClickedList } = useGlobal()
const { updateList, getList, deleteList, archiveList } = useListsStore()
const [titleRef, title] = useModel<HTMLInputElement, string>(
clickedList?.list.title || ""
)
const [ctxOpen, setCtxOpen] = useState(false)
useEffect(() => {
titleRef.current?.focus()
}, [])
async function saveChanges() {
if (!clickedList) return
const list = getList(clickedList.id)
if (!list) throw new Error("no list, wah wah")
await updateList({ ...list, title })
setClickedList(null)
}
async function handleCtxAction(action: "delete" | "archive") {
if (!clickedList) return
switch (action) {
case "delete": {
await deleteList(clickedList.id)
setClickedList(null)
break
}
case "archive": {
await archiveList(clickedList.id)
setClickedList(null)
break
}
}
}
return (
<>
<DialogHeader className="flex pb-0 mb-0 border-b-0">
<Input
ref={titleRef}
maxLength={maxListNameLength}
className="bg-transparent w-full border-0"
placeholder="(Unnamed List)"
onfocus={(e) => (e.target as HTMLInputElement)?.select()}
/>
<div className="relative">
<button
onclick={() => setCtxOpen((prev) => !prev)}
className="w-9 flex justify-center items-center h-full"
>
<MoreIcon />
</button>
<ActionMenu
open={ctxOpen}
close={() => setCtxOpen(false)}
items={[
{ text: "Archive", onclick: () => handleCtxAction("archive") },
{ text: "Delete", onclick: () => handleCtxAction("delete") },
]}
/>
</div>
</DialogHeader>
<DialogFooter className="mt-2">
<span></span>
<Button
variant="primary"
onclick={saveChanges}
disabled={title === clickedList?.list.title}
>
Save & close
</Button>
</DialogFooter>
</>
)
}

View File

@ -0,0 +1,23 @@
import { useRef, useEffect } from "kaioken"
import { ClickedItem } from "../types"
import { useMouse } from "../state/mouse"
export function ListItemClone({ item }: { item: ClickedItem }) {
const { current: mousePos } = useMouse()
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!ref.current) return
ref.current.innerHTML = item.element?.outerHTML || ""
}, [ref.current])
function getStyle() {
const x = mousePos.x - (item.mouseOffset?.x ?? 0)
const y = mousePos.y - (item.mouseOffset?.y ?? 0)
return `transform: translate(${x}px, ${y}px); width: ${
item.domRect?.width || 0
}px; height: ${item.domRect?.height || 0}px;`
}
return <div ref={ref} id="item-clone" style={getStyle()}></div>
}

View File

@ -0,0 +1,122 @@
import { type ElementProps } from "kaioken"
type ButtonVariant =
| "primary"
| "secondary"
| "danger"
| "success"
| "link"
| "default"
export function PrimaryButton({
className,
children,
...props
}: ElementProps<"button">) {
return (
<button
{...props}
className={`bg-primary text-white font-bold text-sm py-2 px-4 rounded ${className || ""
}`}
>
{children}
</button>
)
}
export function SecondaryButton({
className,
children,
...props
}: ElementProps<"button">) {
return (
<button
{...props}
className={`bg-gray-500 hover:bg-gray-700 text-white font-bold text-sm py-2 px-4 rounded ${className || ""
}`}
>
{children}
</button>
)
}
export function DangerButton({
className,
children,
...props
}: ElementProps<"button">) {
return (
<button
{...props}
className={`bg-red-500 hover:bg-red-700 text-white font-bold text-sm py-2 px-4 rounded ${className || ""
}`}
>
{children}
</button>
)
}
export function SuccessButton({
className,
children,
...props
}: ElementProps<"button">) {
return (
<button
{...props}
className={`bg-green-500 hover:bg-green-700 text-white font-bold text-sm py-2 px-4 rounded ${className || ""
}`}
>
{children}
</button>
)
}
export function DefaultButton({
className,
children,
...props
}: ElementProps<"button">) {
return (
<button
{...props}
className={`bg-gray-200 hover:bg-gray-400 text-gray-800 font-bold text-sm py-2 px-4 rounded ${className || ""
}`}
>
{children}
</button>
)
}
function LinkButton({ className, children, ...props }: ElementProps<"button">) {
return (
<button
{...props}
className={`bg-transparent text-primary-light font-medium underline text-sm p-1 ${className || ""
}`}
>
{children}
</button>
)
}
export function Button({
variant,
children,
...props
}: ElementProps<"button"> & { variant?: ButtonVariant }) {
switch (variant) {
case "primary":
return <PrimaryButton {...props}>{children}</PrimaryButton>
case "secondary":
return <SecondaryButton {...props}>{children}</SecondaryButton>
case "danger":
return <DangerButton {...props}>{children}</DangerButton>
case "success":
return <SuccessButton {...props}>{children}</SuccessButton>
case "link":
return <LinkButton {...props}>{children}</LinkButton>
default:
return <DefaultButton {...props}>{children}</DefaultButton>
}
}

View File

@ -0,0 +1,49 @@
import { ElementProps } from "kaioken"
export function H1({ className, children, ...props }: ElementProps<"h1">) {
return (
<h1 className={"text-6xl font-bold " + (className || "")} {...props}>
{children}
</h1>
)
}
export function H2({ className, children, ...props }: ElementProps<"h2">) {
return (
<h2 className={"text-5xl font-bold " + (className || "")} {...props}>
{children}
</h2>
)
}
export function H3({ className, children, ...props }: ElementProps<"h3">) {
return (
<h3 className={"text-4xl font-bold " + (className || "")} {...props}>
{children}
</h3>
)
}
export function H4({ className, children, ...props }: ElementProps<"h4">) {
return (
<h4 className={"text-3xl font-bold " + (className || "")} {...props}>
{children}
</h4>
)
}
export function H5({ className, children, ...props }: ElementProps<"h5">) {
return (
<h5 className={"text-2xl font-bold " + (className || "")} {...props}>
{children}
</h5>
)
}
export function H6({ className, children, ...props }: ElementProps<"h6">) {
return (
<h6 className={"text-xl font-bold " + (className || "")} {...props}>
{children}
</h6>
)
}

View File

@ -0,0 +1,19 @@
import { ElementProps } from "kaioken"
export interface InputProps extends ElementProps<"input"> {}
export function Input({
className = "",
type = "text",
ref,
...props
}: InputProps) {
return (
<input
type={type}
className={"flex h-9 px-2 rounded-md border " + className}
ref={ref}
{...props}
/>
)
}

View File

@ -0,0 +1,9 @@
import { Link as L, LinkProps } from "kaioken"
export function Link(props: LinkProps) {
return (
<L className="text-blue-500" {...props}>
{props.children}
</L>
)
}

View File

@ -0,0 +1,43 @@
import { ElementProps } from "kaioken"
type Key = string | number
type SelectOption =
| {
key: Key
text: string
}
| string
interface SelectProps {
value?: number
options: SelectOption[]
onchange?: (value: string) => void
}
export function Select(
props: SelectProps & Omit<ElementProps<"select">, "onchange">
) {
const { className, value, onchange, options, ...rest } = props
function handleChange(e: Event) {
const target = e.target as HTMLSelectElement
onchange?.(target.value)
}
return (
<select
className={"p-2 " + className || ""}
onchange={handleChange}
{...rest}
>
{props.options.map((item) => {
const key = typeof item === "object" ? String(item.key) : item
return (
<option value={key} selected={value?.toString() === key}>
{typeof item === "object" ? item.text : item}
</option>
)
})}
</select>
)
}

View File

@ -0,0 +1,40 @@
.spinner {
width: 70px;
text-align: center;
}
.spinner>span {
width: 18px;
height: 18px;
background-color: #555;
border-radius: 100%;
display: inline-block;
-webkit-animation: sk-bounce 1.4s infinite ease-in-out both;
animation: sk-bounce 1.4s infinite ease-in-out both;
}
.bounce1 {
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
.bounce2 {
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
}
@keyframes sk-bounce {
0%,
100% {
transform: scale(0);
-webkit-transform: scale(0);
}
50% {
transform: scale(1);
-webkit-transform: scale(1);
}
}

View File

@ -0,0 +1,10 @@
import "./Spinner.css"
export function Spinner() {
return (
<div className="spinner">
<span className="bounce1" />
<span className="bounce2" />
<span className="bounce3" />
</div>
)
}

View File

@ -0,0 +1,9 @@
import { ElementProps } from "kaioken"
export function Backdrop({ children, ...props }: ElementProps<"div">) {
return (
<div {...props} className="backdrop">
{children}
</div>
)
}

View File

@ -0,0 +1,5 @@
import { ElementProps } from "kaioken"
export function DialogBody({ children }: ElementProps<"div">) {
return <div className="p-2">{children}</div>
}

View File

@ -0,0 +1,18 @@
import { ElementProps } from "kaioken"
export function DialogFooter({
children,
className,
...rest
}: ElementProps<"div">) {
return (
<div
className={`pt-2 border-t border-white border-opacity-15 flex justify-between items-center ${
className || ""
}`}
{...rest}
>
{children}
</div>
)
}

View File

@ -0,0 +1,14 @@
import { ElementProps } from "kaioken"
import { H2 } from "../atoms/Heading"
export function DialogHeader({ children, className }: ElementProps<"div">) {
return (
<div
className={`mb-2 pb-2 border-b border-white border-opacity-15 flex justify-between items-center ${
className || ""
}`}
>
<H2 className="text-xl w-full flex gap-2 justify-between">{children}</H2>
</div>
)
}

View File

@ -0,0 +1,42 @@
import { useRef, type TransitionState, useEffect } from "kaioken"
import { Backdrop } from "./Backdrop"
type DrawerProps = {
state: TransitionState
close: () => void
children?: JSX.Element[]
}
export function Drawer({ state, close, children }: DrawerProps) {
const wrapperRef = useRef<HTMLDivElement>(null)
if (state == "exited") return null
const opacity = state === "entered" ? "1" : "0"
const translateX = state === "entered" ? 0 : 100
useEffect(() => {
window.addEventListener("keyup", handleKeyPress)
return () => window.removeEventListener("keyup", handleKeyPress)
}, [])
function handleKeyPress(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault()
close()
}
}
return (
<Backdrop
ref={wrapperRef}
onclick={(e) => e.target === wrapperRef.current && close()}
style={{ opacity }}
>
<div
className="drawer-content p-4"
style={{ transform: `translateX(${translateX}%)` }}
>
{children}
</div>
</Backdrop>
)
}

View File

@ -0,0 +1,46 @@
import { useRef, type TransitionState, useEffect } from "kaioken"
import { Backdrop } from "./Backdrop"
type ModalProps = {
state: TransitionState
close: () => void
children?: JSX.Element[]
}
export function Modal({ state, close, children }: ModalProps) {
const wrapperRef = useRef<HTMLDivElement>(null)
if (state == "exited") return null
const opacity = state === "entered" ? "1" : "0"
const scale = state === "entered" ? 1 : 0.85
const translateY = state === "entered" ? -50 : -25
useEffect(() => {
window.addEventListener("keyup", handleKeyPress)
return () => window.removeEventListener("keyup", handleKeyPress)
}, [])
function handleKeyPress(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault()
if (state === "exited") return
close()
}
}
return (
<Backdrop
ref={wrapperRef}
onclick={(e) => e.target === wrapperRef.current && close()}
style={{ opacity }}
>
<div
className="modal-content p-4"
style={{
transform: `translate(-50%, ${translateY}%) scale(${scale})`,
}}
>
{children}
</div>
</Backdrop>
)
}

View File

@ -0,0 +1,17 @@
export function ChevronLeftIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>
)
}

View File

@ -0,0 +1,21 @@
import { ElementProps } from "kaioken"
export const CloseIcon = (props: ElementProps<"svg">) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1rem"
viewBox="0 0 24 24"
fill="none"
stroke-width="2"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
className="stroke"
{...props}
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
)
}

View File

@ -0,0 +1,20 @@
import { ElementProps } from "kaioken"
export const EditIcon = (props: ElementProps<"svg">) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1rem"
viewBox="0 0 24 24"
fill="none"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
stroke="currentColor"
{...props}
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)
}

View File

@ -0,0 +1,18 @@
export function GithubIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
)
}

View File

@ -0,0 +1,20 @@
export function LogoIcon({ size = 24 }: { size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
viewBox="0 0 24 24"
fill="none"
stroke="crimson"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M8 7v7" />
<path d="M12 7v4" />
<path d="M16 7v9" />
</svg>
)
}

View File

@ -0,0 +1,21 @@
import { ElementProps } from "kaioken"
export const MoreIcon = (props: ElementProps<"svg">) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1rem"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</svg>
)
}

View File

@ -0,0 +1,19 @@
import { ElementProps } from "kaioken"
export const PlusIcon = (props: ElementProps<"svg">) => {
return (
<svg
width="1rem"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
fill="none"
{...props}
>
<path
fill={"currentColor"}
fill-rule="evenodd"
d="M9 17a1 1 0 102 0v-6h6a1 1 0 100-2h-6V3a1 1 0 10-2 0v6H3a1 1 0 000 2h6v6z"
/>
</svg>
)
}

4
src/constants.ts Normal file
View File

@ -0,0 +1,4 @@
export const maxBoardNameLength = 32
export const maxListNameLength = 32
export const maxItemNameLength = 64
export const maxTagNameLength = 16

215
src/idb.ts Normal file
View File

@ -0,0 +1,215 @@
import { idb, model, Field } from "async-idb-orm"
import { List, ListItem, Board, Tag, ItemTag } from "./types"
export {
// boards
loadBoards,
updateBoard,
addBoard,
deleteBoard,
archiveBoard,
// lists
loadLists,
updateList,
addList,
deleteList,
archiveList,
// items
loadItems,
updateItem,
addItem,
deleteItem,
archiveItem,
// tags
loadTags,
updateTag,
addTag,
deleteTag,
addItemTag,
deleteItemTag,
// import/export
JsonUtils,
}
const boards = model({
id: Field.number({ primaryKey: true }),
uuid: Field.string({ default: () => crypto.randomUUID() }),
title: Field.string({ default: () => "" }),
created: Field.date({ default: () => new Date() }),
archived: Field.boolean({ default: () => false }),
order: Field.number({ default: () => 0 }),
})
const lists = model({
id: Field.number({ primaryKey: true }),
boardId: Field.number(),
title: Field.string({ default: () => "" }),
created: Field.date({ default: () => new Date() }),
archived: Field.boolean({ default: () => false }),
order: Field.number({ default: () => 0 }),
})
const items = model({
id: Field.number({ primaryKey: true }),
listId: Field.number(),
title: Field.string({ default: () => "" }),
content: Field.string({ default: () => "" }),
created: Field.date({ default: () => new Date() }),
archived: Field.boolean({ default: () => false }),
refereceItems: Field.array(Field.number()),
order: Field.number({ default: () => 0 }),
banner: Field.string({ default: undefined }),
})
const tags = model({
id: Field.number({ primaryKey: true }),
boardId: Field.number(),
title: Field.string({ default: () => "" }),
color: Field.string({ default: () => "#402579" }),
})
const itemTags = model({
id: Field.number({ primaryKey: true }),
itemId: Field.number(),
tagId: Field.number(),
boardId: Field.number(),
})
const db = idb("kanban", { boards, lists, items, tags, itemTags }, 3)
const JsonUtils = {
export: async () => {
const [boards, lists, items, tags, itemTags] = await Promise.all([
db.boards.all(),
db.lists.all(),
db.items.all(),
db.tags.all(),
db.itemTags.all(),
])
return JSON.stringify({
boards,
lists,
items,
tags,
itemTags,
})
},
import: async (data: string) => {
try {
const parsed = JSON.parse(data)
;["boards", "lists", "items", "tags", "itemTags"].forEach((store) => {
if (!(store in parsed))
throw new Error(`store '${store}' not found in import data`)
})
await Promise.all([
db.boards.clear(),
db.lists.clear(),
db.items.clear(),
db.tags.clear(),
db.itemTags.clear(),
])
const { boards, lists, items, tags, itemTags } = parsed as {
boards: Board[]
lists: List[]
items: ListItem[]
tags: Tag[]
itemTags: ItemTag[]
}
await Promise.all([
...boards.map((b) => db.boards.create(b)),
...lists.map((l) => db.lists.create(l)),
...items.map((i) => db.items.create(i)),
...tags.map((t) => db.tags.create(t)),
...itemTags.map((it) => db.itemTags.create(it)),
])
} catch (error) {
alert("an error occured while importing your data: " + error)
}
},
}
// Boards
const loadBoards = async (): Promise<Board[]> => await db.boards.all()
const updateBoard = (board: Board) => db.boards.update(board) as Promise<Board>
const addBoard = async (): Promise<Board> => {
const board = await db.boards.create({})
if (!board) throw new Error("failed to create board")
await addList(board.id)
return board as Board
}
const deleteBoard = (board: Board) =>
db.boards.delete(board.id) as Promise<void>
const archiveBoard = (board: Board) =>
db.boards.update({ ...board, archived: true }) as Promise<Board>
// Lists
const loadLists = (boardId: number, archived = false) =>
db.lists.findMany((l) => {
return l.boardId === boardId && l.archived === archived
}) as Promise<List[]>
const updateList = (list: List) => db.lists.update(list) as Promise<List>
const addList = (boardId: number, order = 0) =>
db.lists.create({ boardId, order }) as Promise<List>
const deleteList = (list: List) => db.lists.delete(list.id) as Promise<void>
const archiveList = (list: List) =>
db.lists.update({ ...list, archived: true }) as Promise<List>
// Items
const loadItems = (listId: number, archived = false) =>
db.items.findMany((i) => {
return i.listId === listId && i.archived === archived
}) as Promise<ListItem[]>
const updateItem = (item: ListItem) =>
db.items.update(item) as Promise<ListItem>
const addItem = (listId: number, order = 0) =>
db.items.create({ listId, refereceItems: [], order }) as Promise<ListItem>
const deleteItem = (item: ListItem) => db.items.delete(item.id) as Promise<void>
const archiveItem = (item: ListItem) =>
db.items.update({ ...item, archived: true }) as Promise<ListItem>
// Tags
const loadTags = async (
boardId: number
): Promise<{ tags: Tag[]; itemTags: ItemTag[] }> => {
const [tags, itemTags] = await Promise.all([
db.tags.findMany((t) => t.boardId === boardId),
db.itemTags.findMany((t) => t.boardId === boardId),
])
return {
tags,
itemTags,
}
}
const updateTag = (tag: Tag) => db.tags.update(tag) as Promise<Tag>
const addTag = (boardId: number) => db.tags.create({ boardId }) as Promise<Tag>
const deleteTag = async (tag: Tag) => db.tags.delete(tag.id)
const addItemTag = (boardId: number, itemId: number, tagId: number) =>
db.itemTags.create({
boardId,
itemId,
tagId,
}) as Promise<ItemTag>
const deleteItemTag = (itemTag: ItemTag) => db.itemTags.delete(itemTag.id)

View File

@ -1,6 +1,21 @@
import "./styles.css"; import "./styles.css"
import { mount } from "kaioken"; import { mount } from "kaioken"
import { App } from "./App"; import { App } from "./App"
import { useContextMenu } from "./state/contextMenu"
const root = document.getElementById("root")!; document.addEventListener("touchstart", () => {
mount(App, root); document.body.setAttribute("inputMode", "touch")
})
const root = document.querySelector<HTMLDivElement>("#root")!
mount(App, { root, maxFrameMs: 16 })
document.body.addEventListener("contextmenu", (e) => {
if (useContextMenu.getState().rightClickHandled) {
e.preventDefault()
useContextMenu.setState((prev) => ({ ...prev, rightClickHandled: false }))
}
if ("custom-click" in e) {
e.preventDefault()
}
})

View File

@ -0,0 +1,25 @@
import { useEffect, useReducer } from "kaioken"
import {
GlobalCtx,
GlobalDispatchCtx,
defaultGlobalState,
globalStateReducer,
} from "./global"
import { loadBoards } from "../idb"
export function GlobalProvider({ children }: { children?: JSX.Element[] }) {
const [state, dispatch] = useReducer(globalStateReducer, defaultGlobalState)
useEffect(() => {
;(async () => {
const boards = await loadBoards()
dispatch({ type: "SET_BOARDS", payload: boards })
})()
}, [])
return (
<GlobalCtx.Provider value={state}>
<GlobalDispatchCtx.Provider value={dispatch}>
{children}
</GlobalDispatchCtx.Provider>
</GlobalCtx.Provider>
)
}

78
src/state/board.ts Normal file
View File

@ -0,0 +1,78 @@
import { createStore, navigate } from "kaioken"
import type { Board } from "../types"
import { useGlobal } from "./global"
import { useBoardTagsStore } from "./boardTags"
import { useItemsStore } from "./items"
import * as db from "../idb"
import { useListsStore } from "./lists"
export const useBoardStore = createStore(
{ board: null as Board | null },
function (set, get) {
const selectBoard = async (board: Board) => {
const setTagsState = useBoardTagsStore.methods.setState
const setListsState = useListsStore.methods.setState
const setListItemsState = useItemsStore.methods.setState
const lists = await db.loadLists(board.id)
const { tags, itemTags } = await db.loadTags(board.id)
const listItems = (
await Promise.all(lists.map((list) => db.loadItems(list.id)))
).flat()
set({ board })
setListsState(lists)
setTagsState({ tags, itemTags })
setListItemsState(listItems)
}
const updateSelectedBoard = async (payload: Partial<Board>) => {
const { boards, updateBoards } = useGlobal()
const board = get().board!
const newBoard = { ...board, ...payload }
const res = await db.updateBoard(newBoard)
updateBoards(boards.map((b) => (b.id === res.id ? newBoard : b)))
set({ board: res })
}
const deleteBoard = async () => {
const board = get().board!
if (!board) throw "no board, yikes!"
const confirmDelete = confirm(
"Are you sure you want to delete this board and all of its data? This can't be undone!"
)
if (!confirmDelete) return
const { boards, updateBoards } = useGlobal()
const { items } = useItemsStore.getState()
const { tags, itemTags } = useBoardTagsStore.getState()
const { lists } = useListsStore.getState()
await Promise.all([
...tags.map(db.deleteTag),
...itemTags.map(db.deleteItemTag),
...items.map(db.deleteItem),
...lists.map(db.deleteList),
db.deleteBoard(board),
])
updateBoards(boards.filter((b) => b.id !== board.id))
set({ board: null })
navigate("/")
}
const archiveBoard = async () => {
const { boards, updateBoards } = useGlobal()
const board = get().board!
const newBoard = await db.archiveBoard(board)
updateBoards(boards.map((b) => (b.id === board.id ? newBoard : b)))
navigate("/")
}
const restoreBoard = async () => {
const board = get()!
await updateSelectedBoard({ ...board, archived: false })
}
return {
selectBoard,
archiveBoard,
deleteBoard,
updateSelectedBoard,
restoreBoard,
}
}
)

71
src/state/boardTags.ts Normal file
View File

@ -0,0 +1,71 @@
import { createStore } from "kaioken"
import { Tag, ItemTag } from "../types"
import * as db from "../idb"
export { useBoardTagsStore }
const initialValues: BoardStoreType = { tags: [], itemTags: [] }
const useBoardTagsStore = createStore(initialValues, function (set) {
const addItemTag = async ({ boardId, itemId, tagId }: AddItemTagProps) => {
const itemTag = await db.addItemTag(boardId, itemId, tagId)
set((prev) => ({ ...prev, itemTags: [...prev.itemTags, itemTag] }))
}
const removeItemTag = async (itemTag: ItemTag) => {
await db.deleteItemTag(itemTag)
set((prev) => ({
...prev,
itemTags: prev.itemTags.filter((it) => it.id !== itemTag.id),
}))
}
const addTag = async (boardId: number) => {
const tag = await db.addTag(boardId)
set((prev) => ({ ...prev, tags: [...prev.tags, tag] }))
}
const updateTag = async (tag: Tag) => {
const newTag = await db.updateTag(tag)
set((prev) => ({
...prev,
tags: prev.tags.map((t) => (t.id === tag.id ? newTag : t)),
}))
}
const setState = async ({ tags, itemTags }: TagState) => {
set({ tags, itemTags })
}
const deleteTag = async (tag: Tag) => {
await db.deleteTag(tag)
set((prev) => ({
...prev,
tags: prev.tags.filter((t) => t.id !== tag.id),
}))
}
return {
addItemTag,
removeItemTag,
setState,
addTag,
updateTag,
deleteTag,
}
})
interface AddItemTagProps {
boardId: number
itemId: number
tagId: number
}
interface TagState {
tags: Tag[]
itemTags: ItemTag[]
}
interface BoardStoreType {
tags: Tag[]
itemTags: ItemTag[]
}

21
src/state/contextMenu.ts Normal file
View File

@ -0,0 +1,21 @@
import { createStore } from "kaioken"
import type { ListItem } from "../types"
type ContextMenuState = {
open: boolean
rightClickHandled: boolean
click: { x: number; y: number }
item?: ListItem
}
const defaultState: ContextMenuState = {
open: false,
click: { x: 0, y: 0 },
rightClickHandled: false,
item: undefined,
}
export const useContextMenu = createStore({ ...defaultState }, (set) => ({
setOpen: (open: boolean) => set((prev) => ({ ...prev, open })),
reset: () => set({ ...defaultState }),
}))

196
src/state/global.ts Normal file
View File

@ -0,0 +1,196 @@
import { createContext, useContext } from "kaioken"
import {
ClickedItem,
ItemDragTarget,
GlobalState,
List,
ClickedList,
ListDragTarget,
Board,
} from "../types"
import { addBoard as addBoardDb } from "../idb"
export const GlobalCtx = createContext<GlobalState>(null)
export const GlobalDispatchCtx =
createContext<(action: GlobalDispatchAction) => void>(null)
export function useGlobal() {
const dispatch = useContext(GlobalDispatchCtx)
const setItemDragTarget = (payload: ItemDragTarget | null) =>
dispatch({ type: "SET_ITEM_DRAG_TARGET", payload })
const setListDragTarget = (payload: ListDragTarget | null) =>
dispatch({ type: "SET_LIST_DRAG_TARGET", payload })
function handleListDrag(e: MouseEvent, clickedList: ClickedList) {
if (!clickedList.mouseOffset) throw new Error("no mouseoffset")
const elements = Array.from(
document.querySelectorAll("#board .inner .list")
).filter((el) => Number(el.getAttribute("data-id")) !== clickedList.id)
let index = elements.length
const draggedItemLeft = e.clientX - (clickedList.mouseOffset.x ?? 0)
for (let i = 0; i < elements.length; i++) {
const rect = elements[i].getBoundingClientRect()
const left = rect.left
if (draggedItemLeft < left) {
index = i
break
}
}
if (clickedList.index <= index) {
index++
}
setListDragTarget({ index })
}
const addBoard = async () => {
const newBoard = await addBoardDb()
dispatch({ type: "ADD_BOARD", payload: newBoard })
}
function handleItemDrag(
e: MouseEvent,
dropArea: HTMLElement,
clickedItem: ClickedItem,
targetList: List
) {
if (!clickedItem.mouseOffset) throw new Error("no mouseoffset")
const elements = Array.from(dropArea.querySelectorAll(".list-item")).filter(
(el) => Number(el.getAttribute("data-id")) !== clickedItem.id
)
const isOriginList = clickedItem?.listId === targetList.id
let index = elements.length
const draggedItemTop = e.clientY - clickedItem.mouseOffset.y
for (let i = 0; i < elements.length; i++) {
const rect = elements[i].getBoundingClientRect()
const top = rect.top
if (draggedItemTop < top) {
index = i
break
}
}
if (isOriginList && clickedItem.index <= index) {
index++
}
setItemDragTarget({
index,
listId: targetList.id,
})
}
function setBoardEditorOpen(value: boolean) {
dispatch({ type: "SET_BOARD_EDITOR_OPEN", payload: value })
}
return {
...useContext(GlobalCtx),
addBoard,
setRootElement: (payload: HTMLDivElement) =>
dispatch({ type: "SET_ROOT_EL", payload }),
setBoardEditorOpen,
setDragging: (dragging: boolean) =>
dispatch({ type: "SET_DRAGGING", payload: { dragging } }),
setClickedItem: (payload: ClickedItem | null) =>
dispatch({ type: "SET_CLICKED_ITEM", payload }),
setItemDragTarget,
handleItemDrag,
setClickedList: (payload: ClickedList | null) =>
dispatch({ type: "SET_CLICKED_LIST", payload }),
setListDragTarget,
handleListDrag,
updateBoards: (payload: Board[]) =>
dispatch({ type: "SET_BOARDS", payload }),
}
}
type GlobalDispatchAction =
| { type: "SET_BOARD_EDITOR_OPEN"; payload: boolean }
| { type: "SET_DRAGGING"; payload: { dragging: boolean } }
| { type: "SET_CLICKED_ITEM"; payload: ClickedItem | null }
| { type: "SET_ITEM_DRAG_TARGET"; payload: ItemDragTarget | null }
| { type: "SET_CLICKED_LIST"; payload: ClickedList | null }
| { type: "SET_LIST_DRAG_TARGET"; payload: ListDragTarget | null }
| { type: "SET_BOARDS"; payload: Board[] }
| { type: "SET_ROOT_EL"; payload: HTMLDivElement }
| { type: "ADD_BOARD"; payload: Board }
export function globalStateReducer(
state: GlobalState,
action: GlobalDispatchAction
): GlobalState {
switch (action.type) {
case "SET_ROOT_EL": {
return { ...state, rootElement: action.payload }
}
case "SET_BOARD_EDITOR_OPEN": {
return { ...state, boardEditorOpen: action.payload }
}
case "SET_DRAGGING": {
const { dragging } = action.payload
return {
...state,
dragging,
}
}
case "SET_CLICKED_ITEM": {
return {
...state,
clickedItem: action.payload,
}
}
case "SET_ITEM_DRAG_TARGET": {
return {
...state,
itemDragTarget: action.payload,
}
}
case "SET_CLICKED_LIST": {
return {
...state,
clickedList: action.payload,
}
}
case "SET_LIST_DRAG_TARGET": {
return {
...state,
listDragTarget: action.payload,
}
}
case "SET_BOARDS": {
return {
...state,
boards: action.payload,
boardsLoaded: true,
}
}
case "ADD_BOARD": {
return {
...state,
boards: [...state.boards, action.payload],
}
}
default: {
return state
}
}
}
export const defaultGlobalState: GlobalState = {
boardEditorOpen: false,
rootElement: null,
dragging: false,
clickedItem: null,
itemDragTarget: null,
clickedList: null,
listDragTarget: null,
boards: [],
boardsLoaded: false,
}

159
src/state/items.ts Normal file
View File

@ -0,0 +1,159 @@
import { createStore } from "kaioken"
import { ListItem, ClickedItem, ItemDragTarget } from "../types"
import { useGlobal } from "./global"
import * as db from "../idb"
import { useBoardTagsStore } from "./boardTags"
export { useItemsStore, getListItems }
const getListItems = (listId: number) =>
useItemsStore.getState().items.filter((item) => item.listId === listId)
const useItemsStore = createStore(
{ items: [] as ListItem[] },
function (set, get) {
const getMaxListOrder = (listId: number) =>
getListItems(listId).reduce(
(acc, item) => (item.order > acc ? item.order : acc),
-1
)
const removeItemAndReorderList = async (payload: ListItem) => {
const updatedListitems = await Promise.all(
getListItems(payload.listId)
.filter((i) => i.id !== payload.id)
.map(async (item, i) => {
if (item.order === i) return item
item.order = i
return await db.updateItem(item)
})
)
return get()
.items.filter((item) => item.id !== payload.id)
.map((item) => updatedListitems.find((i) => i.id === item.id) ?? item)
}
const handleItemDrop = async (
clickedItem: ClickedItem,
itemDragTarget: ItemDragTarget
) => {
const isOriginList = clickedItem.listId === itemDragTarget.listId
const item = get().items.find((item) => item.id === clickedItem.id)!
const targetIdx =
isOriginList && clickedItem.index <= itemDragTarget.index
? itemDragTarget.index - 1
: itemDragTarget.index
const moved =
item.order !== targetIdx || item.listId !== itemDragTarget.listId
if (!moved) return
const itemList = getListItems(item.listId)
itemList.sort((a, b) => a.order - b.order)
itemList.splice(clickedItem.index, 1)
const applyItemOrder = (item: ListItem, idx: number) => {
if (item.order === idx) return item
item.order = idx
return db.updateItem(item)
}
if (isOriginList) {
itemList.splice(targetIdx, 0, item)
const newItems = await Promise.all(itemList.map(applyItemOrder))
set(({ items }) => ({
items: items.map((i) => newItems.find((ni) => ni.id === i.id) ?? i),
}))
} else {
const targetList = getListItems(itemDragTarget.listId)
targetList.splice(targetIdx, 0, item)
const newOriginItems = await Promise.all(itemList.map(applyItemOrder))
const newTargetListItems = await Promise.all(
targetList.map((item, i) => {
if (item.id === clickedItem.id) {
item.order = i
item.listId = itemDragTarget.listId
return db.updateItem(item)
}
return applyItemOrder(item, i)
})
)
set(({ items }) => ({
items: items.map(
(i) =>
newOriginItems.find((no) => no.id === i.id) ??
newTargetListItems.find((nt) => nt.id === i.id) ??
i
),
}))
}
}
const addItem = async (listId: number) => {
const { setClickedItem } = useGlobal()
const maxListOrder = getMaxListOrder(listId)
const item = await db.addItem(listId, maxListOrder + 1)
set(({ items }) => ({ items: [...items, item] }))
setClickedItem({
item,
id: item.id,
dialogOpen: true,
dragging: false,
listId,
index: item.order,
})
}
const updateItem = async (payload: ListItem) => {
const newItem = await db.updateItem(payload)
set(({ items }) => ({
items: items.map((item) => (item.id === newItem.id ? newItem : item)),
}))
}
const restoreItem = async (payload: ListItem) => {
const maxListOrder = getMaxListOrder(payload.listId)
const newItem = await db.updateItem({
...payload,
archived: false,
order: maxListOrder + 1,
})
set(({ items }) => ({ items: [...items, newItem] }))
}
const deleteItem = async (payload: ListItem) => {
const confirmDelete = confirm(
"Are you sure you want to delete this item? It can't be undone!"
)
if (!confirmDelete) return
const { itemTags } = useBoardTagsStore.getState()
await Promise.all([
...itemTags
.filter((it) => it.itemId === payload.id)
.map((it) => db.deleteItemTag(it)),
db.deleteItem(payload),
])
const newItems = await removeItemAndReorderList(payload)
set({ items: newItems })
}
const archiveItem = async (payload: ListItem) => {
await db.archiveItem(payload)
const newItems = await removeItemAndReorderList(payload)
set({ items: newItems })
}
const setState = async (payload: ListItem[]) => {
set({ items: payload })
}
const getListItems = (listId: number) => {
return get().items.filter((item) => item.listId === listId)
}
return {
addItem,
archiveItem,
deleteItem,
updateItem,
restoreItem,
handleItemDrop,
getListItems,
setState,
}
}
)

134
src/state/lists.ts Normal file
View File

@ -0,0 +1,134 @@
import { createStore } from "kaioken"
import type { List, ClickedList, ListDragTarget } from "../types"
import { useBoardStore } from "./board"
import { useGlobal } from "./global"
import { getListItems } from "./items"
import * as db from "../idb"
import { useBoardTagsStore } from "./boardTags"
export { useListsStore }
const useListsStore = createStore({ lists: [] as List[] }, function (set, get) {
const getBoardOrDie = () => {
const { board } = useBoardStore.getState()
if (!board) throw "no board selected"
return board
}
const handleListRemoved = async (id: number) => {
const newLists = await Promise.all(
get()
.lists.filter((l) => l.id !== id)
.map(async (list, i) => {
if (list.order !== i) {
list.order = i
await db.updateList(list)
}
return list
})
)
set({ lists: newLists })
}
const getMaxListOrder = () => Math.max(...get().lists.map((l) => l.order), -1)
const getList = (listId: number) =>
get().lists.find((list) => list.id === listId)
const addList = async () => {
const { setClickedList } = useGlobal()
const maxListOrder = getMaxListOrder()
const newList = await db.addList(getBoardOrDie().id, maxListOrder + 1)
set(({ lists }) => ({ lists: [...lists, { ...newList, items: [] }] }))
setClickedList({
list: newList,
dialogOpen: true,
dragging: false,
id: newList.id,
index: newList.order,
})
}
const archiveList = async (id: number) => {
const list = getList(id)
if (!list) throw new Error("dafooq, no list")
await db.archiveList(list)
await handleListRemoved(id)
}
const deleteList = async (id: number) => {
const confirmDeletion = confirm(
"Are you sure you want to delete this list and all of its data? It can't be undone!"
)
if (!confirmDeletion) return
const list = getList(id)
if (!list) throw new Error("no list, wah wah")
const { itemTags } = useBoardTagsStore.getState()
const listItems = getListItems(list.id)
await Promise.all([
...listItems.map(db.deleteItem),
...itemTags
.filter((it) => listItems.some((li) => it.itemId === li.id))
.map(db.deleteItemTag),
db.deleteList(list),
handleListRemoved(id),
])
}
const updateList = async (payload: List) => {
const list = await db.updateList(payload)
set(({ lists }) => ({
lists: lists.map((l) => (l.id === list.id ? list : l)),
}))
}
const restoreList = async (list: List) => {
const maxListOrder = getMaxListOrder()
const newList: List = {
...list,
archived: false,
order: maxListOrder + 1,
}
await db.updateList(newList)
const items = await db.loadItems(list.id)
set(({ lists }) => ({ lists: [...lists, { ...newList, items }] }))
}
const handleListDrop = async (
clickedList: ClickedList,
listDragTarget: ListDragTarget
) => {
const lists = get().lists
let targetIdx =
listDragTarget.index >= clickedList.index
? listDragTarget.index - 1
: listDragTarget.index
if (targetIdx > lists.length - 1) targetIdx--
if (clickedList.index !== targetIdx) {
const sortedLists = lists.sort((a, b) => a.order - b.order)
const [list] = sortedLists.splice(clickedList.index, 1)
sortedLists.splice(targetIdx, 0, list)
const newLists = await Promise.all(
sortedLists.map(async (list, i) => {
if (list.order === i) return list
list.order = i
await db.updateList(list)
return list
})
)
set({ lists: newLists })
}
}
const setState = (payload: List[]) => {
set({ lists: payload })
}
return {
addList,
archiveList,
deleteList,
updateList,
restoreList,
handleListDrop,
getList,
setState,
}
})

10
src/state/mouse.ts Normal file
View File

@ -0,0 +1,10 @@
import { createContext, useContext } from "kaioken"
import { Vector2 } from "../types"
type MouseContext = {
current: Vector2
setValue: (payload: Vector2) => void
}
export const MouseCtx = createContext<MouseContext>(null)
export const useMouse = () => useContext(MouseCtx)

View File

@ -3,13 +3,221 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif; background-color: #222;
color: #fff; color: #000;
background-color: #333; color-scheme: light dark;
--primary: rgb(0 0 0);
font-synthesis: none; --radius: 10px;
text-rendering: optimizeLegibility; --color: #fff;
-webkit-font-smoothing: antialiased; --item-height: 80px;
-moz-osx-font-smoothing: grayscale; --selected-item-height: 0px;
-webkit-text-size-adjust: 100%; --scrollbar-thumb: rgba(255, 255, 255, 0.2);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.4);
--list-header-padding-x: 0.25rem;
--list-header-padding-y: 0.5rem;
--dialog-background: #fff;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
/* scrollbar-color: var() transparent; */
-ms-overflow-style: none;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-moz-scrollbar {
width: 8px;
height: 8px;
}
*::-ms-scrollbar {
width: 8px;
height: 8px;
}
*::-o-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-moz-scrollbar-track {
background: transparent;
}
*::-ms-scrollbar-track {
background: transparent;
}
*::-o-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 20px;
border: 2px solid transparent;
}
*::-moz-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 20px;
border: 2px solid transparent;
}
*::-ms-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 20px;
border: 2px solid transparent;
}
*::-o-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 20px;
border: 2px solid transparent;
}
*::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
}
*::-moz-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
}
*::-ms-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
}
*::-o-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
}
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
}
#app {
display: flex;
flex-direction: column;
justify-content: center;
background: rgb(2, 0, 36);
background: radial-gradient(
at top left,
rgba(18, 18, 171, 1) 0%,
rgba(0, 212, 255, 1) 100%
);
}
main {
flex-grow: 1;
display: flex;
flex-direction: column;
min-width: 100vw;
min-height: 100vh;
max-height: 100vh;
max-width: 100vw;
overflow: auto;
}
nav {
max-width: 100vw;
position: sticky;
left: 0;
top: 0;
z-index: 1;
background-color: #fffa;
}
button[disabled] {
opacity: 0.5;
filter: grayscale();
cursor: not-allowed;
transition: 0.3s;
}
input,
textarea {
background-color: rgba(0, 0, 0, 0.15);
}
textarea {
width: 100%;
height: 100px;
padding: 10px;
resize: none;
}
select {
background: url("data:image/svg+xml,<svg height='10px' width='10px' viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'><path d='M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/></svg>")
no-repeat;
background-position: calc(100% - 0.75rem) center !important;
-moz-appearance: none !important;
-webkit-appearance: none !important;
appearance: none !important;
padding-right: 2rem !important;
background-color: rgba(255, 255, 255, 0.05);
}
select:not([disabled]) {
cursor: pointer;
}
select option {
/* background-color: #222; */
color: #fff;
background-color: #222;
}
label,
.text-muted {
color: #aaa;
}
.backdrop {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.45);
transition: opacity 150ms ease-in-out;
backdrop-filter: blur(1px);
}
.modal-content,
.drawer-content {
position: absolute;
z-index: 1000;
max-width: 100%;
background-color: var(--dialog-background);
transition: transform 150ms ease-in-out;
@apply rounded-l-xl;
}
.modal-content {
top: 50%;
left: 50%;
width: 500px;
@apply rounded-xl;
}
.drawer-content {
top: 0;
right: 0;
height: 100%;
width: 420px;
max-width: calc(100% - 50px);
overflow-y: auto;
} }

100
src/types.ts Normal file
View File

@ -0,0 +1,100 @@
export type Vector2 = {
x: number
y: number
}
export interface GlobalState {
boardEditorOpen: boolean
rootElement: HTMLElement | null
clickedItem: ClickedItem | null
itemDragTarget: ItemDragTarget | null
clickedList: ClickedList | null
listDragTarget: ListDragTarget | null
dragging: boolean
boards: Board[]
boardsLoaded: boolean
}
export interface ItemTag {
id: number
itemId: number
tagId: number
boardId: number
}
export interface Tag {
id: number
boardId: number
title: string
color: string
}
export interface ListItem {
id: number
listId: number
title: string
content: string
archived: boolean
created: Date
order: number
refereceItems: number[]
banner: string
}
export interface List {
id: number
boardId: number
title: string
archived: boolean
created: Date
order: number
}
export interface Board {
id: number
uuid: string
title: string
created: Date
archived: boolean
order: number
}
export interface ItemDragTarget {
index: number
listId: number
}
export interface ListDragTarget {
index: number
}
export interface ClickedItem {
sender?: Event
item: ListItem
id: number
index: number
dragging: boolean
dialogOpen: boolean
listId: number
element?: HTMLElement
domRect?: DOMRect
mouseOffset?: {
x: number
y: number
}
}
export interface ClickedList {
sender?: Event
list: List
id: number
index: number
dragging: boolean
dialogOpen: boolean
element?: HTMLElement
domRect?: DOMRect
mouseOffset?: {
x: number
y: number
}
}