diff --git a/heat-stack/.env.example b/heat-stack/.env.example index 78348c0c..d117323c 100644 --- a/heat-stack/.env.example +++ b/heat-stack/.env.example @@ -6,3 +6,9 @@ SESSION_SECRET="super-duper-s3cret" INTERNAL_COMMAND_TOKEN="some-made-up-token" RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" SENTRY_DSN="your-dsn" + +# the mocks and some code rely on these two being prefixed with "MOCK_" +# if they aren't then the real github api will be attempted +GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID" +GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET" +GITHUB_TOKEN="MOCK_GITHUB_TOKEN" diff --git a/heat-stack/.eslintrc.cjs b/heat-stack/.eslintrc.cjs index 02ea66e7..5dae40cd 100644 --- a/heat-stack/.eslintrc.cjs +++ b/heat-stack/.eslintrc.cjs @@ -10,6 +10,8 @@ module.exports = { 'prettier', ], rules: { + // playwright requires destructuring in fixtures even if you don't use anything 🤷‍♂️ + 'no-empty-pattern': 'off', '@typescript-eslint/consistent-type-imports': [ 'warn', { @@ -18,7 +20,22 @@ module.exports = { fixStyle: 'inline-type-imports', }, ], - 'import/no-duplicates': 'warn', + 'import/no-duplicates': ['warn', { 'prefer-inline': true }], + 'import/consistent-type-specifier-style': ['warn', 'prefer-inline'], + 'import/order': [ + 'warn', + { + alphabetize: { order: 'asc', caseInsensitive: true }, + groups: [ + 'builtin', + 'external', + 'internal', + 'parent', + 'sibling', + 'index', + ], + }, + ], }, overrides: [ { diff --git a/heat-stack/.github/workflows/deploy.yml b/heat-stack/.github/workflows/deploy.yml index 98e1a737..8201fa35 100644 --- a/heat-stack/.github/workflows/deploy.yml +++ b/heat-stack/.github/workflows/deploy.yml @@ -111,7 +111,16 @@ jobs: uses: actions/cache@v3 with: path: prisma/data.db - key: db-cache + key: + db-cache-schema_${{ hashFiles('./prisma/schema.prisma') + }}-migrations_${{ hashFiles('./prisma/migrations/*/migration.sql') + }} + + - name: 🌱 Seed Database + if: steps.db-cache.outputs.cache-hit != 'true' + run: npx prisma db seed + env: + MINIMAL_SEED: true - name: 🏗 Build run: npm run build @@ -153,20 +162,20 @@ jobs: mv ./other/Dockerfile ./Dockerfile mv ./other/.dockerignore ./.dockerignore + - name: 🎈 Setup Fly + uses: superfly/flyctl-actions/setup-flyctl@v1.4 + - name: 🚀 Deploy Staging if: ${{ github.ref == 'refs/heads/dev' }} - uses: superfly/flyctl-actions@1.3 - with: - args: - 'deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} --app - ${{ steps.app_name.outputs.value }}-staging' + run: + flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} + --app ${{ steps.app_name.outputs.value }}-staging env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - name: 🚀 Deploy Production if: ${{ github.ref == 'refs/heads/main' }} - uses: superfly/flyctl-actions@1.3 - with: - args: 'deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }}' + run: + flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/heat-stack/.gitignore b/heat-stack/.gitignore index 4ad5c350..f9d5ad84 100644 --- a/heat-stack/.gitignore +++ b/heat-stack/.gitignore @@ -1,5 +1,5 @@ node_modules -.DS_Store +.DS_store /build /public/build diff --git a/heat-stack/.prettierrc.cjs b/heat-stack/.prettierrc.js similarity index 83% rename from heat-stack/.prettierrc.cjs rename to heat-stack/.prettierrc.js index dca7b09d..b0ffa1c7 100644 --- a/heat-stack/.prettierrc.cjs +++ b/heat-stack/.prettierrc.js @@ -1,4 +1,5 @@ -module.exports = { +/** @type {import("prettier").Options} */ +export default { arrowParens: 'avoid', bracketSameLine: false, bracketSpacing: true, @@ -25,5 +26,5 @@ module.exports = { }, }, ], - plugins: [require('prettier-plugin-tailwindcss')], + plugins: ['prettier-plugin-tailwindcss'], } diff --git a/heat-stack/.vscode/extensions.json b/heat-stack/.vscode/extensions.json index 248df137..7619ac2b 100644 --- a/heat-stack/.vscode/extensions.json +++ b/heat-stack/.vscode/extensions.json @@ -1,8 +1,11 @@ { "recommendations": [ + "bradlc.vscode-tailwindcss", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "prisma.prisma", - "bradlc.vscode-tailwindcss" + "qwtel.sqlite-viewer", + "yoavbls.pretty-ts-errors", + "github.vscode-github-actions" ] } diff --git a/heat-stack/.vscode/settings.json b/heat-stack/.vscode/settings.json index eed0b2e4..374cf10c 100644 --- a/heat-stack/.vscode/settings.json +++ b/heat-stack/.vscode/settings.json @@ -4,9 +4,16 @@ "@remix-run/router", "express", "@radix-ui/**", + "@react-email/**", + "react-router-dom", "react-router", "stream/consumers", - "node:stream/consumers" + "node:stream/consumers", + "node:test", + "console", + "node:console" ], - "tailwindCSS.experimental.classRegex": [["cn\\(([^)]*)\\)"]] + "workbench.editorAssociations": { + "*.db": "sqlite-viewer.view" + } } diff --git a/heat-stack/README.md b/heat-stack/README.md index b45b3fef..fabe59d7 100644 --- a/heat-stack/README.md +++ b/heat-stack/README.md @@ -60,7 +60,7 @@ To re-create the patch for py file support in `/patch`, use these [instructions] - `npx patch-package @remix-run/dev` - it should auto-apply any time you do `npm install`, but it may get out of sync with upstream -## Epic Stack docs: +### Epic Stack Docs:

The Epic Stack 🚀

@@ -75,7 +75,7 @@ To re-create the patch for py file support in `/patch`, use these [instructions]
```sh -npx create-remix@latest --typescript --install --template epicweb-dev/epic-stack +npx create-remix@latest --install --template epicweb-dev/epic-stack ``` [![The Epic Stack](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/246885449-1b00286c-aa3d-44b2-9ef2-04f694eb3592.png)](https://www.epicweb.dev/epic-stack) diff --git a/heat-stack/app/components/WeatherExample.tsx b/heat-stack/app/components/WeatherExample.tsx new file mode 100644 index 00000000..2f2b5c48 --- /dev/null +++ b/heat-stack/app/components/WeatherExample.tsx @@ -0,0 +1,56 @@ +import test from "@playwright/test"; +import { json } from "@remix-run/node"; // or cloudflare/deno +import { useLoaderData } from "@remix-run/react"; + +export async function loader() { + const href = 'https://archive-api.open-meteo.com/v1/archive?latitude=52.52&longitude=13.41&daily=temperature_2m_max&timezone=America%2FNew_York&start_date=2022-01-01&end_date=2023-08-30&temperature_unit=fahrenheit'; + const res = await fetch(href); + return json(await res.json()); +} + +export function WeatherExample() { + + const gists = useLoaderData(); + return ( + + ); +} + +// import { useFetcher } from "@remix-run/react"; +// import { useEffect } from "react"; + +// export function WeatherExample() { + +// // 2 separate routes - including one that doesn't load +// // the python to avoid the long restart times. + +// /* +// Historical archival records API: https://archive-api.open-meteo.com/v1/archive?latitude=52.52&longitude=13.41&daily=temperature_2m_max&timezone=America%2FNew_York&start_date=2022-01-01&end_date=2023-08-30&temperature_unit=fahrenheit +// Current week forecast API: https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&daily=temperature_2m_max&timezone=America%2FNew_York&past_days=5&forecast_days=1&temperature_unit=fahrenheit +// */ +// const fetcher = useFetcher(); +// const href = 'https://archive-api.open-meteo.com/v1/archive?latitude=52.52&longitude=13.41&daily=temperature_2m_max&timezone=America%2FNew_York&start_date=2022-01-01&end_date=2023-08-30&temperature_unit=fahrenheit'; + +// // trigger the fetch with these +// // ; + +// // fetcher.load(href) + +// useEffect(() => { +// // fetcher.submit(data, options); +// fetcher.load(href); +// }, []); + +// // // build UI with these +// // fetcher.state; +// // fetcher.formMethod; +// // fetcher.formAction; +// // fetcher.formData; +// // fetcher.formEncType; +// // fetcher.data; +// return ( +//
{ fetcher.data }
+// ) +// } diff --git a/heat-stack/app/components/confetti.tsx b/heat-stack/app/components/confetti.tsx index fb8b9038..69fbecef 100644 --- a/heat-stack/app/components/confetti.tsx +++ b/heat-stack/app/components/confetti.tsx @@ -1,18 +1,15 @@ import { Index as ConfettiShower } from 'confetti-react' import { ClientOnly } from 'remix-utils' -/** - * confetti is a unique random identifier which re-renders the component - */ -export function Confetti({ confetti }: { confetti?: string }) { - if (!confetti) return null +export function Confetti({ id }: { id?: string | null }) { + if (!id) return null return ( {() => ( | null | undefined diff --git a/heat-stack/app/components/search-bar.tsx b/heat-stack/app/components/search-bar.tsx index 71378782..859c52d6 100644 --- a/heat-stack/app/components/search-bar.tsx +++ b/heat-stack/app/components/search-bar.tsx @@ -1,9 +1,9 @@ import { Form, useSearchParams, useSubmit } from '@remix-run/react' +import { useDebounce, useIsPending } from '#app/utils/misc.tsx' import { Icon } from './ui/icon.tsx' import { Input } from './ui/input.tsx' import { Label } from './ui/label.tsx' import { StatusButton } from './ui/status-button.tsx' -import { useDebounce, useIsSubmitting } from '~/utils/misc.tsx' export function SearchBar({ status, @@ -16,7 +16,7 @@ export function SearchBar({ }) { const [searchParams] = useSearchParams() const submit = useSubmit() - const isSubmitting = useIsSubmitting({ + const isSubmitting = useIsPending({ formMethod: 'GET', formAction: '/users', }) diff --git a/heat-stack/app/components/toaster.tsx b/heat-stack/app/components/toaster.tsx new file mode 100644 index 00000000..62125303 --- /dev/null +++ b/heat-stack/app/components/toaster.tsx @@ -0,0 +1,22 @@ +import { useEffect } from 'react' +import { Toaster, toast as showToast } from 'sonner' +import { type Toast } from '#app/utils/toast.server.ts' + +export function EpicToaster({ toast }: { toast?: Toast | null }) { + return ( + <> + + {toast ? : null} + + ) +} + +function ShowToast({ toast }: { toast: Toast }) { + const { id, type, title, description } = toast + useEffect(() => { + setTimeout(() => { + showToast[type](title, { id, description }) + }, 0) + }, [description, id, title, type]) + return null +} diff --git a/heat-stack/app/components/ui/button.tsx b/heat-stack/app/components/ui/button.tsx index bb37bae5..87f29af4 100644 --- a/heat-stack/app/components/ui/button.tsx +++ b/heat-stack/app/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from 'react' import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' +import * as React from 'react' -import { cn } from '~/utils/misc.tsx' +import { cn } from '#app/utils/misc.tsx' const buttonVariants = cva( 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', diff --git a/heat-stack/app/components/ui/checkbox.tsx b/heat-stack/app/components/ui/checkbox.tsx index 7c1761f5..637a7fdd 100644 --- a/heat-stack/app/components/ui/checkbox.tsx +++ b/heat-stack/app/components/ui/checkbox.tsx @@ -1,7 +1,7 @@ -import * as React from 'react' import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import * as React from 'react' -import { cn } from '~/utils/misc.tsx' +import { cn } from '#app/utils/misc.tsx' export type CheckboxProps = Omit< React.ComponentPropsWithoutRef, diff --git a/heat-stack/app/components/ui/dropdown-menu.tsx b/heat-stack/app/components/ui/dropdown-menu.tsx index 3cb7b842..3bb4fe3a 100644 --- a/heat-stack/app/components/ui/dropdown-menu.tsx +++ b/heat-stack/app/components/ui/dropdown-menu.tsx @@ -1,7 +1,7 @@ -import * as React from 'react' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import * as React from 'react' -import { cn } from '~/utils/misc.tsx' +import { cn } from '#app/utils/misc.tsx' const DropdownMenu = DropdownMenuPrimitive.Root diff --git a/heat-stack/app/components/ui/icon.tsx b/heat-stack/app/components/ui/icon.tsx index 7e845902..fb0108fa 100644 --- a/heat-stack/app/components/ui/icon.tsx +++ b/heat-stack/app/components/ui/icon.tsx @@ -1,8 +1,8 @@ import { type SVGProps } from 'react' -import { cn } from '~/utils/misc.tsx' +import { cn } from '#app/utils/misc.tsx' +import { type IconName } from '@/icon-name' import href from './icons/sprite.svg' -import { type IconName } from '@/icon-name' export { href } export { IconName } diff --git a/heat-stack/app/components/ui/icons/name.d.ts b/heat-stack/app/components/ui/icons/name.d.ts index cfdff207..68302432 100644 --- a/heat-stack/app/components/ui/icons/name.d.ts +++ b/heat-stack/app/components/ui/icons/name.d.ts @@ -1,25 +1,30 @@ // This file is generated by npm run build:icons export type IconName = - | "arrow-left" - | "arrow-right" - | "avatar" - | "camera" - | "check" - | "clock" - | "cross-1" - | "envelope-closed" - | "exit" - | "file-text" - | "laptop" - | "lock-closed" - | "lock-open-1" - | "magnifying-glass" - | "moon" - | "pencil-1" - | "pencil-2" - | "plus" - | "reset" - | "sun" - | "trash" - | "update"; + | 'arrow-left' + | 'arrow-right' + | 'avatar' + | 'camera' + | 'check' + | 'clock' + | 'cross-1' + | 'dots-horizontal' + | 'download' + | 'envelope-closed' + | 'exit' + | 'file-text' + | 'github-logo' + | 'laptop' + | 'link-2' + | 'lock-closed' + | 'lock-open-1' + | 'magnifying-glass' + | 'moon' + | 'pencil-1' + | 'pencil-2' + | 'plus' + | 'question-mark-circled' + | 'reset' + | 'sun' + | 'trash' + | 'update' diff --git a/heat-stack/app/components/ui/icons/sprite.svg b/heat-stack/app/components/ui/icons/sprite.svg index 957a4c19..81f92f7b 100644 --- a/heat-stack/app/components/ui/icons/sprite.svg +++ b/heat-stack/app/components/ui/icons/sprite.svg @@ -51,6 +51,20 @@ fill="currentColor" > + + + + + + + + + + + + + {} diff --git a/heat-stack/app/components/ui/label.tsx b/heat-stack/app/components/ui/label.tsx index d741010c..ec453ee2 100644 --- a/heat-stack/app/components/ui/label.tsx +++ b/heat-stack/app/components/ui/label.tsx @@ -1,8 +1,8 @@ -import * as React from 'react' import * as LabelPrimitive from '@radix-ui/react-label' import { cva, type VariantProps } from 'class-variance-authority' +import * as React from 'react' -import { cn } from '~/utils/misc.tsx' +import { cn } from '#app/utils/misc.tsx' const labelVariants = cva( 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', diff --git a/heat-stack/app/components/ui/status-button.tsx b/heat-stack/app/components/ui/status-button.tsx index 0e6cf2ae..1bfdb61b 100644 --- a/heat-stack/app/components/ui/status-button.tsx +++ b/heat-stack/app/components/ui/status-button.tsx @@ -1,7 +1,7 @@ import * as React from 'react' -import { Button, type ButtonProps } from './button.tsx' -import { cn } from '~/utils/misc.tsx' import { useSpinDelay } from 'spin-delay' +import { cn } from '#app/utils/misc.tsx' +import { Button, type ButtonProps } from './button.tsx' import { Icon } from './icon.tsx' import { Tooltip, @@ -48,7 +48,7 @@ export const StatusButton = React.forwardRef< className={cn('flex justify-center gap-4', className)} {...props} > - {children} +
{children}
{message ? ( diff --git a/heat-stack/app/components/ui/textarea.tsx b/heat-stack/app/components/ui/textarea.tsx index 5d35e5e2..f7719e85 100644 --- a/heat-stack/app/components/ui/textarea.tsx +++ b/heat-stack/app/components/ui/textarea.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { cn } from '~/utils/misc.tsx' +import { cn } from '#app/utils/misc.tsx' export interface TextareaProps extends React.TextareaHTMLAttributes {} diff --git a/heat-stack/app/components/ui/toast.tsx b/heat-stack/app/components/ui/toast.tsx deleted file mode 100644 index 6ab49139..00000000 --- a/heat-stack/app/components/ui/toast.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import * as React from 'react' -import * as ToastPrimitives from '@radix-ui/react-toast' -import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from '~/utils/misc.tsx' -import { Icon } from './icon.tsx' - -const ToastProvider = ToastPrimitives.Provider - -const ToastViewport = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastViewport.displayName = ToastPrimitives.Viewport.displayName - -const toastVariants = cva( - 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', - { - variants: { - variant: { - default: 'border bg-background', - destructive: - 'destructive group border-destructive bg-destructive text-destructive-foreground', - }, - }, - defaultVariants: { - variant: 'default', - }, - }, -) - -const Toast = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps ->(({ className, variant, ...props }, ref) => { - return ( - - ) -}) -Toast.displayName = ToastPrimitives.Root.displayName - -const ToastAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastAction.displayName = ToastPrimitives.Action.displayName - -const ToastClose = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -ToastClose.displayName = ToastPrimitives.Close.displayName - -const ToastTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastTitle.displayName = ToastPrimitives.Title.displayName - -const ToastDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastDescription.displayName = ToastPrimitives.Description.displayName - -type ToastProps = React.ComponentPropsWithoutRef - -type ToastActionElement = React.ReactElement - -export { - type ToastProps, - type ToastActionElement, - ToastProvider, - ToastViewport, - Toast, - ToastTitle, - ToastDescription, - ToastClose, - ToastAction, -} diff --git a/heat-stack/app/components/ui/toaster.tsx b/heat-stack/app/components/ui/toaster.tsx deleted file mode 100644 index 0273b9ac..00000000 --- a/heat-stack/app/components/ui/toaster.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport, -} from '~/components/ui/toast.tsx' -import { useToast } from '~/components/ui/use-toast.ts' - -export function Toaster() { - const { toasts } = useToast() - - return ( - - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - -
- {title && {title}} - {description && ( - {description} - )} -
- {action} - -
- ) - })} - -
- ) -} diff --git a/heat-stack/app/components/ui/tooltip.tsx b/heat-stack/app/components/ui/tooltip.tsx index d0daf73a..5017f3ef 100644 --- a/heat-stack/app/components/ui/tooltip.tsx +++ b/heat-stack/app/components/ui/tooltip.tsx @@ -1,7 +1,7 @@ -import * as React from 'react' import * as TooltipPrimitive from '@radix-ui/react-tooltip' +import * as React from 'react' -import { cn } from '~/utils/misc.tsx' +import { cn } from '#app/utils/misc.tsx' const TooltipProvider = TooltipPrimitive.Provider diff --git a/heat-stack/app/components/ui/use-toast.ts b/heat-stack/app/components/ui/use-toast.ts deleted file mode 100644 index be3a3f76..00000000 --- a/heat-stack/app/components/ui/use-toast.ts +++ /dev/null @@ -1,189 +0,0 @@ -// Inspired by react-hot-toast library -import * as React from 'react' - -import type { ToastActionElement, ToastProps } from '~/components/ui/toast.tsx' - -const TOAST_LIMIT = 4 -const TOAST_REMOVE_DELAY = 1000000 - -type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} - -const actionTypes = { - ADD_TOAST: 'ADD_TOAST', - UPDATE_TOAST: 'UPDATE_TOAST', - DISMISS_TOAST: 'DISMISS_TOAST', - REMOVE_TOAST: 'REMOVE_TOAST', -} as const - -let count = 0 - -function genId() { - count = (count + 1) % Number.MAX_VALUE - return count.toString() -} - -type ActionType = typeof actionTypes - -type Action = - | { - type: ActionType['ADD_TOAST'] - toast: ToasterToast - } - | { - type: ActionType['UPDATE_TOAST'] - toast: Partial - } - | { - type: ActionType['DISMISS_TOAST'] - toastId?: ToasterToast['id'] - } - | { - type: ActionType['REMOVE_TOAST'] - toastId?: ToasterToast['id'] - } - -interface State { - toasts: ToasterToast[] -} - -const toastTimeouts = new Map>() - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) - dispatch({ - type: 'REMOVE_TOAST', - toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) - - toastTimeouts.set(toastId, timeout) -} - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case 'ADD_TOAST': - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } - - case 'UPDATE_TOAST': - return { - ...state, - toasts: state.toasts.map(t => - t.id === action.toast.id ? { ...t, ...action.toast } : t, - ), - } - - case 'DISMISS_TOAST': { - const { toastId } = action - - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId) - } else { - state.toasts.forEach(toast => { - addToRemoveQueue(toast.id) - }) - } - - return { - ...state, - toasts: state.toasts.map(t => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t, - ), - } - } - case 'REMOVE_TOAST': - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - } - } - return { - ...state, - toasts: state.toasts.filter(t => t.id !== action.toastId), - } - } -} - -const listeners: Array<(state: State) => void> = [] - -let memoryState: State = { toasts: [] } - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action) - listeners.forEach(listener => { - listener(memoryState) - }) -} - -type Toast = Omit - -function toast({ ...props }: Toast) { - const id = genId() - - const update = (props: ToasterToast) => - dispatch({ - type: 'UPDATE_TOAST', - toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }) - - dispatch({ - type: 'ADD_TOAST', - toast: { - ...props, - id, - open: true, - onOpenChange: open => { - if (!open) dismiss() - }, - }, - }) - - return { - id: id, - dismiss, - update, - } -} - -function useToast() { - const [state, setState] = React.useState(memoryState) - - React.useEffect(() => { - listeners.push(setState) - return () => { - const index = listeners.indexOf(setState) - if (index > -1) { - listeners.splice(index, 1) - } - } - }, [state]) - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), - } -} - -export { useToast, toast } diff --git a/heat-stack/app/entry.client.tsx b/heat-stack/app/entry.client.tsx index 59481246..cfea5580 100644 --- a/heat-stack/app/entry.client.tsx +++ b/heat-stack/app/entry.client.tsx @@ -2,21 +2,10 @@ import { RemixBrowser } from '@remix-run/react' import { startTransition } from 'react' import { hydrateRoot } from 'react-dom/client' -if (ENV.MODE === 'development') { - import('~/utils/devtools.tsx').then(({ init }) => init()) -} if (ENV.MODE === 'production' && ENV.SENTRY_DSN) { - import('~/utils/monitoring.client.tsx').then(({ init }) => init()) -} -if (process.env.NODE_ENV === 'development') { - import('remix-development-tools').then(({ initRouteBoundariesClient }) => { - initRouteBoundariesClient() - startTransition(() => { - hydrateRoot(document, ) - }) - }) -} else { - startTransition(() => { - hydrateRoot(document, ) - }) + import('./utils/monitoring.client.tsx').then(({ init }) => init()) } + +startTransition(() => { + hydrateRoot(document, ) +}) diff --git a/heat-stack/app/entry.server.tsx b/heat-stack/app/entry.server.tsx index 570139e9..2d8e268b 100644 --- a/heat-stack/app/entry.server.tsx +++ b/heat-stack/app/entry.server.tsx @@ -1,9 +1,12 @@ -import { Response, type HandleDocumentRequestFunction } from '@remix-run/node' +import { PassThrough } from 'stream' +import { + createReadableStreamFromReadable, + type HandleDocumentRequestFunction, +} from '@remix-run/node' import { RemixServer } from '@remix-run/react' import isbot from 'isbot' import { getInstanceInfo } from 'litefs-js' import { renderToPipeableStream } from 'react-dom/server' -import { PassThrough } from 'stream' import { getEnv, init } from './utils/env.server.ts' import { NonceProvider } from './utils/nonce-provider.ts' import { makeTimings } from './utils/timing.server.ts' @@ -14,7 +17,7 @@ init() global.ENV = getEnv() if (ENV.MODE === 'production' && ENV.SENTRY_DSN) { - import('~/utils/monitoring.server.ts').then(({ init }) => init()) + import('./utils/monitoring.server.ts').then(({ init }) => init()) } type DocRequestArgs = Parameters @@ -27,13 +30,6 @@ export default async function handleRequest(...args: DocRequestArgs) { remixContext, loadContext, ] = args - const context = - process.env.NODE_ENV === 'development' - ? await import('remix-development-tools').then( - ({ initRouteBoundariesServer }) => - initRouteBoundariesServer(remixContext), - ) - : remixContext const { currentInstance, primaryInstance } = await getInstanceInfo() responseHeaders.set('fly-region', process.env.FLY_REGION ?? 'unknown') responseHeaders.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown') @@ -45,16 +41,15 @@ export default async function handleRequest(...args: DocRequestArgs) { : 'onShellReady' const nonce = String(loadContext.cspNonce) ?? undefined - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { let didError = false - // NOTE: this timing will only include things that are rendered in the shell // and will not include suspended components and deferred loaders const timings = makeTimings('render', 'renderToPipeableStream') const { pipe, abort } = renderToPipeableStream( - + , { [callbackName]: () => { @@ -62,7 +57,7 @@ export default async function handleRequest(...args: DocRequestArgs) { responseHeaders.set('Content-Type', 'text/html') responseHeaders.append('Server-Timing', timings.toString()) resolve( - new Response(body, { + new Response(createReadableStreamFromReadable(body), { headers: responseHeaders, status: didError ? 500 : responseStatusCode, }), diff --git a/heat-stack/app/root.tsx b/heat-stack/app/root.tsx index 176a77b1..a30fedc4 100644 --- a/heat-stack/app/root.tsx +++ b/heat-stack/app/root.tsx @@ -1,10 +1,12 @@ +import { useForm } from '@conform-to/react' +import { parse } from '@conform-to/zod' import { cssBundleHref } from '@remix-run/css-bundle' import { json, type DataFunctionArgs, type HeadersFunction, type LinksFunction, - type V2_MetaFunction, + type MetaFunction, } from '@remix-run/node' import { Form, @@ -15,15 +17,20 @@ import { Outlet, Scripts, ScrollRestoration, + useFetcher, + useFetchers, useLoaderData, useMatches, useSubmit, } from '@remix-run/react' import { withSentry } from '@sentry/remix' import { Suspense, lazy, useEffect, useRef, useState } from 'react' +import { z } from 'zod' import { Confetti } from './components/confetti.tsx' import { GeneralErrorBoundary } from './components/error-boundary.tsx' +import { ErrorList } from './components/forms.tsx' import { SearchBar } from './components/search-bar.tsx' +import { EpicToaster } from './components/toaster.tsx' import { Button } from './components/ui/button.tsx' import { DropdownMenu, @@ -33,26 +40,27 @@ import { DropdownMenuTrigger, } from './components/ui/dropdown-menu.tsx' import { Icon, href as iconsHref } from './components/ui/icon.tsx' -import { Toaster } from './components/ui/toaster.tsx' -import { ThemeSwitch, useTheme } from './routes/resources+/theme/index.tsx' -import { getTheme } from './routes/resources+/theme/theme.server.ts' -import fontStylestylesheetUrl from './styles/font.css' -import tailwindStylesheetUrl from './styles/tailwind.css' +import fontStyleSheetUrl from './styles/font.css' +import tailwindStyleSheetUrl from './styles/tailwind.css' import { authenticator, getUserId } from './utils/auth.server.ts' -import { ClientHintCheck, getHints } from './utils/client-hints.tsx' +import { ClientHintCheck, getHints, useHints } from './utils/client-hints.tsx' +import { getConfetti } from './utils/confetti.server.ts' import { prisma } from './utils/db.server.ts' import { getEnv } from './utils/env.server.ts' -import { getFlashSession } from './utils/flash-session.server.ts' -import { combineHeaders, getDomainUrl, getUserImgSrc } from './utils/misc.tsx' +import { + combineHeaders, + getDomainUrl, + getUserImgSrc, + invariantResponse, +} from './utils/misc.tsx' import { useNonce } from './utils/nonce-provider.ts' +import { useRequestInfo } from './utils/request-info.ts' +import { type Theme, setTheme, getTheme } from './utils/theme.server.ts' import { makeTimings, time } from './utils/timing.server.ts' -import { useToast } from './utils/useToast.tsx' +import { getToast } from './utils/toast.server.ts' import { useOptionalUser, useUser } from './utils/user.ts' -import rdtStylesheetUrl from 'remix-development-tools/stylesheet.css' -const RemixDevTools = - process.env.NODE_ENV === 'development' - ? lazy(() => import('remix-development-tools')) - : undefined + +// import { WeatherExample } from './components/WeatherExample.tsx' import * as pyodideModule from 'pyodide' import engine from '../../rules-engine/src/rules_engine/engine.py'; @@ -70,20 +78,17 @@ const runPythonScript = async () => { await pyodide.loadPackage("numpy") await pyodide.runPythonAsync(engine); return pyodide; - }; - +}; + export const links: LinksFunction = () => { return [ // Preload svg sprite as a resource to avoid render blocking { rel: 'preload', href: iconsHref, as: 'image' }, // Preload CSS as a resource to avoid render blocking - { rel: 'preload', href: fontStylestylesheetUrl, as: 'style' }, - { rel: 'preload', href: tailwindStylesheetUrl, as: 'style' }, + { rel: 'preload', href: fontStyleSheetUrl, as: 'style' }, + { rel: 'preload', href: tailwindStyleSheetUrl, as: 'style' }, cssBundleHref ? { rel: 'preload', href: cssBundleHref, as: 'style' } : null, - rdtStylesheetUrl && process.env.NODE_ENV === 'development' - ? { rel: 'preload', href: rdtStylesheetUrl, as: 'style' } - : null, { rel: 'mask-icon', href: '/favicons/mask-icon.svg' }, { rel: 'alternate icon', @@ -98,16 +103,13 @@ export const links: LinksFunction = () => { } as const, // necessary to make typescript happy //These should match the css preloads above to avoid css as render blocking resource { rel: 'icon', type: 'image/svg+xml', href: '/favicons/favicon.svg' }, - { rel: 'stylesheet', href: fontStylestylesheetUrl }, - { rel: 'stylesheet', href: tailwindStylesheetUrl }, + { rel: 'stylesheet', href: fontStyleSheetUrl }, + { rel: 'stylesheet', href: tailwindStyleSheetUrl }, cssBundleHref ? { rel: 'stylesheet', href: cssBundleHref } : null, - rdtStylesheetUrl && process.env.NODE_ENV === 'development' - ? { rel: 'stylesheet', href: rdtStylesheetUrl } - : null, ].filter(Boolean) } -export const meta: V2_MetaFunction = ({ data }) => { +export const meta: MetaFunction = ({ data }) => { return [ { title: data ? 'Epic Notes' : 'Error | Epic Notes' }, { name: 'description', content: `Your own captain's log` }, @@ -125,9 +127,22 @@ export async function loader({ request }: DataFunctionArgs) { const user = userId ? await time( () => - prisma.user.findUnique({ + prisma.user.findUniqueOrThrow({ + select: { + id: true, + name: true, + username: true, + image: { select: { id: true } }, + roles: { + select: { + name: true, + permissions: { + select: { entity: true, action: true, access: true }, + }, + }, + }, + }, where: { id: userId }, - select: { id: true, name: true, username: true, imageId: true }, }), { timings, type: 'find user', desc: 'find user in root' }, ) @@ -138,7 +153,8 @@ export async function loader({ request }: DataFunctionArgs) { // them in the database. Maybe they were deleted? Let's log them out. await authenticator.logout(request, { redirectTo: '/' }) } - const { flash, headers: flashHeaders } = await getFlashSession(request) + const { toast, headers: toastHeaders } = await getToast(request) + const { confettiId, headers: confettiHeaders } = getConfetti(request) return json( { @@ -152,12 +168,14 @@ export async function loader({ request }: DataFunctionArgs) { }, }, ENV: getEnv(), - flash, + toast, + confettiId, }, { headers: combineHeaders( { 'Server-Timing': timings.toString() }, - flashHeaders, + toastHeaders, + confettiHeaders, ), }, ) @@ -170,6 +188,34 @@ export const headers: HeadersFunction = ({ loaderHeaders }) => { return headers } +const ThemeFormSchema = z.object({ + theme: z.enum(['system', 'light', 'dark']), +}) + +export async function action({ request }: DataFunctionArgs) { + const formData = await request.formData() + invariantResponse( + formData.get('intent') === 'update-theme', + 'Invalid intent', + { status: 400 }, + ) + const submission = parse(formData, { + schema: ThemeFormSchema, + }) + if (submission.intent !== 'submit') { + return json({ status: 'success', submission } as const) + } + if (!submission.value) { + return json({ status: 'error', submission } as const, { status: 400 }) + } + const { theme } = submission.value + + const responseInit = { + headers: { 'set-cookie': setTheme(theme) }, + } + return json({ success: true, submission }, responseInit) +} + function Document({ children, nonce, @@ -178,7 +224,7 @@ function Document({ }: { children: React.ReactNode nonce: string - theme?: 'dark' | 'light' + theme?: Theme env?: Record }) { const [output, setOutput] = useState('(loading python...)'); @@ -206,6 +252,7 @@ function Document({
Output:
{output} + {/* */} {children}