├── .env.example ├── .gitignore ├── README.md ├── app ├── actions │ ├── actions.server.ts │ └── actions.ts ├── components │ ├── editable-list.tsx │ ├── forms.tsx │ ├── icons.tsx │ ├── layout-effect.ts │ ├── layouts.tsx │ ├── tasks │ │ ├── backlog.tsx │ │ ├── bucket.tsx │ │ ├── calendar.tsx │ │ ├── day.tsx │ │ ├── shared.tsx │ │ └── unassigned.tsx │ └── use-parent-data.ts ├── entry.client.tsx ├── entry.server.tsx ├── models │ ├── bucket.ts │ ├── db.server.ts │ └── task.ts ├── root.tsx ├── routes │ ├── auth │ │ ├── logout.tsx │ │ └── validate.tsx │ ├── buckets.tsx │ ├── buckets │ │ ├── $bucketSlug.tsx │ │ └── index.tsx │ ├── calendar.tsx │ ├── calendar │ │ └── $day.tsx │ ├── index.tsx │ └── login.tsx └── util │ ├── auth.server.tsx │ ├── date.ts │ ├── http.ts │ └── user.server.ts ├── fly.toml ├── package-lock.json ├── package.json ├── prisma ├── migrations │ ├── 20211213204911_init │ │ └── migration.sql │ ├── 20211218150006_change_task_date_to_string │ │ └── migration.sql │ ├── 20220117211501_add_sort_updated_at │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public └── favicon.png ├── remix.config.js ├── remix.env.d.ts ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | ORIGIN=http://localhost:3000 2 | SESSION_SECRET=... 3 | MAGIC_LINK_SALT=... 4 | MAILGUN_KEY=... 5 | MAILGUN_DOMAIN=... 6 | DATABASE_URL=postgres://user:pass@db-server:5432 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | /prisma/*.db 8 | /app/tailwind.css 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ryan's Planner 2 | 3 | No instructions yet, but there will be soon. 4 | -------------------------------------------------------------------------------- /app/actions/actions.server.ts: -------------------------------------------------------------------------------- 1 | import { ActionFunction, redirect } from "remix"; 2 | 3 | import invariant from "tiny-invariant"; 4 | 5 | import { requireAuthSession } from "../util/auth.server"; 6 | 7 | import * as Task from "~/models/task"; 8 | import * as Bucket from "~/models/bucket"; 9 | 10 | import { parseStringFormData } from "~/util/http"; 11 | import { Actions } from "./actions"; 12 | 13 | export let handleTaskAction: ActionFunction = async ({ request, params }) => { 14 | let session = await requireAuthSession(request); 15 | let userId = session.get("userId"); 16 | 17 | let data = await parseStringFormData(request); 18 | 19 | switch (data._action) { 20 | case Actions.CREATE_TASK: 21 | case Actions.UPDATE_TASK_NAME: { 22 | invariant(data.id, "expected id"); 23 | let { date, name, bucketId } = data; 24 | return Task.createOrUpdateTask(userId, data.id, { date, name, bucketId }); 25 | } 26 | 27 | case Actions.MARK_COMPLETE: { 28 | invariant(data.id, "expected task id"); 29 | return Task.markComplete(data.id); 30 | } 31 | 32 | case Actions.MARK_INCOMPLETE: { 33 | invariant(data.id, "expected task id"); 34 | return Task.markIncomplete(data.id); 35 | } 36 | 37 | case Actions.MOVE_TASK_TO_DAY: { 38 | invariant(data.id && params.day, "expected taskId and params.day"); 39 | return Task.addDate(data.id, params.day); 40 | } 41 | 42 | case Actions.MOVE_TASK_TO_BACKLOG: { 43 | invariant(data.id, "expected taskId"); 44 | return Task.removeDate(data.id); 45 | } 46 | 47 | case Actions.UNASSIGN_TASK: { 48 | invariant(data.id, "expected taskId"); 49 | return Task.unassignTask(data.id); 50 | } 51 | 52 | case Actions.MOVE_TASK_TO_BUCKET: { 53 | invariant(data.id && data.bucketId, "expected taskId, bucketId"); 54 | return Task.assignTask(data.id, data.bucketId); 55 | } 56 | 57 | case Actions.DELETE_TASK: { 58 | invariant(data.id, "expected taskId"); 59 | return Task.deleteTask(data.id); 60 | } 61 | 62 | case Actions.CREATE_BUCKET: { 63 | invariant(data.id, "expected bucket id"); 64 | return Bucket.createBucket(userId, data.id, data.name); 65 | } 66 | 67 | case Actions.DELETE_BUCKET: { 68 | invariant(data.id, "expected bucket id"); 69 | return Bucket.deleteBucket(data.id); 70 | } 71 | 72 | case Actions.UPDATE_BUCKET_NAME: { 73 | invariant( 74 | data.id && data.name && data.slug, 75 | "expected bucket id, slug, name" 76 | ); 77 | let bucket = await Bucket.getBucket(data.id); 78 | invariant(bucket, `expected bucket with id ${data.id}`); 79 | let bucketIsActivePage = data.slug === bucket.slug; 80 | bucket = await Bucket.updateBucketName(data.id, data.name); 81 | return bucketIsActivePage ? redirect(`/buckets/${bucket.slug}`) : bucket; 82 | } 83 | 84 | default: { 85 | throw new Response(`Unknown action ${data._action}`, { status: 400 }); 86 | } 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /app/actions/actions.ts: -------------------------------------------------------------------------------- 1 | // TODO: nahh ... I don't like this, gonna move the actions to the specific 2 | // routes that handle them so illustrate how you might scale a very large app in 3 | // Remix, this huge list of actions (and the huge function that handles them 4 | // all) wouldn't scale as well as each route owning it's actions. 5 | export enum Actions { 6 | CREATE_TASK = "CREATE_TASK", 7 | UPDATE_TASK_NAME = "UPDATE_TASK_NAME", 8 | MOVE_TASK_TO_DAY = "MOVE_TASK_TO_DAY", 9 | MOVE_TASK_TO_BACKLOG = "MOVE_TASK_TO_BACKLOG", 10 | MARK_COMPLETE = "MARK_COMPLETE", 11 | MARK_INCOMPLETE = "MARK_INCOMPLETE", 12 | DELETE_TASK = "DELETE_TASK", 13 | UNASSIGN_TASK = "UNASSIGN_TASK", 14 | CREATE_BUCKET = "CREATE_BUCKET", 15 | DELETE_BUCKET = "DELETE_BUCKET", 16 | UPDATE_BUCKET_NAME = "UPDATE_BUCKET_NAME", 17 | MOVE_TASK_TO_BUCKET = "MOVE_TASK_TO_BUCKET", 18 | } 19 | -------------------------------------------------------------------------------- /app/components/editable-list.tsx: -------------------------------------------------------------------------------- 1 | import cuid from "cuid"; 2 | import React from "react"; 3 | import { useLayoutEffect } from "./layout-effect"; 4 | import { PlusIcon } from "./icons"; 5 | import { AppButton } from "./forms"; 6 | 7 | interface EditableRecord extends Record { 8 | name: string; 9 | id: string; 10 | isNew?: boolean; 11 | } 12 | 13 | type RenderedRecord = EditableRecord | T; 14 | 15 | export function isNewRecord(record: any): record is EditableRecord { 16 | return ( 17 | record && 18 | typeof record.id === "string" && 19 | typeof record.name === "string" && 20 | record.isNew 21 | ); 22 | } 23 | 24 | export function useOptimisticRecords( 25 | records: T[] 26 | ): [RenderedRecord[], () => void] { 27 | let [optimisticIds, setOptimisticIds] = React.useState([]); 28 | 29 | // Both optimistic and actual tasks combined into one array 30 | let renderedRecords: RenderedRecord[] = [...records]; 31 | 32 | // Add the optimistic tasks to the rendered list 33 | let savedIds = new Set(records.map((t) => t.id)); 34 | for (let id of optimisticIds) { 35 | if (!savedIds.has(id)) { 36 | renderedRecords.push({ id, name: "", isNew: true }); 37 | } 38 | } 39 | 40 | // Clear out optimistic task IDs when they show up in the actual list 41 | React.useEffect(() => { 42 | let newIds = new Set(optimisticIds); 43 | let intersection = new Set([...savedIds].filter((x) => newIds.has(x))); 44 | if (intersection.size) { 45 | setOptimisticIds(optimisticIds.filter((id) => !intersection.has(id))); 46 | } 47 | }); 48 | 49 | let addRecord = React.useCallback(() => { 50 | setOptimisticIds((ids) => ids.concat([cuid()])); 51 | }, []); 52 | 53 | return [renderedRecords, addRecord]; 54 | } 55 | 56 | export function EditableList({ 57 | items, 58 | renderItem, 59 | label, 60 | }: { 61 | items: T[]; 62 | renderItem: (item: RenderedRecord) => React.ReactNode; 63 | label: string; 64 | }) { 65 | let [renderedRecords, addRecord] = useOptimisticRecords(items); 66 | let scrollRef = React.useRef(null); 67 | 68 | // scroll to bottom of task list on mount, causes flicker on hydration 69 | // sometimes but oh well 70 | useLayoutEffect(() => { 71 | if (scrollRef.current) { 72 | scrollRef.current.scrollTop = scrollRef.current.scrollHeight; 73 | } 74 | }, []); 75 | 76 | return ( 77 |
78 |
79 |
{renderedRecords.map((item) => renderItem(item))}
80 |
81 | { 84 | addRecord(); 85 | event.preventDefault(); 86 | }} 87 | > 88 | {label} 89 | 90 |
91 |
92 |
93 | ); 94 | } 95 | 96 | export function ContentEditableField({ 97 | value, 98 | isNew, 99 | onCreate, 100 | onChange, 101 | onDelete, 102 | }: { 103 | value: string; 104 | isNew: boolean; 105 | onCreate: () => void; 106 | onChange: (value: string) => void; 107 | onDelete: () => void; 108 | }) { 109 | // uncontrolled contenteditable, so don't ever take an update from the server 110 | let [initialValue] = React.useState(value); 111 | 112 | let ref = React.useRef(null); 113 | 114 | // Kick off the fetcher to create a new record and focus when it's new layout 115 | // effect so it's in the same tick of the event and therefore "in response to 116 | // a user interactions" so that the keyboard opens up to start editing 117 | useLayoutEffect(() => { 118 | if (isNew && ref.current) { 119 | onCreate(); 120 | ref.current.focus(); 121 | ref.current?.scrollIntoView(); 122 | } 123 | }, [isNew]); 124 | 125 | return ( 126 |
{ 131 | placeCaretAtEnd(e.currentTarget); 132 | }} 133 | onKeyDown={(e) => { 134 | if (e.key === "Escape") { 135 | e.currentTarget.blur(); 136 | return; 137 | } 138 | 139 | if (e.metaKey && e.key === "Enter") { 140 | // TODO: create a new task, don't blur 141 | e.currentTarget.blur(); 142 | } 143 | 144 | if (e.key === "Backspace") { 145 | let value = e.currentTarget.innerHTML.trim(); 146 | if (value === "") { 147 | onDelete(); 148 | } 149 | } 150 | }} 151 | onBlur={(e) => { 152 | let newValue = e.currentTarget.innerHTML.trim(); 153 | if (newValue !== value) { 154 | onChange(newValue); 155 | } 156 | }} 157 | dangerouslySetInnerHTML={{ __html: initialValue }} 158 | /> 159 | ); 160 | } 161 | 162 | function placeCaretAtEnd(node: HTMLElement) { 163 | let range = document.createRange(); 164 | range.selectNodeContents(node); 165 | range.collapse(false); 166 | let sel = window.getSelection(); 167 | if (sel) { 168 | sel.removeAllRanges(); 169 | sel.addRange(range); 170 | } 171 | } 172 | 173 | function selectAll(node: HTMLElement) { 174 | let range = document.createRange(); 175 | range.selectNodeContents(node); 176 | let sel = window.getSelection(); 177 | if (sel) { 178 | sel.removeAllRanges(); 179 | sel.addRange(range); 180 | } 181 | } 182 | 183 | export function Header({ children }: { children: React.ReactNode }) { 184 | return ( 185 |
189 | ); 190 | } 191 | 192 | export function EditableItem({ 193 | children, 194 | hide, 195 | }: { 196 | children: React.ReactNode; 197 | // TODO: bringin in an animation library, needs to wrap the whole list to 198 | // persist them for the animation 199 | hide?: boolean; 200 | }) { 201 | return hide ? null : ( 202 |
203 | {children} 204 |
205 | ); 206 | } 207 | -------------------------------------------------------------------------------- /app/components/forms.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react"; 2 | import { useLayoutEffect } from "./layout-effect"; 3 | 4 | export let Button = forwardRef< 5 | HTMLButtonElement, 6 | React.ComponentPropsWithRef<"button"> 7 | >(({ className, children, ...props }, ref) => { 8 | return ( 9 | 20 | ); 21 | }); 22 | 23 | export let TextInput = forwardRef< 24 | HTMLInputElement, 25 | React.ComponentPropsWithRef<"input"> 26 | >(({ className, children, ...props }, ref) => { 27 | return ( 28 | 38 | ); 39 | }); 40 | 41 | export let AppButton = forwardRef< 42 | HTMLButtonElement, 43 | React.ComponentPropsWithRef<"button"> 44 | >(({ className, ...props }, ref) => { 45 | return ( 46 | 93 | 94 | 95 | { 99 | fetcher.submit( 100 | { 101 | _action: Actions.CREATE_TASK, 102 | id: task.id, 103 | name: "", 104 | bucketId: bucketId, 105 | }, 106 | { method: "post", action } 107 | ); 108 | }} 109 | onChange={(value) => { 110 | fetcher.submit( 111 | { _action: Actions.UPDATE_TASK_NAME, id: task.id, name: value }, 112 | { method: "post", action } 113 | ); 114 | }} 115 | onDelete={() => { 116 | fetcher.submit( 117 | { _action: Actions.DELETE_TASK, id: task.id }, 118 | { method: "post", action } 119 | ); 120 | }} 121 | /> 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /app/components/tasks/calendar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NavLink, useTransition } from "remix"; 3 | import { buildStyles, CircularProgressbar } from "react-circular-progressbar"; 4 | import { format, isFirstDayOfMonth, isToday } from "date-fns"; 5 | 6 | import { useLayoutEffect } from "~/components/layout-effect"; 7 | import { parseParamDate } from "~/util/date"; 8 | import { CalendarStats } from "~/models/task"; 9 | 10 | /** 11 | * This component needs a lot of help, but for now this is a great MVP 12 | * 13 | * - add virtual scrolling 14 | * - on load, scroll the active day to the second row 15 | * - don't bounce around when clicking 16 | * 17 | */ 18 | export function Calendar({ 19 | weeks, 20 | stats, 21 | day: paramDate, 22 | }: { 23 | stats: CalendarStats; 24 | weeks: Array>; 25 | day: string; 26 | }) { 27 | return ( 28 |
34 | {weeks.map((week) => 35 | week.map((day) => ( 36 | 43 | )) 44 | )} 45 |
46 | ); 47 | } 48 | 49 | function CalendarDay({ 50 | day, 51 | complete, 52 | total, 53 | isActive, 54 | }: { 55 | day: string; 56 | complete?: number; 57 | total?: number; 58 | isActive: boolean; 59 | }) { 60 | let date = parseParamDate(day); 61 | let isMonthBoundary = isFirstDayOfMonth(date); 62 | let ref = React.useRef(null); 63 | let transition = useTransition(); 64 | let isPending = transition.location?.pathname.split("/").slice(-1)[0] === day; 65 | 66 | // this is so gross right now. 67 | useLayoutEffect(() => { 68 | if (isActive) { 69 | ref.current?.scrollIntoView(); 70 | } 71 | }, []); 72 | 73 | return ( 74 | 83 | "relative flex items-center justify-center m-2 h-10 font-semibold rounded-lg xl:w-12 xl:h-10 text-sm" + 84 | " " + 85 | (isActive || isPending 86 | ? "bg-pink-500 text-white" 87 | : isToday(date) 88 | ? "text-gray-900 shadow" 89 | : "text-gray-400") 90 | } 91 | > 92 | {isMonthBoundary && ( 93 |
94 | {format(date, "MMM")} 95 |
96 | )} 97 |
{day.split("-").slice(-1)[0]}
98 | {total != null && ( 99 |
100 | 108 |
109 | )} 110 |
111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /app/components/tasks/day.tsx: -------------------------------------------------------------------------------- 1 | // @ts-expect-error 2 | import sortBy from "sort-by"; 3 | import { format } from "date-fns"; 4 | import { Task } from "@prisma/client"; 5 | import { useFetcher, useFormAction } from "remix"; 6 | 7 | import { ArrowButton, CheckIcon, RightArrowIcon } from "~/components/icons"; 8 | import { Actions } from "~/actions/actions"; 9 | import { 10 | ContentEditableField, 11 | EditableItem, 12 | EditableList, 13 | Header, 14 | } from "~/components/editable-list"; 15 | import { 16 | ColoredLabel, 17 | isNewTask, 18 | RenderedTask, 19 | useImmigrants, 20 | } from "~/components/tasks/shared"; 21 | import { parseParamDate } from "~/util/date"; 22 | 23 | export function DayTaskList({ 24 | day, 25 | tasks, 26 | backlog, 27 | }: { 28 | day: string; 29 | tasks: Task[]; 30 | backlog: Task[]; 31 | }) { 32 | let formattedDate = format(parseParamDate(day), "E, LLL do"); 33 | let immigrants = useImmigrants(Actions.MOVE_TASK_TO_DAY, backlog); 34 | return ( 35 | <> 36 |
{formattedDate}
37 | } 41 | /> 42 | 43 | ); 44 | } 45 | 46 | function DayTask({ task, day }: { task: RenderedTask; day: string }) { 47 | let fetcher = useFetcher(); 48 | 49 | // TODO: move this to a generic route so it doesn't matter which route 50 | // is calling this 51 | let action = useFormAction(); 52 | 53 | // optimistic "complete" status 54 | let complete = 55 | fetcher.submission?.formData.get("_action") === Actions.MARK_COMPLETE 56 | ? true 57 | : fetcher.submission?.formData.get("_action") === Actions.MARK_INCOMPLETE 58 | ? false 59 | : Boolean(task.complete); 60 | 61 | let moving = 62 | fetcher.submission?.formData.get("_action") === 63 | Actions.MOVE_TASK_TO_BACKLOG; 64 | 65 | let deleting = 66 | fetcher.submission?.formData.get("_action") === Actions.DELETE_TASK; 67 | 68 | // FIXME: fix the types here 69 | let bucketName = (task as any).Bucket?.name as string | undefined; 70 | 71 | return ( 72 | 73 | 74 | 79 | 80 | 91 | 92 | 93 | { 97 | fetcher.submit( 98 | { 99 | _action: Actions.CREATE_TASK, 100 | id: task.id, 101 | name: "", 102 | date: day, 103 | }, 104 | { method: "post", action } 105 | ); 106 | }} 107 | onChange={(value) => { 108 | fetcher.submit( 109 | { _action: Actions.UPDATE_TASK_NAME, id: task.id, name: value }, 110 | { method: "post", action } 111 | ); 112 | }} 113 | onDelete={() => { 114 | fetcher.submit( 115 | { _action: Actions.DELETE_TASK, id: task.id }, 116 | { method: "post", action } 117 | ); 118 | }} 119 | /> 120 | 121 | {bucketName && } 122 | 123 | 124 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | ); 136 | } 137 | -------------------------------------------------------------------------------- /app/components/tasks/shared.tsx: -------------------------------------------------------------------------------- 1 | import { Task } from "@prisma/client"; 2 | import { useFetchers } from "remix"; 3 | 4 | import { Actions } from "~/actions/actions"; 5 | 6 | export type NewTask = { 7 | id: string; 8 | name: string; 9 | isNew?: boolean; 10 | complete?: boolean; 11 | }; 12 | 13 | export type RenderedTask = Task | NewTask; 14 | 15 | export function isNewTask(task: any): task is NewTask { 16 | return ( 17 | task && 18 | typeof task.id === "string" && 19 | typeof task.name === "string" && 20 | task.isNew 21 | ); 22 | } 23 | 24 | export function useImmigrants(action: Actions, tasks: Task[]): Task[] { 25 | let fetchers = useFetchers(); 26 | let immigrants: Task[] = []; 27 | let tasksMap = new Map(); 28 | 29 | // if there are some fetchers, fill up the map to avoid a nested loop next 30 | if (fetchers.length) { 31 | for (let task of tasks) { 32 | tasksMap.set(task.id, task); 33 | } 34 | } 35 | 36 | // find the tasks that are moving to the other list 37 | for (let fetcher of fetchers) { 38 | if (fetcher.submission?.formData.get("_action") === action) { 39 | let id = fetcher.submission.formData.get("id"); 40 | if (typeof id === "string") { 41 | let task = tasksMap.get(id); 42 | if (task) { 43 | immigrants.push({ 44 | ...task, 45 | sortUpdatedAt: new Date(), // optimistic 46 | }); 47 | } 48 | } 49 | } 50 | } 51 | 52 | return immigrants; 53 | } 54 | 55 | export function ColoredLabel({ label }: { label: string }) { 56 | let hue = stringToHue(label); 57 | return ( 58 |
66 | ); 67 | } 68 | 69 | function hashString(str: string) { 70 | let hash = 0; 71 | for (let i = 0; i < str.length; i++) { 72 | hash = str.charCodeAt(i) + ((hash << 5) - hash); 73 | } 74 | return hash; 75 | } 76 | 77 | export function stringToHue(str: string) { 78 | return hashString(str) % 360; 79 | } 80 | -------------------------------------------------------------------------------- /app/components/tasks/unassigned.tsx: -------------------------------------------------------------------------------- 1 | import { Task } from "@prisma/client"; 2 | import { useFetcher, useFormAction } from "remix"; 3 | 4 | import { Actions } from "~/actions/actions"; 5 | import { 6 | isNewTask, 7 | RenderedTask, 8 | useImmigrants, 9 | } from "~/components/tasks/shared"; 10 | import { 11 | ContentEditableField, 12 | EditableItem, 13 | EditableList, 14 | Header, 15 | } from "../editable-list"; 16 | import { ArrowButton, LeftArrowIcon } from "../icons"; 17 | 18 | export function UnassignedTaskList({ 19 | tasks, 20 | bucketId, 21 | unassigned, 22 | }: { 23 | tasks: Task[]; 24 | bucketId: string; 25 | unassigned: Task[]; 26 | }) { 27 | let immigrants = useImmigrants(Actions.UNASSIGN_TASK, tasks); 28 | return ( 29 | <> 30 |
Unassigned
31 | ( 35 | 36 | )} 37 | /> 38 | 39 | ); 40 | } 41 | 42 | /** 43 | * TODO: This is just copy/pasta from BacklogTask, needs it's own stuff 44 | */ 45 | function UnassignedTask({ 46 | task, 47 | bucketId, 48 | }: { 49 | task: RenderedTask; 50 | bucketId: string; 51 | }) { 52 | let action = useFormAction(); 53 | let fetcher = useFetcher(); 54 | let moving = 55 | fetcher.submission?.formData.get("_action") === Actions.MOVE_TASK_TO_BUCKET; 56 | 57 | let deleting = 58 | fetcher.submission?.formData.get("_action") === Actions.DELETE_TASK; 59 | 60 | return ( 61 | 62 | 63 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | { 78 | fetcher.submit( 79 | { _action: Actions.CREATE_TASK, id: task.id, name: "" }, 80 | { method: "post", action } 81 | ); 82 | }} 83 | onChange={(value) => { 84 | fetcher.submit( 85 | { _action: Actions.UPDATE_TASK_NAME, id: task.id, name: value }, 86 | { method: "post", action } 87 | ); 88 | }} 89 | onDelete={() => { 90 | fetcher.submit( 91 | { _action: Actions.DELETE_TASK, id: task.id }, 92 | { method: "post", action } 93 | ); 94 | }} 95 | /> 96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /app/components/use-parent-data.ts: -------------------------------------------------------------------------------- 1 | import { useMatches } from "remix"; 2 | 3 | // TODO: Decide if I love or hate this 4 | export function useParentData() { 5 | return useMatches().slice(-2)[0].data as T; 6 | } 7 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | ReactDOM.hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOMServer from "react-dom/server"; 2 | import type { EntryContext } from "remix"; 3 | import { RemixServer } from "remix"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = ReactDOMServer.renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /app/models/bucket.ts: -------------------------------------------------------------------------------- 1 | import { db } from "~/models/db.server"; 2 | import slugify from "slugify"; 3 | 4 | export async function getBucketWithTasksBySlug(userId: string, slug: string) { 5 | return db.bucket.findFirst({ 6 | where: { userId, slug }, 7 | include: { tasks: true }, 8 | }); 9 | } 10 | 11 | export async function getBucket(id: string) { 12 | return db.bucket.findFirst({ where: { id } }); 13 | } 14 | 15 | export async function deleteBucket(id: string) { 16 | return db.bucket.delete({ where: { id } }); 17 | } 18 | 19 | export async function getBuckets(userId: string) { 20 | return db.bucket.findMany({ 21 | where: { userId }, 22 | orderBy: { updatedAt: "asc" }, 23 | }); 24 | } 25 | 26 | export async function createBucket( 27 | userId: string, 28 | id: string, 29 | name: string = "" 30 | ) { 31 | return db.bucket.create({ 32 | data: { id, userId, name, slug: slugify(name, { lower: true }) }, 33 | }); 34 | } 35 | 36 | export async function updateBucketName(id: string, name: string) { 37 | return db.bucket.update({ 38 | where: { id }, 39 | data: { name, slug: slugify(name, { lower: true }) }, 40 | }); 41 | } 42 | 43 | export function getRecentBucket(userId: string) { 44 | return db.bucket.findFirst({ 45 | where: { userId }, 46 | orderBy: { updatedAt: "desc" }, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /app/models/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let db: PrismaClient; 4 | 5 | declare global { 6 | var db: PrismaClient; 7 | } 8 | 9 | if (process.env.NODE_ENV === "production") { 10 | db = new PrismaClient(); 11 | } else { 12 | if (!global.db) { 13 | global.db = new PrismaClient(); 14 | } 15 | db = global.db; 16 | db.$connect(); 17 | } 18 | 19 | export { db }; 20 | -------------------------------------------------------------------------------- /app/models/task.ts: -------------------------------------------------------------------------------- 1 | import { db } from "~/models/db.server"; 2 | import invariant from "tiny-invariant"; 3 | import { formatParamDate } from "~/util/date"; 4 | import { Task } from "@prisma/client"; 5 | 6 | export function getBucketTasks(userId: string, slug: string) { 7 | return db.bucket.findMany({ 8 | where: { userId, slug }, 9 | orderBy: { updatedAt: "asc" }, 10 | }); 11 | } 12 | 13 | export function getUnassignedTasks(userId: string) { 14 | return db.task.findMany({ 15 | where: { userId, bucketId: null }, 16 | orderBy: { sortUpdatedAt: "asc" }, 17 | }); 18 | } 19 | 20 | export function getBacklog(userId: string) { 21 | return db.task.findMany({ 22 | where: { userId, date: null }, 23 | include: { 24 | Bucket: { select: { name: true } }, 25 | }, 26 | }); 27 | } 28 | 29 | export function getDayTasks(userId: string, day: string) { 30 | return db.task.findMany({ 31 | where: { userId, date: day }, 32 | include: { 33 | Bucket: { select: { name: true } }, 34 | }, 35 | }); 36 | } 37 | 38 | export type CalendarStats = Awaited>; 39 | 40 | export async function getTotalCountsByDate( 41 | userId: string, 42 | start: Date, 43 | end: Date 44 | ) { 45 | let result = await db.task.groupBy({ 46 | by: ["date"], 47 | // TODO: why is this here? 48 | orderBy: { date: "asc" }, 49 | _count: { 50 | date: true, 51 | }, 52 | where: { 53 | userId: userId, 54 | date: { 55 | gt: formatParamDate(start), 56 | lt: formatParamDate(end), 57 | }, 58 | }, 59 | }); 60 | 61 | return result 62 | .map((group) => { 63 | invariant( 64 | group.date, 65 | "expected group.date (being one on one makes me nervous)" 66 | ); 67 | return { 68 | date: group.date, 69 | count: group._count.date, 70 | }; 71 | }) 72 | .reduce((map, stat) => { 73 | map[stat.date] = stat.count; 74 | return map; 75 | }, {} as { [date: string]: number }); 76 | } 77 | 78 | export async function getCompletedCountsByDate( 79 | userId: string, 80 | start: Date, 81 | end: Date 82 | ) { 83 | let result = await db.task.groupBy({ 84 | by: ["date"], 85 | // TODO: why is this here? 86 | orderBy: { date: "asc" }, 87 | _count: { 88 | date: true, 89 | }, 90 | where: { 91 | userId: userId, 92 | complete: true, 93 | date: { 94 | gt: formatParamDate(start), 95 | lt: formatParamDate(end), 96 | }, 97 | }, 98 | }); 99 | 100 | return result 101 | .map((group) => { 102 | invariant( 103 | group.date, 104 | "expected group.date (being one on one makes me nervous)" 105 | ); 106 | return { 107 | date: group.date, 108 | count: group._count.date, 109 | }; 110 | }) 111 | .reduce((map, stat) => { 112 | map[stat.date] = stat.count; 113 | return map; 114 | }, {} as { [date: string]: number }); 115 | } 116 | 117 | export async function getCalendarStats(userId: string, start: Date, end: Date) { 118 | let [total, incomplete] = await Promise.all([ 119 | getTotalCountsByDate(userId, start, end), 120 | getCompletedCountsByDate(userId, start, end), 121 | ]); 122 | 123 | return { total, incomplete }; 124 | } 125 | 126 | export function markComplete(id: string) { 127 | return db.task.update({ 128 | where: { id }, 129 | data: { complete: true }, 130 | }); 131 | } 132 | 133 | export function createOrUpdateTask( 134 | userId: string, 135 | id: string, 136 | data: Partial 137 | ) { 138 | let name = data.name || ""; 139 | return db.task.upsert({ 140 | where: { id }, 141 | create: { userId, ...data, id, name }, 142 | update: { ...data, id, name }, 143 | }); 144 | } 145 | 146 | export function markIncomplete(id: string) { 147 | return db.task.update({ 148 | where: { id }, 149 | data: { complete: false }, 150 | }); 151 | } 152 | 153 | export function addDate(id: string, date: string) { 154 | return db.task.update({ 155 | where: { id }, 156 | data: { date, sortUpdatedAt: new Date() }, 157 | }); 158 | } 159 | 160 | export function removeDate(id: string) { 161 | return db.task.update({ 162 | where: { id }, 163 | data: { date: null, sortUpdatedAt: new Date() }, 164 | }); 165 | } 166 | 167 | export function unassignTask(id: string) { 168 | return db.task.update({ 169 | where: { id }, 170 | data: { bucketId: null, sortUpdatedAt: new Date() }, 171 | }); 172 | } 173 | 174 | export function assignTask(id: string, bucketId: string) { 175 | return db.task.update({ 176 | where: { id }, 177 | data: { bucketId, sortUpdatedAt: new Date() }, 178 | }); 179 | } 180 | 181 | export function deleteTask(id: string) { 182 | return db.task.delete({ where: { id } }); 183 | } 184 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Meta, 3 | Links, 4 | Scripts, 5 | LiveReload, 6 | Outlet, 7 | useLoaderData, 8 | NavLink, 9 | useTransition, 10 | useLocation, 11 | } from "remix"; 12 | import type { LoaderFunction } from "remix"; 13 | import ringStyles from "react-circular-progressbar/dist/styles.css"; 14 | import { getAuthSession } from "~/util/auth.server"; 15 | import styles from "~/tailwind.css"; 16 | import { ArchiveIcon, CalendarIcon, LogoutIcon } from "./components/icons"; 17 | 18 | export function links() { 19 | return [ 20 | { rel: "stylesheet", href: styles }, 21 | { rel: "stylesheet", href: ringStyles }, 22 | ]; 23 | } 24 | 25 | export function meta() { 26 | return { title: "Ryan's Planner" }; 27 | } 28 | 29 | export let loader: LoaderFunction = async ({ request }) => { 30 | let session = await getAuthSession(request); 31 | return { authenticated: Boolean(session) }; 32 | }; 33 | 34 | export default function Root() { 35 | let { authenticated } = useLoaderData(); 36 | let transition = useTransition(); 37 | let location = useLocation(); 38 | let changingPages = 39 | transition.location && 40 | transition.location.pathname.split("/")[1] !== 41 | location.pathname.split("/")[1]; 42 | 43 | return ( 44 | 45 | 46 | 47 | 48 | 52 | 53 | 54 | 55 | 56 | {authenticated ? ( 57 |
58 | 72 |
79 | 80 |
81 |
82 | ) : ( 83 | 84 | )} 85 | 86 | {process.env.NODE_ENV === "development" && } 87 | 88 | 89 | ); 90 | } 91 | 92 | function PrimaryNavLink({ 93 | to, 94 | children, 95 | }: { 96 | to: string; 97 | children: React.ReactNode; 98 | }) { 99 | return ( 100 | 104 | isActive ? "text-white" : "focus:text-gray-100" 105 | } 106 | /> 107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /app/routes/auth/logout.tsx: -------------------------------------------------------------------------------- 1 | import { Link, redirect } from "remix"; 2 | export { logoutAction as action } from "~/util/auth.server"; 3 | 4 | // FIXME: if you redirect here it doesn't reload all data, it acts like a normal 5 | // data diff! 6 | // export function loader() { 7 | // return redirect("/"); 8 | // } 9 | 10 | export default function Logout() { 11 | return ( 12 |
13 |

You've been logged out.

14 |

15 | Go home 16 |

17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/routes/auth/validate.tsx: -------------------------------------------------------------------------------- 1 | export { validateMagicLinkLoader as loader } from "~/util/auth.server"; 2 | 3 | // FIXME: shouldn't need this 4 | export default function Validate() { 5 | return null; 6 | } 7 | -------------------------------------------------------------------------------- /app/routes/buckets.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Outlet, 3 | json, 4 | useLoaderData, 5 | useFetcher, 6 | useLocation, 7 | useFormAction, 8 | NavLink, 9 | useParams, 10 | useMatches, 11 | } from "remix"; 12 | import type { LoaderFunction } from "remix"; 13 | import { requireUserId } from "~/util/auth.server"; 14 | import { getUnassignedTasks } from "~/models/task"; 15 | import { CACHE_CONTROL } from "~/util/http"; 16 | import { 17 | HScrollChild, 18 | HScrollContent, 19 | SidebarLayout, 20 | SidebarNav, 21 | } from "~/components/layouts"; 22 | import { UnassignedTaskList } from "~/components/tasks/unassigned"; 23 | import * as BucketModel from "~/models/bucket"; 24 | import { Actions } from "~/actions/actions"; 25 | import { 26 | ContentEditableField, 27 | EditableItem, 28 | EditableList, 29 | Header, 30 | } from "~/components/editable-list"; 31 | import { Bucket, Task } from "@prisma/client"; 32 | 33 | // FIXME: https://github.com/remix-run/remix/issues/1291 34 | export { handleTaskAction as action } from "~/actions/actions.server"; 35 | 36 | type BucketsLoaderData = { 37 | unassigned: Awaited>; 38 | buckets: Awaited>; 39 | }; 40 | 41 | export let loader: LoaderFunction = async ({ request }) => { 42 | let userId = await requireUserId(request); 43 | let [unassigned, buckets] = await Promise.all([ 44 | getUnassignedTasks(userId), 45 | BucketModel.getBuckets(userId), 46 | ]); 47 | return json( 48 | { unassigned, buckets }, 49 | { 50 | headers: { "Cache-Control": CACHE_CONTROL.none }, 51 | } 52 | ); 53 | }; 54 | 55 | export default function Buckets() { 56 | let { unassigned, buckets } = useLoaderData(); 57 | 58 | // FIXME: Gonna move this rendering over to the bucketSlug like tasks/$day 59 | let bucket = useMatches().slice(-1)[0].data; 60 | let tasks = bucket.tasks as unknown as Task[]; 61 | let bucketId = bucket.id; 62 | 63 | return ( 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 78 | 79 | 80 | 81 | ); 82 | } 83 | 84 | function BucketList({ buckets }: { buckets: BucketsLoaderData["buckets"] }) { 85 | return ( 86 |
87 |
Buckets (WIP)
88 | } 92 | /> 93 |
94 | ); 95 | } 96 | 97 | export type NewBucket = { 98 | id: string; 99 | name: string; 100 | isNew?: boolean; 101 | }; 102 | 103 | export type RenderedBucket = Bucket | NewBucket; 104 | 105 | function BucketItem({ bucket: bucket }: { bucket: RenderedBucket }) { 106 | let params = useParams(); 107 | let fetcher = useFetcher(); 108 | let action = useFormAction(); 109 | let location = useLocation(); 110 | 111 | let deleting = 112 | fetcher.submission?.formData.get("_action") === Actions.DELETE_BUCKET; 113 | 114 | return ( 115 | 118 | // TODO: these styles are dumb because the abstractions in the wrong 119 | // place need to move the styles out of EditableField, actually 120 | // starting to think it's silly to be re-using this list for the 121 | // projects, it's not as similar as I was anticipating! 122 | "w-full" + " " + (isActive ? "bg-pink-500 text-white" : "") 123 | } 124 | > 125 | 126 | { 130 | fetcher.submit( 131 | { 132 | _action: Actions.CREATE_BUCKET, 133 | id: bucket.id, 134 | name: "", 135 | }, 136 | { method: "post", action } 137 | ); 138 | }} 139 | onChange={(value) => { 140 | fetcher.submit( 141 | { 142 | _action: Actions.UPDATE_BUCKET_NAME, 143 | id: bucket.id, 144 | slug: params.bucketSlug || "", 145 | name: value.trim(), 146 | }, 147 | { method: "post", action } 148 | ); 149 | }} 150 | onDelete={() => { 151 | fetcher.submit( 152 | { _action: Actions.DELETE_BUCKET, id: bucket.id }, 153 | { method: "post", action } 154 | ); 155 | }} 156 | /> 157 | 158 | 159 | ); 160 | } 161 | 162 | // TODO: make generic with isNewTask 163 | export function isNewBucket(bucket: any): bucket is NewBucket { 164 | return bucket && typeof bucket.id === "string" && bucket.isNew; 165 | } 166 | -------------------------------------------------------------------------------- /app/routes/buckets/$bucketSlug.tsx: -------------------------------------------------------------------------------- 1 | import { json, LoaderFunction, useLoaderData } from "remix"; 2 | import invariant from "tiny-invariant"; 3 | import { requireUserId } from "~/util/auth.server"; 4 | import { CACHE_CONTROL } from "~/util/http"; 5 | import * as BucketModel from "~/models/bucket"; 6 | import { BucketTaskList } from "~/components/tasks/bucket"; 7 | import { useParentData } from "~/components/use-parent-data"; 8 | import { Task } from "@prisma/client"; 9 | 10 | export { handleTaskAction as action } from "~/actions/actions.server"; 11 | 12 | type BucketWithTasks = Awaited< 13 | ReturnType 14 | >; 15 | 16 | export let loader: LoaderFunction = async ({ request, params }) => { 17 | invariant(params.bucketSlug, "Expected params.bucketSlug"); 18 | 19 | let userId = await requireUserId(request); 20 | let bucket = await BucketModel.getBucketWithTasksBySlug( 21 | userId, 22 | params.bucketSlug 23 | ); 24 | 25 | return json(bucket, { 26 | headers: { "Cache-Control": CACHE_CONTROL.none }, 27 | }); 28 | }; 29 | 30 | export default function Bucket() { 31 | let { unassigned } = useParentData<{ unassigned: Task[] }>(); 32 | let bucket = useLoaderData(); 33 | invariant(bucket, "expected bucket"); 34 | 35 | return ( 36 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/routes/buckets/index.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction, redirect } from "remix"; 2 | import { requireUserId } from "~/util/auth.server"; 3 | import { createBucket, getRecentBucket } from "~/models/bucket"; 4 | import cuid from "cuid"; 5 | 6 | // FIXME: https://github.com/remix-run/remix/issues/1291 7 | export { handleTaskAction as action } from "~/actions/actions.server"; 8 | 9 | export let loader: LoaderFunction = async ({ request }) => { 10 | let userId = await requireUserId(request); 11 | let latest = await getRecentBucket(userId); 12 | if (latest) { 13 | return redirect(`/buckets/${latest.slug}`); 14 | } else { 15 | let bucket = await createBucket(userId, cuid(), "Family"); 16 | return redirect(`/buckets/${bucket.slug}`); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /app/routes/calendar.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, redirect, json } from "remix"; 2 | import type { LoaderFunction } from "remix"; 3 | import { requireAuthSession } from "~/util/auth.server"; 4 | import { CalendarStats, getBacklog, getCalendarStats } from "~/models/task"; 5 | import { format } from "date-fns"; 6 | import { CACHE_CONTROL } from "~/util/http"; 7 | import { getCalendarWeeks } from "~/util/date"; 8 | import { Task } from "@prisma/client"; 9 | 10 | export type CalendarLoaderData = { 11 | backlog: Task[]; 12 | stats: CalendarStats; 13 | weeks: Array>; 14 | }; 15 | 16 | export let loader: LoaderFunction = async ({ request, params }) => { 17 | if (!params.day) { 18 | let today = new Date(); 19 | return redirect(`/calendar/${format(today, "yyyy-MM-dd")}`); 20 | } 21 | 22 | let session = await requireAuthSession(request); 23 | let userId = session.get("id"); 24 | 25 | // FIXME: need timezone offset of user to show what I mean to 26 | let date = new Date(); 27 | let weeks = getCalendarWeeks(date); 28 | let start = new Date(weeks[0][0]); 29 | let end = new Date(weeks.slice(-1)[0].slice(-1)[0]); 30 | 31 | let [backlog, stats] = await Promise.all([ 32 | getBacklog(userId), 33 | getCalendarStats(userId, start, end), 34 | ]); 35 | 36 | let data: CalendarLoaderData = { backlog, stats, weeks }; 37 | return json(data, { 38 | headers: { "Cache-Control": CACHE_CONTROL.none }, 39 | }); 40 | }; 41 | 42 | export default function Calendar() { 43 | return ; 44 | } 45 | -------------------------------------------------------------------------------- /app/routes/calendar/$day.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction, useLocation, useTransition } from "remix"; 2 | import type { CalendarLoaderData } from "../calendar"; 3 | 4 | import { useLoaderData, useParams, json } from "remix"; 5 | import invariant from "tiny-invariant"; 6 | 7 | import { handleTaskAction } from "~/actions/actions.server"; 8 | import { requireUserId } from "~/util/auth.server"; 9 | import * as Task from "~/models/task"; 10 | import { CACHE_CONTROL } from "~/util/http"; 11 | 12 | import { useParentData } from "~/components/use-parent-data"; 13 | import { 14 | HScrollChild, 15 | HScrollContent, 16 | SidebarLayout, 17 | SidebarNav, 18 | } from "~/components/layouts"; 19 | import { DayTaskList } from "~/components/tasks/day"; 20 | import { BacklogTaskList } from "~/components/tasks/backlog"; 21 | import { Calendar } from "~/components/tasks/calendar"; 22 | 23 | //////////////////////////////////////////////////////////////////////////////// 24 | type DayTasks = Awaited>; 25 | 26 | export let loader: LoaderFunction = async ({ request, params }) => { 27 | invariant(params.day, "Expected params.day"); 28 | 29 | let userId = await requireUserId(request); 30 | let tasks = await Task.getDayTasks(userId, params.day); 31 | 32 | return json(tasks, { 33 | headers: { "Cache-Control": CACHE_CONTROL.safePrefetch }, 34 | }); 35 | }; 36 | 37 | //////////////////////////////////////////////////////////////////////////////// 38 | export { handleTaskAction as action }; 39 | 40 | //////////////////////////////////////////////////////////////////////////////// 41 | export default function DayRoute() { 42 | let params = useParams<"day">(); 43 | invariant(params.day, "expected params.day"); 44 | 45 | let tasks = useLoaderData(); 46 | let { backlog, weeks, stats } = useParentData(); 47 | let location = useLocation(); 48 | let transition = useTransition(); 49 | let changingDays = 50 | transition.location && 51 | transition.location.pathname.split("/").slice(-1)[0] !== 52 | location.pathname.split("/").slice(-1)[0]; 53 | 54 | return ( 55 | 56 | 57 | 58 | 59 | 60 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction, redirect } from "remix"; 2 | import { requireAuthSession } from "~/util/auth.server"; 3 | 4 | export let loader: LoaderFunction = async ({ request }) => { 5 | await requireAuthSession(request); 6 | return redirect("/calendar"); 7 | }; 8 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Form, useActionData, useLoaderData, useTransition } from "remix"; 3 | import { Button, TextInput } from "~/components/forms"; 4 | export { 5 | loginAction as action, 6 | loginLoader as loader, 7 | } from "~/util/auth.server"; 8 | 9 | export default function Index() { 10 | let loaderData = useLoaderData(); 11 | let actionData = useActionData(); 12 | let transition = useTransition(); 13 | 14 | return ( 15 |
16 |
17 |
18 |

Planner

19 |
Get your 💩 together
20 |
21 |
22 |
23 | {actionData === "ok" ? ( 24 |

28 | Check your email to log in! 29 |

30 | ) : ( 31 |
32 | 37 | 45 | 56 | 57 | )} 58 |
59 | 60 |
61 |
62 |
63 | ); 64 | } 65 | 66 | function LoadingDots() { 67 | let [n, setN] = useState(1); 68 | 69 | useEffect(() => { 70 | let id = setTimeout(() => setN(n + 1), 250); 71 | return () => clearTimeout(id); 72 | }, [n]); 73 | 74 | return {Array.from({ length: n % 4 }).fill(".")}; 75 | } 76 | -------------------------------------------------------------------------------- /app/util/auth.server.tsx: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { renderToStaticMarkup } from "react-dom/server"; 3 | import createMailgun from "mailgun-js"; 4 | import type { ActionFunction, LoaderFunction, Session } from "remix"; 5 | import { createCookieSessionStorage, json, redirect } from "remix"; 6 | import { ensureUserAccount } from "./user.server"; 7 | 8 | /******************************************************************************* 9 | * Before we can do anything, we need to make sure the environment has 10 | * everything we need. If anything is missing, we just prevent the app from 11 | * starting up. 12 | */ 13 | if (typeof process.env.ORIGIN !== "string") 14 | throw new Error("Missing `process.env.ORIGIN`"); 15 | 16 | if (typeof process.env.SESSION_SECRET !== "string") 17 | throw new Error("Missing `process.env.SESSION_SECRET`"); 18 | 19 | if (typeof process.env.MAGIC_LINK_SALT !== "string") 20 | throw new Error("Missing `process.env.MAGIC_LINK_SALT`"); 21 | 22 | if (typeof process.env.MAILGUN_KEY !== "string") 23 | throw new Error("Missing process.env.MAILGUN_KEY"); 24 | 25 | if (typeof process.env.MAILGUN_DOMAIN !== "string") 26 | throw new Error("Missing `process.env.MAILGUN_DOMAIN`"); 27 | 28 | /******************************************************************************* 29 | * 1. It all starts with a "user session". A session is a fancy type of cookie 30 | * that references data either in the cookie directly or in some other storage 31 | * like a database (and the cookie holds value that can access the other 32 | * storage). In our case we're going to keep the data in the cookie itself since 33 | * we don't know what kind of database you've got. 34 | */ 35 | let authSession = createCookieSessionStorage({ 36 | cookie: { 37 | secrets: [process.env.SESSION_SECRET], 38 | path: "/", 39 | sameSite: "lax", 40 | }, 41 | }); 42 | 43 | let sessionMaxAge = 604800 * 52; 44 | 45 | /******************************************************************************* 46 | * 2. The whole point of authentication is to make sure we have a valid user 47 | * before showing them some pages. This function protects pages from 48 | * unauthenticated users. You call this from any loader/action that needs 49 | * authentication. 50 | * 51 | * This function will return the user session (with a way to refresh it, we'll 52 | * talk about that when you get to (7)). If there isn't a session, it redirects 53 | * to the "/login" route by throwing a redirect response. 54 | * 55 | * Because you can `throw` a response in Remix, your loaders and actions don't 56 | * have to worry about doing the redirects themselves. Code in the loader will 57 | * stop executing and this function peforms a redirect right here. 58 | * 59 | * 6. All future requests to loaders/actions that require a user session will 60 | * call this function and they'll get the session instead of a login redirect. 61 | * Sessions are stored with cookies which have a "max age" value. This is how 62 | * long you want the browser to hang on to the cookie. The `refresh` function 63 | * allows loaders and actions to "refresh" the max age so it's always "since the 64 | * user last used it". If we didn't refresh, then sessions would always expire 65 | * even if the user is on your site every day. 66 | */ 67 | export async function requireAuthSession(request: Request): Promise { 68 | let auth = await getAuthSession(request); 69 | 70 | if (!auth) { 71 | throw redirect("/login", { 72 | status: 303, 73 | headers: { 74 | "auth-redirect": getReferrer(request), 75 | }, 76 | }); 77 | } 78 | 79 | return auth; 80 | } 81 | 82 | export async function getAuthSession( 83 | request: Request 84 | ): Promise { 85 | let cookie = request.headers.get("cookie"); 86 | let session = await authSession.getSession(cookie); 87 | 88 | if (!session.has("auth")) { 89 | return null; 90 | } 91 | 92 | // let refresh = async () => 93 | // new Headers({ 94 | // "Set-Cookie": await authSession.commitSession(session, { 95 | // maxAge: sessionMaxAge, 96 | // }), 97 | // }); 98 | 99 | return session; 100 | } 101 | 102 | /******************************************************************************* 103 | * 3. The user is redirected to this loader from `getAuthSession` if they 104 | * haven't logged in yet. It renders the route with a "referrer" so the token 105 | * can log them into the right page later. 106 | */ 107 | export let loginLoader: LoaderFunction = async ({ request }) => { 108 | let userId = await getSessionUserId(request); 109 | if (userId) { 110 | return redirect("/"); 111 | } 112 | return json({ landingPage: getReferrer(request) }); 113 | }; 114 | 115 | /******************************************************************************* 116 | * 4. After the user submits the form with their email address, we read the POST 117 | * body from the request, validate it, send the email, and finally render the 118 | * same route again but this time with action data. The UI then tells them to 119 | * check their email. We also set the email into the session so we can be sure 120 | * it's the same person clicking the link. 121 | */ 122 | export let loginAction: ActionFunction = async ({ request }) => { 123 | let body = Object.fromEntries(new URLSearchParams(await request.text())); 124 | 125 | if (typeof body.email !== "string" || body.email.indexOf("@") === -1) { 126 | throw json("Missing email", { status: 400 }); 127 | } 128 | 129 | if (typeof body.landingPage !== "string") { 130 | throw json("Missing landing page", { status: 400 }); 131 | } 132 | 133 | let cookie = request.headers.get("cookie"); 134 | let session = await authSession.getSession(cookie); 135 | // make a token out of anything, probably should use UUID 136 | let token = encrypt(body.email); 137 | session.set("token", token); 138 | 139 | await sendMagicLinkEmail(body.email, body.landingPage, token); 140 | 141 | return json("ok", { 142 | headers: { 143 | "Set-Cookie": await authSession.commitSession(session), 144 | }, 145 | }); 146 | }; 147 | 148 | /******************************************************************************* 149 | * 5. When the user clicks the link in their email we validate the token. If 150 | * it's valid, we set "auth" in the session as we redirect to the landing page. 151 | * We've got a user session! If it's invalid the user will get a 400 error. 152 | * 153 | * You might also do some work with your database here, like create a user 154 | * record. 155 | * 156 | * Now all future requests will have a user session, so go back to (6). 157 | */ 158 | export let validateMagicLinkLoader: LoaderFunction = async ({ request }) => { 159 | let magicToken = getMagicToken(request); 160 | 161 | if (typeof magicToken !== "string") { 162 | throw invalidLink(); 163 | } 164 | 165 | let cookie = request.headers.get("Cookie"); 166 | let session = await authSession.getSession(cookie); 167 | 168 | let magicLinkPayload = getMagicLink(magicToken); 169 | // make sure it came from the device that sent the link 170 | if (magicLinkPayload.token !== session.get("token")) { 171 | throw new Response("", { status: 401, statusText: "Not Authorized" }); 172 | } 173 | 174 | // might want to create user in the db 175 | // might want to create a db session instead of a cookie session 176 | // might set the user.id or session.id from a db instead of email 177 | if (session) session.set("auth", magicLinkPayload.email); 178 | 179 | let user = await ensureUserAccount(magicLinkPayload.email); 180 | session.set("userId", user.id); 181 | 182 | return redirect(magicLinkPayload.landingPage, { 183 | headers: { 184 | "Set-Cookie": await authSession.commitSession(session, { 185 | maxAge: sessionMaxAge, 186 | }), 187 | }, 188 | }); 189 | }; 190 | 191 | /******************************************************************************* 192 | * When the user clicks the logout button we call this action and destroy the 193 | * session. 194 | */ 195 | export let logoutAction: ActionFunction = async ({ request }) => { 196 | let session = await authSession.getSession(); 197 | return redirect("/", { 198 | headers: { 199 | "Set-Cookie": await authSession.destroySession(session), 200 | }, 201 | }); 202 | }; 203 | 204 | //////////////////////////////////////////////////////////////////////////////// 205 | function getMagicToken(request: Request) { 206 | let { searchParams } = new URL(request.url); 207 | return searchParams.get(magicLinkSearchParam); 208 | } 209 | 210 | function getMagicLink(magicToken: string) { 211 | try { 212 | return validateMagicLink(magicToken); 213 | } catch (e) { 214 | throw invalidLink(); 215 | } 216 | } 217 | 218 | function invalidLink() { 219 | return json("Invalid magic link", { status: 400 }); 220 | } 221 | 222 | function getReferrer(request: Request) { 223 | // This doesn't work with all remix adapters yet, so pick a good default 224 | let referrer = request.referrer; 225 | if (referrer) { 226 | let url = new URL(referrer); 227 | return url.pathname + url.search; 228 | } 229 | return "/"; 230 | } 231 | 232 | let magicLinkSearchParam = "magic"; 233 | let linkExpirationTime = 1000 * 60 * 30; 234 | let algorithm = "aes-256-ctr"; 235 | let ivLength = 16; 236 | 237 | let encryptionKey = crypto.scryptSync(process.env.MAGIC_LINK_SALT, "salt", 32); 238 | 239 | function encrypt(text: string) { 240 | let iv = crypto.randomBytes(ivLength); 241 | let cipher = crypto.createCipheriv(algorithm, encryptionKey, iv); 242 | let encrypted = Buffer.concat([cipher.update(text), cipher.final()]); 243 | return `${iv.toString("hex")}:${encrypted.toString("hex")}`; 244 | } 245 | 246 | function decrypt(text: string) { 247 | let [ivPart, encryptedPart] = text.split(":"); 248 | if (!ivPart || !encryptedPart) { 249 | throw new Error("Invalid text."); 250 | } 251 | 252 | let iv = Buffer.from(ivPart, "hex"); 253 | let encryptedText = Buffer.from(encryptedPart, "hex"); 254 | let decipher = crypto.createDecipheriv(algorithm, encryptionKey, iv); 255 | let decrypted = Buffer.concat([ 256 | decipher.update(encryptedText), 257 | decipher.final(), 258 | ]); 259 | return decrypted.toString(); 260 | } 261 | 262 | type MagicLinkPayload = { 263 | email: string; 264 | landingPage: string; 265 | creationDate: string; 266 | token: string; 267 | }; 268 | 269 | function generateMagicLink(email: string, landingPage: string, token: string) { 270 | let payload: MagicLinkPayload = { 271 | email, 272 | landingPage, 273 | creationDate: new Date().toISOString(), 274 | token, 275 | }; 276 | let stringToEncrypt = JSON.stringify(payload); 277 | let encryptedString = encrypt(stringToEncrypt); 278 | let url = new URL(process.env.ORIGIN as string); 279 | url.pathname = "/auth/validate"; 280 | url.searchParams.set(magicLinkSearchParam, encryptedString); 281 | return url.toString(); 282 | } 283 | 284 | function isMagicLinkPayload(obj: any): obj is MagicLinkPayload { 285 | return ( 286 | typeof obj === "object" && 287 | typeof obj.email === "string" && 288 | typeof obj.landingPage === "string" && 289 | typeof obj.creationDate === "string" 290 | ); 291 | } 292 | 293 | function validateMagicLink(link: string) { 294 | let decryptedString = decrypt(link); 295 | let payload = JSON.parse(decryptedString); 296 | 297 | if (!isMagicLinkPayload(payload)) { 298 | throw invalidLink(); 299 | } 300 | 301 | let linkCreationDate = new Date(payload.creationDate); 302 | let expirationTime = linkCreationDate.getTime() + linkExpirationTime; 303 | 304 | if (Date.now() > expirationTime) { 305 | throw invalidLink(); 306 | } 307 | 308 | return payload; 309 | } 310 | 311 | /******************************************************************************* 312 | * Email handled by mailgun 313 | */ 314 | let mailgun = createMailgun({ 315 | apiKey: process.env.MAILGUN_KEY, 316 | domain: process.env.MAILGUN_DOMAIN, 317 | }); 318 | 319 | async function sendMagicLinkEmail( 320 | email: string, 321 | landingPage: string, 322 | token: string 323 | ) { 324 | let link = generateMagicLink(email, landingPage, token); 325 | 326 | let html = renderToStaticMarkup( 327 | <> 328 |

Log in to Ryan's Planner.

329 |

330 | (This email uses React to render the email on the server, that's cool) 331 |

332 |

333 | Click here to be logged in. 334 |

335 | 336 | ); 337 | 338 | if (process.env.NODE_ENV === "production") { 339 | return mailgun.messages().send({ 340 | from: "Ryan's Planner ", 341 | to: email, 342 | subject: "Login to Planner!", 343 | html, 344 | }); 345 | } else { 346 | console.log(link); 347 | } 348 | } 349 | 350 | export async function requireUserId(request: Request) { 351 | let session = await requireAuthSession(request); 352 | let userId = session.get("userId"); 353 | if (typeof userId !== "string") throw redirect("/login"); 354 | return userId; 355 | } 356 | 357 | export async function getSessionUserId(request: Request) { 358 | let session = await getAuthSession(request); 359 | return session?.get("userId") || null; 360 | } 361 | -------------------------------------------------------------------------------- /app/util/date.ts: -------------------------------------------------------------------------------- 1 | import { 2 | format, 3 | subWeeks, 4 | addWeeks, 5 | eachWeekOfInterval, 6 | addDays, 7 | parse, 8 | } from "date-fns"; 9 | 10 | export function getCalendarWeeks(date: Date) { 11 | // FIXME: implement user time zones so these aren't all based on the server 12 | let start = subWeeks(date, 4); 13 | let end = addWeeks(date, 12); 14 | let weeks = eachWeekOfInterval({ start, end }, { weekStartsOn: 1 }); 15 | return weeks.map((start) => { 16 | return [0, 1, 2, 3, 4, 5, 6].map((n) => formatParamDate(addDays(start, n))); 17 | }); 18 | } 19 | 20 | const PARAM_FORMAT = "yyyy-MM-dd"; 21 | 22 | export function formatParamDate(date: Date) { 23 | return format(date, PARAM_FORMAT); 24 | } 25 | 26 | export function parseParamDate(paramDate: string) { 27 | let [year, month, day] = paramDate.substring(0, 10).split("-"); 28 | return new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day)); 29 | } 30 | -------------------------------------------------------------------------------- /app/util/http.ts: -------------------------------------------------------------------------------- 1 | import invariant from "tiny-invariant"; 2 | 3 | export const CACHE_CONTROL = { 4 | /** 5 | * max-age=3 6 | * 7 | * Enough time for link prefetching to be effective, but short enough that if 8 | * they hover w/o visiting, we don't cache stale data for a later click 9 | */ 10 | safePrefetch: "max-age=3", 11 | 12 | /** 13 | * "no-cache, max-age=0, must-revalidate" 14 | * 15 | * Don't want this cached even if it gets prefetched 16 | */ 17 | none: "no-cache, max-age=0, must-revalidate", 18 | }; 19 | 20 | export async function parseStringFormData(request: Request) { 21 | let formData = await request.formData(); 22 | let obj: { [key: string]: string | undefined } = {}; 23 | for (let [key, val] of formData.entries()) { 24 | invariant(typeof val === "string", `expected string in for ${key}`); 25 | obj[key] = val; 26 | } 27 | return obj; 28 | } 29 | -------------------------------------------------------------------------------- /app/util/user.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "remix"; 2 | import { db } from "~/models/db.server"; 3 | 4 | export async function requireUser(email: string) { 5 | let user = await db.user.findUnique({ where: { email } }); 6 | 7 | if (!user) { 8 | throw redirect("/login"); 9 | } 10 | 11 | return user; 12 | } 13 | 14 | export async function ensureUserAccount(email: string) { 15 | let user = await db.user.findUnique({ where: { email } }); 16 | 17 | if (user) { 18 | return user; 19 | } 20 | 21 | return db.user.create({ data: { email } }); 22 | } 23 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for planner on 2021-12-14T08:49:39-07:00 2 | 3 | app = "planner" 4 | 5 | kill_signal = "SIGINT" 6 | kill_timeout = 5 7 | processes = [] 8 | 9 | [build] 10 | builder = "heroku/buildpacks:20" 11 | 12 | [env] 13 | PORT = "8080" 14 | NODE_ENV="production" 15 | 16 | [deploy] 17 | release_command = "npx prisma migrate deploy" 18 | 19 | [experimental] 20 | allowed_public_ports = [] 21 | auto_rollback = true 22 | 23 | [[services]] 24 | http_checks = [] 25 | internal_port = 8080 26 | processes = ["app"] 27 | protocol = "tcp" 28 | script_checks = [] 29 | 30 | [services.concurrency] 31 | hard_limit = 25 32 | soft_limit = 20 33 | type = "connections" 34 | 35 | [[services.ports]] 36 | handlers = ["http"] 37 | port = 80 38 | 39 | [[services.ports]] 40 | handlers = ["tls", "http"] 41 | port = 443 42 | 43 | [[services.tcp_checks]] 44 | grace_period = "1s" 45 | interval = "15s" 46 | restart_limit = 0 47 | timeout = "2s" 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "remix-app-template", 4 | "description": "", 5 | "license": "", 6 | "scripts": { 7 | "postinstall": "remix setup", 8 | "dev": "concurrently \"npm run dev:css\" \"node -r dotenv/config node_modules/.bin/remix dev\"", 9 | "start": "remix-serve build", 10 | "build": "npm run build:css && remix build", 11 | "build:css": "tailwindcss -o ./app/tailwind.css", 12 | "dev:css": "tailwindcss -o ./app/tailwind.css --watch", 13 | "deploy": "fly deploy" 14 | }, 15 | "dependencies": { 16 | "@prisma/client": "^3.6.0", 17 | "@remix-run/react": "1.3.4", 18 | "@remix-run/serve": "1.3.4", 19 | "cuid": "^2.1.8", 20 | "date-fns": "^2.27.0", 21 | "dotenv": "^10.0.0", 22 | "lodash.debounce": "^4.0.8", 23 | "mailgun-js": "^0.22.0", 24 | "prisma": "^3.6.0", 25 | "react": "^17.0.1", 26 | "react-circular-progressbar": "^2.0.4", 27 | "react-dom": "^17.0.1", 28 | "remix": "1.3.4", 29 | "slugify": "^1.6.4", 30 | "sort-by": "^1.2.0", 31 | "tailwindcss-neumorphism": "^0.1.0", 32 | "tiny-invariant": "^1.2.0", 33 | "tailwindcss": "^2.2.19" 34 | }, 35 | "devDependencies": { 36 | "@remix-run/dev": "1.3.4", 37 | "@types/mailgun-js": "^0.22.12", 38 | "@types/react": "^17.0.4", 39 | "@types/react-dom": "^17.0.4", 40 | "concurrently": "^6.4.0", 41 | "typescript": "^4.1.2" 42 | }, 43 | "engines": { 44 | "node": "16" 45 | }, 46 | "sideEffects": false, 47 | "prettier": {} 48 | } 49 | -------------------------------------------------------------------------------- /prisma/migrations/20211213204911_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "email" TEXT NOT NULL, 5 | 6 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 7 | ); 8 | 9 | -- CreateTable 10 | CREATE TABLE "Bucket" ( 11 | "id" TEXT NOT NULL, 12 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | "updatedAt" TIMESTAMP(3) NOT NULL, 14 | "name" TEXT NOT NULL, 15 | "slug" TEXT NOT NULL, 16 | "userId" TEXT NOT NULL, 17 | 18 | CONSTRAINT "Bucket_pkey" PRIMARY KEY ("id") 19 | ); 20 | 21 | -- CreateTable 22 | CREATE TABLE "Task" ( 23 | "id" TEXT NOT NULL, 24 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 25 | "updatedAt" TIMESTAMP(3) NOT NULL, 26 | "name" TEXT NOT NULL, 27 | "complete" BOOLEAN NOT NULL DEFAULT false, 28 | "date" TIMESTAMP(3), 29 | "userId" TEXT NOT NULL, 30 | "bucketId" TEXT, 31 | 32 | CONSTRAINT "Task_pkey" PRIMARY KEY ("id") 33 | ); 34 | 35 | -- CreateTable 36 | CREATE TABLE "Note" ( 37 | "id" TEXT NOT NULL, 38 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 39 | "updatedAt" TIMESTAMP(3) NOT NULL, 40 | "name" TEXT NOT NULL, 41 | "date" TIMESTAMP(3), 42 | "bucketId" TEXT, 43 | 44 | CONSTRAINT "Note_pkey" PRIMARY KEY ("id") 45 | ); 46 | 47 | -- CreateIndex 48 | CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); 49 | 50 | -- CreateIndex 51 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 52 | 53 | -- CreateIndex 54 | CREATE UNIQUE INDEX "Bucket_id_key" ON "Bucket"("id"); 55 | 56 | -- CreateIndex 57 | CREATE UNIQUE INDEX "Task_id_key" ON "Task"("id"); 58 | 59 | -- CreateIndex 60 | CREATE UNIQUE INDEX "Note_id_key" ON "Note"("id"); 61 | 62 | -- AddForeignKey 63 | ALTER TABLE "Bucket" ADD CONSTRAINT "Bucket_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 64 | 65 | -- AddForeignKey 66 | ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 67 | 68 | -- AddForeignKey 69 | ALTER TABLE "Task" ADD CONSTRAINT "Task_bucketId_fkey" FOREIGN KEY ("bucketId") REFERENCES "Bucket"("id") ON DELETE SET NULL ON UPDATE CASCADE; 70 | 71 | -- AddForeignKey 72 | ALTER TABLE "Note" ADD CONSTRAINT "Note_bucketId_fkey" FOREIGN KEY ("bucketId") REFERENCES "Bucket"("id") ON DELETE SET NULL ON UPDATE CASCADE; 73 | -------------------------------------------------------------------------------- /prisma/migrations/20211218150006_change_task_date_to_string/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Task" ALTER COLUMN "date" SET DATA TYPE TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20220117211501_add_sort_updated_at/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Task" ADD COLUMN "sortUpdatedAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // datasource db { 5 | // provider = "sqlite" 6 | // url = env("DATABASE_URL") 7 | // } 8 | 9 | datasource db { 10 | provider = "postgresql" 11 | url = env("DATABASE_URL") 12 | } 13 | 14 | generator client { 15 | provider = "prisma-client-js" 16 | binaryTargets = ["native", "darwin-arm64"] 17 | } 18 | 19 | model User { 20 | id String @id @unique @default(cuid()) 21 | email String @unique 22 | buckets Bucket[] 23 | tasks Task[] 24 | // FIXME: add timezone offset 25 | } 26 | 27 | model Bucket { 28 | id String @id @unique @default(cuid()) 29 | createdAt DateTime @default(now()) 30 | updatedAt DateTime @updatedAt 31 | 32 | name String 33 | slug String 34 | 35 | User User @relation(fields: [userId], references: [id]) 36 | userId String 37 | 38 | tasks Task[] 39 | notes Note[] 40 | } 41 | 42 | model Task { 43 | id String @id @unique @default(cuid()) 44 | createdAt DateTime @default(now()) 45 | updatedAt DateTime @updatedAt 46 | sortUpdatedAt DateTime? @default(now()) 47 | 48 | name String 49 | complete Boolean @default(false) 50 | date String? 51 | 52 | User User @relation(fields: [userId], references: [id]) 53 | userId String 54 | 55 | Bucket Bucket? @relation(fields: [bucketId], references: [id]) 56 | bucketId String? 57 | } 58 | 59 | model Note { 60 | id String @id @unique @default(cuid()) 61 | createdAt DateTime @default(now()) 62 | updatedAt DateTime @updatedAt 63 | 64 | name String 65 | date DateTime? 66 | 67 | Bucket Bucket? @relation(fields: [bucketId], references: [id]) 68 | bucketId String? 69 | } 70 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryanflorence/remix-planner/8b8c18f9d7c3c110dde2c429bdb97606b7af7f3b/public/favicon.png -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev/config').AppConfig} 3 | */ 4 | module.exports = { 5 | appDirectory: "app", 6 | assetBuildDirectory: "public/remix", 7 | publicPath: "/build/", 8 | serverBuildDirectory: "build", 9 | devServerPort: 8002, 10 | }; 11 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright © 2021 React Training LLC. All rights reserved. 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: "jit", 3 | purge: ["./app/**/*.{ts,tsx}"], 4 | darkMode: "media", // or 'media' or 'class' 5 | theme: { 6 | extend: {}, 7 | }, 8 | variants: {}, 9 | plugins: [require("tailwindcss-neumorphism")], 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "esModuleInterop": true, 6 | "jsx": "react-jsx", 7 | "moduleResolution": "node", 8 | "target": "ES2019", 9 | "strict": true, 10 | "paths": { 11 | "~/*": ["./app/*"] 12 | }, 13 | 14 | // Remix takes care of building everything in `remix build`. 15 | "noEmit": true 16 | } 17 | } 18 | --------------------------------------------------------------------------------