Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Last active March 29, 2023 18:35
Show Gist options
  • Save ryanflorence/0becf1b7d7baf71ef0788c3bf3efbd21 to your computer and use it in GitHub Desktop.
Save ryanflorence/0becf1b7d7baf71ef0788c3bf3efbd21 to your computer and use it in GitHub Desktop.
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