Last active
March 29, 2023 18:35
-
-
Save ryanflorence/0becf1b7d7baf71ef0788c3bf3efbd21 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { LoaderFunction, ActionFunction } from "remix"; | |
import { useLoaderData, useFetcher } from "remix"; | |
import invariant from "tiny-invariant"; | |
import cuid from "cuid"; | |
import React from "react"; | |
import type { Task, User } from "@prisma/client"; | |
import { requireAuthSession } from "~/util/magic-auth"; | |
import { ensureUserAccount } from "~/util/account"; | |
import { placeCaretAtEnd } from "~/components/range"; | |
import { getBacklog } from "~/models/backlog"; | |
import { PlusIcon } from "~/components/icons"; | |
import { isNewTask, NewTask } from "~/models/task"; | |
import { useLayoutEffect } from "~/components/layout-effect"; | |
import { getCalendarWeeks } from "~/models/date"; | |
type LoaderData = { | |
user: User; | |
backlog: Task[]; | |
weeks: Array<Array<string>>; | |
}; | |
export let loader: LoaderFunction = async ({ request }) => { | |
let session = await requireAuthSession(request); | |
let user = await ensureUserAccount(session.get("auth")); | |
let backlog = await getBacklog(user.id); | |
let weeks = getCalendarWeeks(new Date()); | |
return { user, backlog, weeks }; | |
}; | |
enum Actions { | |
CREATE_TASK = "CREATE_TASK", | |
UPDATE_TASK_NAME = "UPDATE_TASK_NAME", | |
} | |
export let action: ActionFunction = async ({ request }) => { | |
let session = await requireAuthSession(request); | |
let user = await ensureUserAccount(session.get("auth")); | |
let data = Object.fromEntries(await request.formData()); | |
invariant(typeof data._action === "string", "_action should be string"); | |
switch (data._action) { | |
case Actions.CREATE_TASK: | |
case Actions.UPDATE_TASK_NAME: { | |
invariant(typeof data.id === "string", "expected taskId"); | |
invariant(typeof data.name === "string", "expected name"); | |
return db.task.upsert({ | |
where: { id: data.id }, | |
create: { name: data.name, id: data.id, userId: user.id }, | |
update: { name: data.name, id: data.id }, | |
}); | |
} | |
default: { | |
throw new Response("Bad Request", { status: 400 }); | |
} | |
} | |
}; | |
type RenderedTask = Task | NewTask; | |
export default function Index() { | |
return ( | |
<div className="h-full flex flex-col"> | |
<div> | |
<Calendar /> | |
</div> | |
<div className="flex-1 flex overflow-x-scroll"> | |
<div className="h-full flex-shrink-0 w-full order-2"> | |
<div className="overflow-auto h-full"> | |
<Day /> | |
</div> | |
</div> | |
<div className="flex-shrink-0 h-full w-full order-1"> | |
<Backlog /> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
function Backlog() { | |
let { backlog } = useLoaderData<LoaderData>(); | |
let [newTaskIds, setNewTaskIds] = React.useState<string[]>([]); | |
let scrollRef = React.useRef<HTMLDivElement>(null); | |
// both new tasks (haven't been saved on the server) and the actual backlog | |
let tasks: Array<RenderedTask> = [...backlog]; | |
// add the new tasks to the list | |
let savedIds = new Set(backlog.map((t) => t.id)); | |
for (let id of newTaskIds) { | |
if (!savedIds.has(id)) { | |
tasks.push({ id, name: "", isNew: true }); | |
} | |
} | |
// clear out new task ids when they show up in the real list | |
React.useEffect(() => { | |
let newIds = new Set(newTaskIds); | |
let intersection = new Set([...savedIds].filter((x) => newIds.has(x))); | |
if (intersection.size) { | |
setNewTaskIds(newTaskIds.filter((id) => !intersection.has(id))); | |
} | |
}); | |
// scroll to bottom of task list on mount, causes flicker on hydration | |
// sometimes but oh well, it's the order I want | |
useLayoutEffect(() => { | |
if (scrollRef.current) { | |
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; | |
} | |
}, []); | |
return ( | |
<div className="h-full relative"> | |
<div ref={scrollRef} className="h-full overflow-auto pb-16"> | |
{tasks.map((task) => ( | |
<TaskView key={task.id} task={task} /> | |
))} | |
</div> | |
<div className="px-2 py-4 absolute left-0 bottom-0 w-full"> | |
<button | |
type="button" | |
onClick={() => { | |
setNewTaskIds((ids) => ids.concat([cuid()])); | |
}} | |
className="shadow flex items-center justify-between gap-1 w-full bg-green-500 text-gray-50 px-4 py-2 rounded text-sm font-bold uppercase" | |
> | |
New Task <PlusIcon /> | |
</button> | |
</div> | |
</div> | |
); | |
} | |
function TaskView({ task }: { task: RenderedTask }) { | |
// nothing else ever changes the value, so don't ever take an update from the | |
// server | |
let [initialValue] = React.useState(task.name); | |
let fetcher = useFetcher(); | |
let ref = React.useRef<HTMLDivElement>(null); | |
let isNew = isNewTask(task); | |
// Kick off the fetcher to create a new record and focus when it's new layout | |
// effect so it's in the same tick of the event and therefore "in response to | |
// a user interactions" so that the keyboard opens up to start editing | |
useLayoutEffect(() => { | |
if (isNew) { | |
ref.current?.focus(); | |
// scroll iOS all the way | |
ref.current?.scrollIntoView(); | |
fetcher.submit( | |
{ _action: Actions.CREATE_TASK, id: task.id, name: "" }, | |
{ method: "post", action: "?index" } | |
); | |
} | |
}, [isNew]); | |
return ( | |
<div className="flex items-center p-2 border-t text-gray-700 focus-within:bg-gray-50"> | |
<input disabled type="checkbox" className="mr-2" /> | |
<div | |
ref={ref} | |
className="flex-1 outline-none" | |
contentEditable | |
onFocus={(e) => { | |
placeCaretAtEnd(e.currentTarget); | |
}} | |
onKeyDown={(e) => { | |
if (e.key === "Enter" || e.key === "Escape") { | |
e.currentTarget.blur(); | |
} | |
}} | |
onBlur={(e) => { | |
let value = e.currentTarget.innerHTML.trim(); | |
if (value !== task.name) { | |
fetcher.submit( | |
{ _action: Actions.UPDATE_TASK_NAME, id: task.id, name: value }, | |
{ method: "post", action: "?index" } | |
); | |
} | |
}} | |
dangerouslySetInnerHTML={{ __html: initialValue }} | |
/> | |
</div> | |
); | |
} | |
function Calendar() { | |
let { weeks } = useLoaderData<LoaderData>(); | |
return ( | |
<div className="h-full"> | |
{weeks.map((week, index) => ( | |
<div key={index} className="flex justify-between"> | |
{week.map((day) => ( | |
<CalendarDay key={day} datestring={day} /> | |
))} | |
</div> | |
))} | |
</div> | |
); | |
} | |
function CalendarDay({ datestring }: { datestring: string }) { | |
return ( | |
<div className="p-4 flex justify-center items-center w-[14.2%] h-full font-semibold"> | |
{new Date(datestring).getDate()} | |
</div> | |
); | |
} | |
function Day() { | |
return <div>Day</div>; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment