init commit with existing code
This commit is contained in:
parent
5072e5c34b
commit
96e8a5b449
20
src-tauri/Cargo.lock
generated
20
src-tauri/Cargo.lock
generated
@ -2,6 +2,16 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "KlectrTemplate"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.21.0"
|
||||
@ -119,16 +129,6 @@ version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "big-word"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
|
39
src/App.tsx
39
src/App.tsx
@ -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() {
|
||||
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 (
|
||||
<div className="w-full min-h-screen flex items-center justify-center flex-col">
|
||||
<h1 className="text-5xl text-center">{text}</h1>
|
||||
<br />
|
||||
<br />
|
||||
<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>
|
||||
</div>
|
||||
<GlobalProvider>
|
||||
<Router>
|
||||
<Route path="/" element={HomePage} />
|
||||
<Route path="/boards/:boardId" element={BoardPage} />
|
||||
</Router>
|
||||
<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
20
src/BoardPage.tsx
Normal 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
9
src/HomePage.css
Normal 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
138
src/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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 |
@ -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 |
@ -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 |
24
src/components/ActionMenu.css
Normal file
24
src/components/ActionMenu.css
Normal 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;
|
||||
}
|
||||
|
81
src/components/ActionMenu.tsx
Normal file
81
src/components/ActionMenu.tsx
Normal 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
33
src/components/Board.css
Normal 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
245
src/components/Board.tsx
Normal 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>
|
||||
)
|
||||
}
|
339
src/components/BoardEditor.tsx
Normal file
339
src/components/BoardEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
38
src/components/ContextMenu.css
Normal file
38
src/components/ContextMenu.css
Normal 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);
|
||||
}
|
||||
|
145
src/components/ContextMenu.tsx
Normal file
145
src/components/ContextMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
279
src/components/ItemEditor.tsx
Normal file
279
src/components/ItemEditor.tsx
Normal 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
178
src/components/ItemList.css
Normal 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
373
src/components/ItemList.tsx
Normal 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>
|
||||
)
|
||||
}
|
21
src/components/ListClone.tsx
Normal file
21
src/components/ListClone.tsx
Normal 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>
|
||||
}
|
113
src/components/ListEditor.tsx
Normal file
113
src/components/ListEditor.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
23
src/components/ListItemClone.tsx
Normal file
23
src/components/ListItemClone.tsx
Normal 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>
|
||||
}
|
122
src/components/atoms/Button.tsx
Normal file
122
src/components/atoms/Button.tsx
Normal 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>
|
||||
}
|
||||
}
|
49
src/components/atoms/Heading.tsx
Normal file
49
src/components/atoms/Heading.tsx
Normal 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>
|
||||
)
|
||||
}
|
19
src/components/atoms/Input.tsx
Normal file
19
src/components/atoms/Input.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
9
src/components/atoms/Link.tsx
Normal file
9
src/components/atoms/Link.tsx
Normal 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>
|
||||
)
|
||||
}
|
43
src/components/atoms/Select.tsx
Normal file
43
src/components/atoms/Select.tsx
Normal 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>
|
||||
)
|
||||
}
|
40
src/components/atoms/Spinner.css
Normal file
40
src/components/atoms/Spinner.css
Normal 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);
|
||||
}
|
||||
}
|
10
src/components/atoms/Spinner.tsx
Normal file
10
src/components/atoms/Spinner.tsx
Normal 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>
|
||||
)
|
||||
}
|
9
src/components/dialog/Backdrop.tsx
Normal file
9
src/components/dialog/Backdrop.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { ElementProps } from "kaioken"
|
||||
|
||||
export function Backdrop({ children, ...props }: ElementProps<"div">) {
|
||||
return (
|
||||
<div {...props} className="backdrop">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
5
src/components/dialog/DialogBody.tsx
Normal file
5
src/components/dialog/DialogBody.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { ElementProps } from "kaioken"
|
||||
|
||||
export function DialogBody({ children }: ElementProps<"div">) {
|
||||
return <div className="p-2">{children}</div>
|
||||
}
|
18
src/components/dialog/DialogFooter.tsx
Normal file
18
src/components/dialog/DialogFooter.tsx
Normal 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>
|
||||
)
|
||||
}
|
14
src/components/dialog/DialogHeader.tsx
Normal file
14
src/components/dialog/DialogHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
42
src/components/dialog/Drawer.tsx
Normal file
42
src/components/dialog/Drawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
46
src/components/dialog/Modal.tsx
Normal file
46
src/components/dialog/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
17
src/components/icons/ChevronLeftIcon.tsx
Normal file
17
src/components/icons/ChevronLeftIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
21
src/components/icons/CloseIcon.tsx
Normal file
21
src/components/icons/CloseIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
20
src/components/icons/EditIcon.tsx
Normal file
20
src/components/icons/EditIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
18
src/components/icons/GithubIcon.tsx
Normal file
18
src/components/icons/GithubIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
20
src/components/icons/LogoIcon.tsx
Normal file
20
src/components/icons/LogoIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
21
src/components/icons/MoreIcon.tsx
Normal file
21
src/components/icons/MoreIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
19
src/components/icons/PlusIcon.tsx
Normal file
19
src/components/icons/PlusIcon.tsx
Normal 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
4
src/constants.ts
Normal 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
215
src/idb.ts
Normal 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)
|
25
src/main.ts
25
src/main.ts
@ -1,6 +1,21 @@
|
||||
import "./styles.css";
|
||||
import { mount } from "kaioken";
|
||||
import { App } from "./App";
|
||||
import "./styles.css"
|
||||
import { mount } from "kaioken"
|
||||
import { App } from "./App"
|
||||
import { useContextMenu } from "./state/contextMenu"
|
||||
|
||||
const root = document.getElementById("root")!;
|
||||
mount(App, root);
|
||||
document.addEventListener("touchstart", () => {
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
25
src/state/GlobalProvider.tsx
Normal file
25
src/state/GlobalProvider.tsx
Normal 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
78
src/state/board.ts
Normal 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
71
src/state/boardTags.ts
Normal 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
21
src/state/contextMenu.ts
Normal 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
196
src/state/global.ts
Normal 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
159
src/state/items.ts
Normal 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
134
src/state/lists.ts
Normal 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
10
src/state/mouse.ts
Normal 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)
|
226
src/styles.css
226
src/styles.css
@ -3,13 +3,221 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
color: #fff;
|
||||
background-color: #333;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
background-color: #222;
|
||||
color: #000;
|
||||
color-scheme: light dark;
|
||||
--primary: rgb(0 0 0);
|
||||
--radius: 10px;
|
||||
--color: #fff;
|
||||
--item-height: 80px;
|
||||
--selected-item-height: 0px;
|
||||
--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
100
src/types.ts
Normal 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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user