diff --git a/package.json b/package.json index 4c62b34..32f3a88 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "kysely": "^0.27.3", "lowdb": "^7.0.1", "prisma": "^5.7.0", + "solid-events": "^0.0.4", "solid-icons": "^1.1.0", "solid-js": "^1.8.22", "unimport": "^3.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6167e5b..e562ac4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: prisma: specifier: ^5.7.0 version: 5.18.0 + solid-events: + specifier: ^0.0.4 + version: 0.0.4 solid-icons: specifier: ^1.1.0 version: 1.1.0(solid-js@1.8.22) @@ -947,6 +950,11 @@ packages: peerDependencies: solid-js: ^1.8.6 + '@solidjs/router@0.14.8': + resolution: {integrity: sha512-S+rD5Twp0820cM03wEIYtb7/4KN7Cfr3BP+qPIqb7IXO/SZ72tWqHEMQsmcjDbr4yVfpA+5Sq0Y+xcq09y1gQA==} + peerDependencies: + solid-js: ^1.8.6 + '@solidjs/start@1.0.6': resolution: {integrity: sha512-O5knaeqDBx+nKLJRm5ZJurnXZtIYBOwOreQ10APaVtVjKIKKRC5HxJ1Kwqg7atOQNNDgsF0pzhW218KseaZ1UA==} @@ -2579,6 +2587,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -2671,6 +2682,9 @@ packages: smob@1.5.0: resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} + solid-events@0.0.4: + resolution: {integrity: sha512-ZpHHOf0X0PnWyp3ECnIrB8+VMVrDPlnkbVel2GpLhceJh39LWk/aLyKbYeMoUEM7TzhkTc6rbBN40VO3A3M3lA==} + solid-icons@1.1.0: resolution: {integrity: sha512-IesTfr/F1ElVwH2E1110s2RPXH4pujKfSs+koT8rwuTAdleO5s26lNSpqJV7D1+QHooJj18mcOiz2PIKs0ic+A==} peerDependencies: @@ -2679,6 +2693,9 @@ packages: solid-js@1.8.22: resolution: {integrity: sha512-VBzN5j+9Y4rqIKEnK301aBk+S7fvFSTs9ljg+YEdFxjNjH0hkjXPiQRcws9tE5fUzMznSS6KToL5hwMfHDgpLA==} + solid-js@1.9.2: + resolution: {integrity: sha512-fe/K03nV+kMFJYhAOE8AIQHcGxB4rMIEoEyrulbtmf217NffbbwBqJnJI4ovt16e+kaIt0czE2WA7mP/pYN9yg==} + solid-refresh@0.6.3: resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} peerDependencies: @@ -3897,6 +3914,10 @@ snapshots: dependencies: solid-js: 1.8.22 + '@solidjs/router@0.14.8(solid-js@1.9.2)': + dependencies: + solid-js: 1.9.2 + '@solidjs/start@1.0.6(rollup@4.21.0)(solid-js@1.8.22)(vinxi@0.4.2(@libsql/client@0.6.2)(@types/node@20.16.1)(better-sqlite3@9.6.0)(ioredis@5.4.1)(terser@5.31.6))(vite@5.4.2(@types/node@20.16.1)(terser@5.31.6))': dependencies: '@vinxi/plugin-directives': 0.4.1(vinxi@0.4.2(@libsql/client@0.6.2)(@types/node@20.16.1)(better-sqlite3@9.6.0)(ioredis@5.4.1)(terser@5.31.6)) @@ -5758,6 +5779,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.1: + dependencies: + tslib: 2.6.3 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -5849,6 +5874,12 @@ snapshots: smob@1.5.0: {} + solid-events@0.0.4: + dependencies: + '@solidjs/router': 0.14.8(solid-js@1.9.2) + rxjs: 7.8.1 + solid-js: 1.9.2 + solid-icons@1.1.0(solid-js@1.8.22): dependencies: solid-js: 1.8.22 @@ -5859,6 +5890,12 @@ snapshots: seroval: 1.1.1 seroval-plugins: 1.1.1(seroval@1.1.1) + solid-js@1.9.2: + dependencies: + csstype: 3.1.3 + seroval: 1.1.1 + seroval-plugins: 1.1.1(seroval@1.1.1) + solid-refresh@0.6.3(solid-js@1.8.22): dependencies: '@babel/generator': 7.25.0 diff --git a/prisma/dev.db b/prisma/dev.db index 123a99f..7c59823 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/src/components/Board.tsx b/src/components/Board.tsx index f73f402..49d44f6 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -1,24 +1,8 @@ -import { Action, useSubmissions } from "@solidjs/router"; -import { For, batch, createEffect, createMemo, untrack } from "solid-js"; -import { createStore, produce, reconcile } from "solid-js/store"; -import { - AddColumn, - Column, - ColumnGap, - ColumnId, - createColumn, - deleteColumn, - moveColumn, - renameColumn, -} from "./Column"; -import { - Note, - NoteId, - createNote, - deleteNote, - editNote, - moveNote, -} from "./Note"; +import { createSubjectStore } from "solid-events"; +import { For, createMemo } from "solid-js"; +import { AddColumn, Column, ColumnGap } from "./Column"; +import { Note } from "./Note"; +import { useBoardActions } from "./actions"; export enum DragTypes { Note = "application/note", @@ -39,211 +23,71 @@ export type BoardData = { notes: Note[]; }; -type Mutation = - | { - type: "createNote"; - id: NoteId; - column: ColumnId; - board: BoardId; - body: string; - order: number; - timestamp: number; - } - | { - type: "editNote"; - id: NoteId; - content: string; - timestamp: number; - } - | { - type: "moveNote"; - id: NoteId; - column: ColumnId; - order: number; - timestamp: number; - } - | { - type: "deleteNote"; - id: NoteId; - timestamp: number; - } - | { - type: "createColumn"; - id: ColumnId; - board: string; - title: string; - timestamp: number; - } - | { - type: "renameColumn"; - id: ColumnId; - title: string; - timestamp: number; - } - | { - type: "moveColumn"; - id: ColumnId; - order: number; - timestamp: number; - } - | { - type: "deleteColumn"; - id: ColumnId; - timestamp: number; - }; - export function Board(props: { board: BoardData }) { - const [boardStore, setBoardStore] = createStore({ - columns: props.board.columns, - notes: props.board.notes, - timestamp: 0, - }); - - const createNoteSubmission = useSubmissions(createNote); - const editNoteSubmission = useSubmissions(editNote); - const moveNoteSubmission = useSubmissions(moveNote); - const deleteNoteSubmission = useSubmissions(deleteNote); - const createColumnSubmission = useSubmissions(createColumn); - const renameColumnSubmission = useSubmissions(renameColumn); - const moveColumnSubmission = useSubmissions(moveColumn); - const deleteColumnSubmission = useSubmissions(deleteColumn); - - function getMutations() { - const mutations: Mutation[] = []; - - for (const note of createNoteSubmission.values()) { - if (!note.pending) continue; - const [{ id, column, body, order, timestamp }] = note.input; - mutations.push({ - type: "createNote", - board: props.board.board.id, - id, - column, - body, - order, - timestamp, - }); - } - - for (const note of editNoteSubmission.values()) { - if (!note.pending) continue; - const [id, content, timestamp] = note.input; - mutations.push({ - type: "editNote", - id, - content, - timestamp, - }); - } - - for (const note of moveNoteSubmission.values()) { - if (!note.pending) continue; - const [id, column, order, timestamp] = note.input; - mutations.push({ - type: "moveNote", - id, - column, - order, - timestamp, - }); - } - - for (const note of deleteNoteSubmission.values()) { - if (!note.pending) continue; - const [id, timestamp] = note.input; - mutations.push({ - type: "deleteNote", - id, - timestamp, - }); - } - - for (const column of createColumnSubmission.values()) { - if (!column.pending) continue; - const [id, board, title, timestamp] = column.input; - mutations.push({ - type: "createColumn", - id, - board, - title, - timestamp, - }); - } - - for (const column of renameColumnSubmission.values()) { - if (!column.pending) continue; - const [id, title, timestamp] = column.input; - mutations.push({ - type: "renameColumn", - id, - title, - timestamp, - }); - } - - for (const column of moveColumnSubmission.values()) { - if (!column.pending) continue; - const [id, order, timestamp] = column.input; - mutations.push({ - type: "moveColumn", - id, - order, - timestamp, - }); - } - - for (const column of deleteColumnSubmission.values()) { - if (!column.pending) continue; - const [id, timestamp] = column.input; - mutations.push({ - type: "deleteColumn", - id, - timestamp, - }); - } - - return mutations; - } - - createEffect(() => { - const mutations = untrack(() => getMutations()); - - const { notes, columns } = props.board; - applyMutations(mutations, notes, columns); - - console.log( - `got server data, reset the board with mutations`, - ...mutations - ); - - batch(() => { - setBoardStore("notes", reconcile(notes)); - setBoardStore("columns", reconcile(columns)); - setBoardStore("timestamp", Date.now()); - }); - }); - - createEffect(() => { - const mutations = getMutations(); - const prevTimestamp = untrack(() => boardStore.timestamp); - const latestMutations = mutations.filter( - (m) => m.timestamp > prevTimestamp - ); - - console.log( - `found submission, apply optimistic update with mutations`, - ...latestMutations - ); - - if (!optimisticUpdates) return console.log(`Skipping optimistic update`); - - setBoardStore( - produce((b) => { - applyMutations(latestMutations, b.notes, b.columns); - b.timestamp = Date.now(); - }) - ); - }); + const { + onMoveColumn, + onMoveNote, + onCreateNote, + onCreateColumn, + onDeleteColumn, + onRenameColumn, + onDeleteNote, + onEditNote, + boardData, + } = useBoardActions(); + + const boardStore = createSubjectStore( + boardData, + onCreateNote(([note]) => (board) => { + if (!optimisticUpdates) return; + const index = board.notes.findIndex((n) => n.id === note.id); + if (index === -1) board.notes.push(note); + }), + onMoveNote(([note, column, order]) => (board) => { + if (!optimisticUpdates) return; + const index = board.notes.findIndex((n) => n.id === note); + if (index !== -1) { + board.notes[index].column = column; + board.notes[index].order = order; + } + }), + onEditNote(([id, content]) => (board) => { + if (!optimisticUpdates) return; + const index = board.notes.findIndex((n) => n.id === id); + if (index !== -1) board.notes[index].body = content; + }), + onDeleteNote(([id]) => (board) => { + if (!optimisticUpdates) return; + const index = board.notes.findIndex((n) => n.id === id); + if (index !== -1) board.notes.splice(index, 1); + }), + onCreateColumn(([id, boardId, name]) => (board) => { + if (!optimisticUpdates) return; + const index = board.columns.findIndex((c) => c.id === id); + if (index === -1) + board.columns.push({ + id: id, + board: boardId, + title: name, + order: board.columns.length + 1, + }); + }), + onRenameColumn(([id, name]) => (board) => { + if (!optimisticUpdates) return; + const index = board.columns.findIndex((c) => c.id === id); + if (index !== -1) board.columns[index].title = name; + }), + onMoveColumn(([id, order]) => (board) => { + if (!optimisticUpdates) return; + const index = board.columns.findIndex((c) => c.id === id); + if (index !== -1) board.columns[index].order = order; + }), + onDeleteColumn(([id]) => (board) => { + if (!optimisticUpdates) return; + const index = board.columns.findIndex((c) => c.id === id); + if (index !== -1) board.columns.splice(index, 1); + }) + ); const sortedColumns = createMemo(() => boardStore.columns.slice().sort((a, b) => a.order - b.order) @@ -285,73 +129,6 @@ export function Board(props: { board: BoardData }) { ); } -function applyMutations( - mutations: Mutation[], - notes: Note[], - columns: Column[] -) { - for (const mut of mutations.sort((a, b) => a.timestamp - b.timestamp)) { - switch (mut.type) { - case "createNote": { - const index = notes.findIndex((n) => n.id === mut.id); - if (index === -1) - notes.push({ - id: mut.id, - column: mut.column, - body: mut.body, - order: mut.order, - board: mut.board, - }); - break; - } - case "moveNote": { - const index = notes.findIndex((n) => n.id === mut.id); - if (index !== -1) { - notes[index].column = mut.column; - notes[index].order = mut.order; - } - break; - } - case "editNote": { - const index = notes.findIndex((n) => n.id === mut.id); - if (index !== -1) notes[index].body = mut.content; - break; - } - case "deleteNote": { - const index = notes.findIndex((n) => n.id === mut.id); - if (index !== -1) notes.splice(index, 1); - break; - } - case "createColumn": { - const index = columns.findIndex((c) => c.id === mut.id); - if (index === -1) - columns.push({ - id: mut.id, - board: mut.board, - title: mut.title, - order: columns.length + 1, - }); - break; - } - case "renameColumn": { - const index = columns.findIndex((c) => c.id === mut.id); - if (index !== -1) columns[index].title = mut.title; - break; - } - case "moveColumn": { - const index = columns.findIndex((c) => c.id === mut.id); - if (index !== -1) columns[index].order = mut.order; - break; - } - case "deleteColumn": { - const index = columns.findIndex((c) => c.id === mut.id); - if (index !== -1) columns.splice(index, 1); - break; - } - } - } -} - let optimisticUpdates = true; if (typeof window !== "undefined") { // disable optimistic updates in production for testing/demonstration purposes diff --git a/src/components/Column.tsx b/src/components/Column.tsx index cca6d73..8a10bea 100644 --- a/src/components/Column.tsx +++ b/src/components/Column.tsx @@ -15,6 +15,8 @@ import { AddNote, Note, NoteId, moveNote } from "./Note"; import { getAuthUser } from "~/lib/auth"; import { db } from "~/lib/db"; import { fetchBoard } from "~/lib"; +import { createEvent, createSubject, halt } from "solid-events"; +import { useBoardActions } from "./actions"; export const renameColumn = action( async (id: ColumnId, name: string, timestamp: number) => { @@ -88,14 +90,65 @@ export type Column = { order: number; }; +type BlurInput = FocusEvent & { + target: HTMLInputElement; +}; export function Column(props: { column: Column; board: Board; notes: Note[] }) { let parent: HTMLDivElement | undefined; - const renameAction = useAction(renameColumn); - const deleteAction = useAction(deleteColumn); - const moveNoteAction = useAction(moveNote); + const { emitRenameColumn, emitDeleteColumn, emitMoveNote } = + useBoardActions(); + + const [onDragStart, emitDragStart] = createEvent(); + const [onDragOver, emitDragOver] = createEvent< + DragEvent & { + currentTarget: HTMLDivElement; + } + >(); + const [onDragExit, emitDragExit] = createEvent(); + const [onDragLeave, emitDragLeave] = createEvent(); + const [onDrop, emitDrop] = createEvent(); + const [onBlur, emitBlur] = createEvent(); + + onDragStart((e) => + e.dataTransfer?.setData(DragTypes.Column, props.column.id) + ); - const [acceptDrop, setAcceptDrop] = createSignal(false); + onDrop((e) => { + if (e.dataTransfer?.types.includes(DragTypes.Note)) { + const noteId = e.dataTransfer?.getData(DragTypes.Note) as + | NoteId + | undefined; + + if (noteId && !filteredNotes().find((n) => n.id === noteId)) { + emitMoveNote([ + noteId, + props.column.id, + getIndexBetween( + filteredNotes()[filteredNotes().length - 1]?.order, + undefined + ), + new Date().getTime(), + ]); + } + } + }); + + onBlur((e) => { + if (e.target.reportValidity()) { + emitRenameColumn([props.column.id, e.target.value, new Date().getTime()]); + } + }); + + const acceptDrop = createSubject( + false, + onDragOver((e) => + e.dataTransfer?.types.includes(DragTypes.Note) ? true : halt() + ), + onDragLeave(() => false), + onDragExit(() => false), + onDrop(() => false) + ); const filteredNotes = createMemo(() => props.notes @@ -111,39 +164,12 @@ export function Column(props: { column: Column; board: Board; notes: Note[] }) { border: acceptDrop() === true ? "2px solid red" : "2px solid transparent", }} - onDragStart={(e) => { - e.dataTransfer?.setData(DragTypes.Column, props.column.id); - }} + onDragStart={emitDragStart} onDragEnter={(e) => e.preventDefault()} - onDragOver={(e) => { - e.preventDefault(); - if (e.dataTransfer?.types.includes(DragTypes.Note)) { - setAcceptDrop(true); - return; - } - }} - onDragLeave={(e) => setAcceptDrop(false)} - onDragExit={(e) => setAcceptDrop(false)} - onDrop={(e) => { - e.preventDefault(); - if (e.dataTransfer?.types.includes(DragTypes.Note)) { - const noteId = e.dataTransfer?.getData(DragTypes.Note) as - | NoteId - | undefined; - if (noteId && !filteredNotes().find((n) => n.id === noteId)) { - moveNoteAction( - noteId, - props.column.id, - getIndexBetween( - filteredNotes()[filteredNotes().length - 1]?.order, - undefined - ), - new Date().getTime() - ); - } - } - setAcceptDrop(false); - }} + onDragOver={(e) => (e.preventDefault(), emitDragOver(e))} + onDragLeave={emitDragLeave} + onDragExit={emitDragExit} + onDrop={(e) => (e.preventDefault(), emitDrop(e))} >
@@ -153,15 +179,7 @@ export function Column(props: { column: Column; board: Board; notes: Note[] }) { class="input input-ghost text-2xl font-bold w-full" value={props.column.title} required - onBlur={(e) => { - if (e.target.reportValidity()) { - renameAction( - props.column.id, - e.target.value, - new Date().getTime() - ); - } - }} + onBlur={emitBlur} onKeyDown={(e) => { if (e.keyCode === 13) { // @ts-expect-error maybe use currentTarget? @@ -171,7 +189,9 @@ export function Column(props: { column: Column; board: Board; notes: Note[] }) { /> @@ -203,8 +223,45 @@ export function Column(props: { column: Column; board: Board; notes: Note[] }) { } export function ColumnGap(props: { left?: Column; right?: Column }) { - const [active, setActive] = createSignal(false); - const moveColumnAction = useAction(moveColumn); + const { emitMoveColumn } = useBoardActions(); + + const [onDragOver, emitDragOver] = createEvent< + DragEvent & { + currentTarget: HTMLDivElement; + } + >(); + const [onDragExit, emitDragExit] = createEvent(); + const [onDragLeave, emitDragLeave] = createEvent(); + const [onDrop, emitDrop] = createEvent(); + + onDrop((e) => { + if ( + e.dataTransfer?.types.includes(DragTypes.Column) && + e.dataTransfer?.types.length === 1 + ) { + const columnId = e.dataTransfer?.getData(DragTypes.Column) as + | ColumnId + | undefined; + if (columnId) { + if (columnId === props.left?.id || columnId === props.right?.id) return; + const newOrder = getIndexBetween(props.left?.order, props.right?.order); + emitMoveColumn([columnId, newOrder, new Date().getTime()]); + } + } + }); + + const active = createSubject( + false, + onDragOver((e) => + e.dataTransfer?.types.includes(DragTypes.Column) && + e.dataTransfer?.types.length === 1 + ? true + : halt() + ), + onDrop(() => false), + onDragLeave(() => false), + onDragExit(() => false) + ); return (
e.preventDefault()} - onDragOver={(e) => { - e.preventDefault(); - e.stopPropagation(); - if ( - e.dataTransfer?.types.includes(DragTypes.Column) && - e.dataTransfer?.types.length === 1 - ) { - setActive(true); - } - }} - onDragLeave={(e) => setActive(false)} - onDragExit={(e) => setActive(false)} - onDrop={(e) => { - e.preventDefault(); - setActive(false); - if ( - e.dataTransfer?.types.includes(DragTypes.Column) && - e.dataTransfer?.types.length === 1 - ) { - const columnId = e.dataTransfer?.getData(DragTypes.Column) as - | ColumnId - | undefined; - if (columnId) { - if (columnId === props.left?.id || columnId === props.right?.id) - return; - const newOrder = getIndexBetween( - props.left?.order, - props.right?.order - ); - moveColumnAction(columnId, newOrder, new Date().getTime()); - } - } - }} + onDragOver={(e) => ( + e.preventDefault(), e.stopPropagation(), emitDragOver(e) + )} + onDragLeave={emitDragLeave} + onDragExit={emitDragExit} + onDrop={(e) => (e.preventDefault(), emitDrop(e))} /> ); } @@ -253,7 +283,7 @@ export function ColumnGap(props: { left?: Column; right?: Column }) { export function AddColumn(props: { board: BoardId; onAdd: () => void }) { const [active, setActive] = createSignal(false); - const addColumn = useAction(createColumn); + const { emitCreateColumn } = useBoardActions(); let inputRef: HTMLInputElement | undefined; let plusRef: HTMLButtonElement | undefined; @@ -268,12 +298,12 @@ export function AddColumn(props: { board: BoardId; onAdd: () => void }) {
( e.preventDefault(), - addColumn( + emitCreateColumn([ crypto.randomUUID() as ColumnId, props.board, inputRef?.value ?? "Column", - new Date().getTime() - ), + new Date().getTime(), + ]), inputRef && (inputRef.value = ""), props.onAdd() )} diff --git a/src/components/Note.tsx b/src/components/Note.tsx index becbc12..29ae124 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -1,13 +1,20 @@ import { action, json, useAction } from "@solidjs/router"; import { BsPlus, BsTrash } from "solid-icons/bs"; import { RiEditorDraggable } from "solid-icons/ri"; -import { Match, Switch, createSignal } from "solid-js"; +import { Match, Switch } from "solid-js"; import { BoardId, DragTypes } from "./Board"; import { ColumnId } from "./Column"; import { getIndexBetween } from "~/lib/utils"; import { getAuthUser } from "~/lib/auth"; import { db } from "~/lib/db"; import { fetchBoard } from "~/lib"; +import { + createEvent, + createPartition, + createSubject, + halt, +} from "solid-events"; +import { useBoardActions } from "./actions"; export const createNote = action( async ({ @@ -119,17 +126,93 @@ export type Note = { body: string; }; +type BlurTextArea = FocusEvent & { + target: HTMLTextAreaElement; +}; + export function Note(props: { note: Note; previous?: Note; next?: Note }) { - const updateAction = useAction(editNote); - const deleteAction = useAction(deleteNote); - const moveNoteAction = useAction(moveNote); + const { emitMoveNote, emitDeleteNote, emitEditNote } = useBoardActions(); let input: HTMLTextAreaElement | undefined; - const [isBeingDragged, setIsBeingDragged] = createSignal(false); + const [onDragStart, emitDragStart] = createEvent(); + const [onDrag, emitDrag] = createEvent(); + const [onDragEnd, emitDragEnd] = createEvent(); + const [onDragEnter, emitDragEnter] = createEvent(); + const [onDragOver, emitDragOver] = createEvent< + DragEvent & { + currentTarget: HTMLDivElement; + } + >(); + const [onDragExit, emitDragExit] = createEvent(); + const [onDragLeave, emitDragLeave] = createEvent(); + const [onDrop, emitDrop] = createEvent(); + const [onBlur, emitBlur] = createEvent(); + + onDragStart((e) => { + e.dataTransfer?.setData(DragTypes.Note, props.note.id.toString()); + }); - const [acceptDrop, setAcceptDrop] = createSignal<"top" | "bottom" | false>( - false + const isBeingDragged = createSubject( + false, + onDrag(() => true), + onDragEnd(() => false) + ); + + const onDropNote = onDrop((e) => { + if (!e.dataTransfer?.types.includes(DragTypes.Note)) halt(); + + const noteId = e.dataTransfer?.getData(DragTypes.Note) as + | NoteId + | undefined; + + if (!noteId || noteId === props.note.id) halt(); + + return noteId; + }); + + onDropNote((noteId) => { + if (acceptDrop() === "top" && props.previous?.id !== noteId) { + return emitMoveNote([ + noteId, + props.note.column, + getIndexBetween(props.previous?.order, props.note.order), + new Date().getTime(), + ]); + } + + if (acceptDrop() === "bottom" && props.next?.id !== noteId) { + return emitMoveNote([ + noteId, + props.note.column, + getIndexBetween(props.note.order, props.next?.order), + new Date().getTime(), + ]); + } + }); + + onBlur((e) => + emitEditNote([props.note.id, e.target.value, new Date().getTime()]) + ); + + const [onDragOverValidEl, onDragOverInvalidEl] = createPartition( + onDragOver, + (e) => !!e.dataTransfer?.types.includes(DragTypes.Note) + ); + + const acceptDrop = createSubject<"top" | "bottom" | false>( + false, + onDragExit(() => false), + onDragLeave(() => false), + onDrop(() => false), + onDragOverInvalidEl(() => false), + onDragOverValidEl((e) => { + const rect = e.currentTarget.getBoundingClientRect(); + const midpoint = (rect.top + rect.bottom) / 2; + const isTop = e.clientY < midpoint; + + return isTop ? "top" : "bottom"; + }) ); return ( @@ -143,77 +226,18 @@ export function Note(props: { note: Note; previous?: Note; next?: Note }) { }} draggable="true" class="card card-side px-1 py-2 w-full bg-slate-200 text-lg flex justify-between items-center space-x-1" - onDragStart={(e) => { - e.dataTransfer?.setData(DragTypes.Note, props.note.id.toString()); - }} - onDrag={(e) => { - setIsBeingDragged(true); - }} - onDragEnd={(e) => { - setIsBeingDragged(false); - }} - onDragEnter={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - onDragOver={(e) => { - e.preventDefault(); - e.stopPropagation(); - - if (!e.dataTransfer?.types.includes(DragTypes.Note)) { - setAcceptDrop(false); - return; - } - - const rect = e.currentTarget.getBoundingClientRect(); - const midpoint = (rect.top + rect.bottom) / 2; - const isTop = e.clientY < midpoint; - - setAcceptDrop(isTop ? "top" : "bottom"); - }} - onDragExit={(e) => { - setAcceptDrop(false); - }} - onDragLeave={(e) => { - setAcceptDrop(false); - }} - onDrop={(e) => { - e.preventDefault(); - e.stopPropagation(); - if (e.dataTransfer?.types.includes(DragTypes.Note)) { - const noteId = e.dataTransfer?.getData(DragTypes.Note) as - | NoteId - | undefined; - - action: if (noteId && noteId !== props.note.id) { - if (acceptDrop() === "top") { - if (props.previous && props.previous?.id === noteId) { - break action; - } - moveNoteAction( - noteId, - props.note.column, - getIndexBetween(props.previous?.order, props.note.order), - new Date().getTime() - ); - } - - if (acceptDrop() === "bottom") { - if (props.previous && props.next?.id === noteId) { - break action; - } - moveNoteAction( - noteId, - props.note.column, - getIndexBetween(props.note.order, props.next?.order), - new Date().getTime() - ); - } - } - } - - setAcceptDrop(false); - }} + onDragStart={emitDragStart} + onDrag={emitDrag} + onDragEnd={emitDragEnd} + onDragEnter={(e) => ( + e.preventDefault(), e.stopPropagation(), emitDragEnter(e) + )} + onDragOver={(e) => ( + e.preventDefault(), e.stopPropagation(), emitDragOver(e) + )} + onDragExit={emitDragExit} + onDragLeave={emitDragLeave} + onDrop={(e) => (e.preventDefault(), e.stopPropagation(), emitDrop(e))} >
@@ -224,19 +248,13 @@ export function Note(props: { note: Note; previous?: Note; next?: Note }) { style={{ resize: "none", }} - onBlur={(e) => - updateAction( - props.note.id, - (e.target as HTMLTextAreaElement).value, - new Date().getTime() - ) - } + onBlur={emitBlur} > {`${props.note.body}`} @@ -244,14 +262,52 @@ export function Note(props: { note: Note; previous?: Note; next?: Note }) { ); } +type FocusOut = FocusEvent & { + currentTarget: HTMLFormElement; +}; + export function AddNote(props: { column: ColumnId; length: number; onAdd: () => void; board: BoardId; }) { - const [active, setActive] = createSignal(false); - const addNote = useAction(createNote); + const { emitCreateNote } = useBoardActions(); + + const [onSubmit, emitSubmit] = createEvent(); + const [onCancel, emitCancel] = createEvent(); + const [onClickAdd, emitClickAdd] = createEvent(); + const [onFocusOut, emitFocusOut] = createEvent(); + + const active = createSubject( + false, + onClickAdd(() => true), + onCancel(() => false), + onFocusOut((e) => + e.currentTarget.contains(e.relatedTarget as any) ? halt() : false + ) + ); + + onSubmit(() => { + const body = inputRef?.value.trim() ?? "Note"; + if (body === "") { + inputRef?.setCustomValidity("Please fill out this field."); + inputRef?.reportValidity(); + return; + } + emitCreateNote([ + { + id: crypto.randomUUID() as NoteId, + board: props.board, + column: props.column, + body, + order: props.length + 1, + timestamp: new Date().getTime(), + }, + ]); + inputRef && (inputRef.value = ""); + props.onAdd(); + }); let inputRef: HTMLInputElement | undefined; @@ -261,30 +317,8 @@ export function AddNote(props: { { - e.preventDefault(); - const body = inputRef?.value.trim() ?? "Note"; - if (body === "") { - inputRef?.setCustomValidity("Please fill out this field."); - inputRef?.reportValidity(); - return; - } - addNote({ - id: crypto.randomUUID() as NoteId, - board: props.board, - column: props.column, - body, - order: props.length + 1, - timestamp: new Date().getTime(), - }); - inputRef && (inputRef.value = ""); - props.onAdd(); - }} - onFocusOut={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as any)) { - setActive(false); - } - }} + onSubmit={(e) => (e.preventDefault(), emitSubmit(e))} + onFocusOut={emitFocusOut} > { @@ -299,18 +333,14 @@ export function AddNote(props: { -
- diff --git a/src/components/actions.tsx b/src/components/actions.tsx new file mode 100644 index 0000000..3b52426 --- /dev/null +++ b/src/components/actions.tsx @@ -0,0 +1,153 @@ +import { useAction, useSubmission } from "@solidjs/router"; +import { Accessor, createContext, ParentProps, useContext } from "solid-js"; + +import { createColumn, deleteColumn, moveColumn, renameColumn } from "./Column"; +import { createNote, deleteNote, editNote, moveNote } from "./Note"; +import { BoardData } from "./Board"; +import { + createEvent, + createSubject, + createTopic, + Emitter, + halt, + Handler, +} from "solid-events"; + +type CreateColumnProps = Parameters; +type MoveColumnProps = Parameters; +type RenameColumnProps = Parameters; +type DeleteColumnProps = Parameters; +type CreateNoteProps = Parameters; +type MoveNoteProps = Parameters; +type EditNoteProps = Parameters; +type DeleteNoteProps = Parameters; + +const ctx = createContext<{ + onCreateNote: Handler; + emitCreateNote: Emitter; + onMoveNote: Handler; + emitMoveNote: Emitter; + onEditNote: Handler; + emitEditNote: Emitter; + onDeleteNote: Handler; + emitDeleteNote: Emitter; + onCreateColumn: Handler; + emitCreateColumn: Emitter; + onMoveColumn: Handler; + emitMoveColumn: Emitter; + onRenameColumn: Handler; + emitRenameColumn: Emitter; + onDeleteColumn: Handler; + emitDeleteColumn: Emitter; + boardData: Accessor; +}>(); + +export function useBoardActions() { + const value = useContext(ctx); + if (!value) throw new Error("BoardActionsProvider not found"); + return value; +} + +export function BoardActionsProvider(props: ParentProps<{ board: BoardData }>) { + const [onCreateNote, emitCreateNote] = createEvent(); + const createNoteAction = useAction(createNote); + const createNoteSubmission = useSubmission(createNote); + const onCreateNoteComplete = onCreateNote((p) => createNoteAction(...p)); + + const [onMoveNote, emitMoveNote] = createEvent(); + const moveNoteAction = useAction(moveNote); + const moveNoteSubmission = useSubmission(moveNote); + const onMoveNoteComplete = onMoveNote((p) => moveNoteAction(...p)); + + const [onEditNote, emitEditNote] = createEvent(); + const updateNoteAction = useAction(editNote); + const updateNoteSubmission = useSubmission(editNote); + const onEditNoteComplete = onEditNote((p) => updateNoteAction(...p)); + + const [onDeleteNote, emitDeleteNote] = createEvent(); + const deleteNoteAction = useAction(deleteNote); + const deleteNoteSubmission = useSubmission(deleteNote); + const onDeleteNoteComplete = onDeleteNote((p) => deleteNoteAction(...p)); + + const [onCreateColumn, emitCreateColumn] = createEvent(); + const createColumnAction = useAction(createColumn); + const createColumnSubmission = useSubmission(createColumn); + const onCreateColumnComplete = onCreateColumn((p) => + createColumnAction(...p) + ); + + const [onMoveColumn, emitMoveColumn] = createEvent(); + const moveColumnAction = useAction(moveColumn); + const moveColumnSubmission = useSubmission(moveColumn); + const onMoveColumnComplete = onMoveColumn((p) => moveColumnAction(...p)); + + const [onRenameColumn, emitRenameColumn] = createEvent(); + const renameColumnAction = useAction(renameColumn); + const renameColumnSubmission = useSubmission(renameColumn); + const onRenameColumnComplete = onRenameColumn((p) => + renameColumnAction(...p) + ); + + const [onDeleteColumn, emitDeleteColumn] = createEvent(); + const deleteColumnAction = useAction(deleteColumn); + const deleteColumnSubmission = useSubmission(deleteColumn); + const onDeleteColumnComplete = onDeleteColumn((p) => + deleteColumnAction(...p) + ); + + const onActionComplete = createTopic( + onCreateNoteComplete, + onMoveNoteComplete, + onEditNoteComplete, + onDeleteNoteComplete, + onCreateColumnComplete, + onMoveColumnComplete, + onRenameColumnComplete, + onDeleteColumnComplete + ); + + const boardData = createSubject( + { + board: props.board.board, + columns: props.board.columns, + notes: props.board.notes, + }, + onActionComplete(() => { + if ( + createNoteSubmission.pending || + moveNoteSubmission.pending || + updateNoteSubmission.pending || + deleteNoteSubmission.pending || + createColumnSubmission.pending || + moveColumnSubmission.pending || + renameColumnSubmission.pending || + deleteColumnSubmission.pending + ) + halt(); + + return props.board; + }) + ); + + const value = { + onCreateNote, + emitCreateNote, + onMoveNote, + emitMoveNote, + onEditNote, + emitEditNote, + onDeleteNote, + emitDeleteNote, + onCreateColumn, + emitCreateColumn, + onMoveColumn, + emitMoveColumn, + onRenameColumn, + emitRenameColumn, + onDeleteColumn, + emitDeleteColumn, + boardData, + }; + + return {props.children}; +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 0c2c97c..6031213 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,14 +1,6 @@ import { PrismaClient } from "@prisma/client"; -import { PrismaLibSQL } from "@prisma/adapter-libsql"; -import { createClient } from "@libsql/client"; -const libsql = createClient({ - url: `${process.env.TURSO_DATABASE_URL}`, - authToken: `${process.env.TURSO_AUTH_TOKEN}`, -}); - -const adapter = new PrismaLibSQL(libsql); -const db = new PrismaClient({ adapter }); +const db = new PrismaClient(); process.on("beforeExit", () => { db.$disconnect(); diff --git a/src/routes/board/[id].tsx b/src/routes/board/[id].tsx index f5e188f..68116cf 100644 --- a/src/routes/board/[id].tsx +++ b/src/routes/board/[id].tsx @@ -8,6 +8,7 @@ import { useSubmission, } from "@solidjs/router"; import { Show } from "solid-js"; +import { BoardActionsProvider } from "~/components/actions"; import { Board } from "~/components/Board"; import EditableText from "~/components/EditableText"; import { fetchBoard } from "~/lib"; @@ -56,7 +57,9 @@ export default function Page(props: RouteSectionProps) {
- + + +
)} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 8aca058..08c4d13 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -4,8 +4,8 @@ import { cache, createAsync, redirect, + useAction, useSubmission, - useSubmissions, type RouteDefinition, } from "@solidjs/router"; import { BsTrash } from "solid-icons/bs"; @@ -13,6 +13,7 @@ import { For, Show, onMount } from "solid-js"; import { getUser } from "~/lib"; import { getAuthUser } from "~/lib/auth"; import { db } from "~/lib/db"; +import { createEvent, createAsyncSubject } from "solid-events"; const addBoard = action(async (formData: FormData) => { "use server"; @@ -66,25 +67,17 @@ export const route = { export default function Home() { const user = createAsync(() => getUser()); - const serverBoards = createAsync(() => getBoards()); const addBoardSubmission = useSubmission(addBoard); - const deleteBoardSubmissions = useSubmissions(deleteBoard); - const boards = () => { - if (deleteBoardSubmissions.pending) { - const deletedBoards: number[] = []; + const [onDeleteBoard, emitDeleteBoard] = createEvent(); + onDeleteBoard(useAction(deleteBoard)); - for (const sub of deleteBoardSubmissions) { - deletedBoards.push(sub.input[0]); - } - - return serverBoards()?.filter( - (board) => !deletedBoards.includes(board.id) - ); - } - - return serverBoards(); - }; + const boards = createAsyncSubject( + () => getBoards(), + onDeleteBoard( + (boardId) => (boards) => boards.filter((board) => board.id !== boardId) + ) + ); let inputRef: HTMLInputElement | undefined; @@ -162,7 +155,7 @@ export default function Home() {
emitDeleteBoard(board.id)} method="post" >