Old stuff

This commit is contained in:
Atridad Lahiji 2023-04-20 04:20:00 -06:00 committed by atridadl
commit b086f719c8
No known key found for this signature in database
45 changed files with 10423 additions and 0 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules
/.cache
/build
/public/build
.env

24
.env.example Normal file
View file

@ -0,0 +1,24 @@
#Database
DATABASE_URL=""
DATABASE_AUTH_TOKEN=""
# Redis
REDIS_URL=""
REDIS_EXPIRY_SECONDS=""
#Auth
NEXT_PUBLIC_CLERK_SIGN_UP_URL="/sign-up"
NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in"
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
CLERK_SECRET_KEY=""
CLERK_WEBHOOK_SIGNING_SECRET=""
# Ably
ABLY_API_KEY=""
# Unkey
UNKEY_ROOT_KEY=""
# Misc
APP_ENV=""
NEXT_PUBLIC_APP_ENV=""

4
.eslintrc.cjs Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
};

15
.github/workflows/fly.yml vendored Normal file
View file

@ -0,0 +1,15 @@
name: Fly Deploy
on:
push:
branches:
- master
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules
/.cache
/build
/public/build
.env

1
.npmrc Normal file
View file

@ -0,0 +1 @@
auto-install-peers=true

49
Dockerfile Normal file
View file

@ -0,0 +1,49 @@
# syntax = docker/dockerfile:1
# Adjust NODE_VERSION as desired
ARG NODE_VERSION=18.14.2
FROM node:${NODE_VERSION}-slim as base
LABEL fly_launch_runtime="Remix"
# Remix app lives here
WORKDIR /app
# Set production environment
ENV NODE_ENV="production"
# Install pnpm
ARG PNPM_VERSION=8.9.2
RUN npm install -g pnpm@$PNPM_VERSION
# Throw-away build stage to reduce size of final image
FROM base as build
# Install packages needed to build node modules
RUN apt-get update -qq && \
apt-get install -y build-essential pkg-config python-is-python3
# Install node modules
COPY --link package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
# Copy application code
COPY --link . .
# Build application
RUN pnpm run build
# Remove development dependencies
RUN pnpm prune --prod
# Final stage for app image
FROM base
# Copy built application
COPY --from=build /app /app
# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD [ "pnpm", "run", "start" ]

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# Welcome to Remix!
- [Remix Docs](https://remix.run/docs)
## Development
From your terminal:
```sh
npm run dev
```
This starts your app in development mode, rebuilding assets on file changes.
## Deployment
First, build your app for production:
```sh
npm run build
```
Then run the app in production mode:
```sh
npm start
```
Now you'll need to pick a host to deploy it to.
### DIY
If you're familiar with deploying node applications, the built-in Remix app server is production-ready.
Make sure to deploy the output of `remix build`
- `build/`
- `public/build/`

33
app/components/Footer.tsx Normal file
View file

@ -0,0 +1,33 @@
import { HeartIcon } from "lucide-react";
import packagejson from "../../package.json";
const Footer = () => {
return (
<footer className="footer footer-center h-12 p-2 bg-base-100 text-base-content">
<div className="block">
Made with{" "}
<HeartIcon className="inline-block text-primary text-lg animate-pulse" />{" "}
by{" "}
<a
className="link link-primary link-hover"
href="https://atri.dad"
rel="noreferrer"
target="_blank"
>
Atridad Lahiji
</a>{" "}
-{" "}
<a
className="link link-primary link-hover"
href={`https://github.com/atridadl/sprintpadawan/releases/tag/${packagejson.version}`}
rel="noreferrer"
target="_blank"
>
v{packagejson.version}
</a>
</div>
</footer>
);
};
export default Footer;

59
app/components/Header.tsx Normal file
View file

@ -0,0 +1,59 @@
"use client";
import { UserButton, useUser } from "@clerk/remix";
import { Link, useLocation, useNavigate } from "@remix-run/react";
interface NavbarProps {
title: string;
}
const Navbar = ({ title }: NavbarProps) => {
const { isSignedIn } = useUser();
const { pathname } = useLocation();
const navigate = useNavigate();
const navigationMenu = () => {
if (pathname !== "/dashboard" && isSignedIn) {
return (
<Link className="btn btn-primary btn-outline mx-2" to="/dashboard">
Dashboard
</Link>
);
} else if (!isSignedIn) {
return (
<button
className="btn btn-primary"
onClick={() => void navigate("/sign-in")}
>
Sign In
</button>
);
}
};
return (
<nav className="navbar bg-base-100 h-12">
<div className="flex-1">
<Link
about="Back to home."
to="/"
className="btn btn-ghost normal-case text-xl"
>
<img
className="md:mr-2"
src="/logo.webp"
alt="Nav Logo"
width={32}
height={32}
/>
<span className="hidden md:inline-flex">{title}</span>
</Link>
</div>
{navigationMenu()}
<UserButton afterSignOutUrl="/" userProfileMode="modal" />
</nav>
);
};
export default Navbar;

View file

@ -0,0 +1,9 @@
const LoadingIndicator = () => {
return (
<div className="flex items-center justify-center">
<span className="loading loading-dots loading-lg"></span>
</div>
);
};
export default LoadingIndicator;

18
app/entry.client.tsx Normal file
View file

@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});

137
app/entry.server.tsx Normal file
View file

@ -0,0 +1,137 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
loadContext: AppLoadContext
) {
return isbot(request.headers.get("user-agent"))
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}

61
app/root.tsx Normal file
View file

@ -0,0 +1,61 @@
import { rootAuthLoader } from "@clerk/remix/ssr.server";
import { ClerkApp, ClerkErrorBoundary, ClerkLoaded } from "@clerk/remix";
import type {
LinksFunction,
LoaderFunction,
MetaFunction,
} from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import stylesheet from "~/tailwind.css";
import Footer from "./components/Footer";
import Header from "./components/Header";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesheet },
];
export const meta: MetaFunction = () => {
return [
{ title: "Sprint Padawan" },
{ name: "description", content: "Plan. Sprint. Repeat." },
];
};
export const loader: LoaderFunction = (args) => rootAuthLoader(args);
export const ErrorBoundary = ClerkErrorBoundary();
function App() {
return (
<html lang="en" className="h-[100%] w-[100%] fixed overflow-y-auto">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="h-[100%] w-[100%] fixed overflow-y-auto">
<ClerkLoaded>
<Header title={"Sprint Padawan"} />
<div className="flex flex-row items-center justify-center min-h-[calc(100%-114px)]">
<Outlet />
</div>
<Footer />
</ClerkLoaded>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
export default ClerkApp(App);

40
app/routes/_index.tsx Normal file
View file

@ -0,0 +1,40 @@
export default function Index() {
return (
<div className="flex flex-col text-center items-center justify-center px-4 py-16 gap-4">
<h1 className="text-3xl sm:text-6xl font-bold">
Sprint{" "}
<span className="bg-gradient-to-br from-pink-600 to-cyan-400 bg-clip-text text-transparent box-decoration-clone">
Padawan
</span>
</h1>
<h2 className="my-4 text-xl sm:text-3xl font-bold">
A{" "}
<span className="bg-gradient-to-br from-pink-600 to-pink-400 bg-clip-text text-transparent box-decoration-clone">
scrum poker{" "}
</span>{" "}
tool that helps{" "}
<span className="bg-gradient-to-br from-purple-600 to-purple-400 bg-clip-text text-transparent box-decoration-clone">
agile teams{" "}
</span>{" "}
plan their sprints in{" "}
<span className="bg-gradient-to-br from-cyan-600 to-cyan-400 bg-clip-text text-transparent box-decoration-clone">
real-time
</span>
.
</h2>
<div className="card card-compact bg-secondary text-black font-bold text-left">
<div className="card-body">
<h2 className="card-title">Features:</h2>
<ul>
<li>🚀 Real-time votes!</li>
<li>🚀 Customizable room name and vote scale!</li>
<li>🚀 CSV Reports for every room!</li>
<li>🚀 100% free and open-source... forever!</li>
</ul>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
import { getAuth } from "@clerk/remix/ssr.server";
import { ActionFunctionArgs, json } from "@remix-run/node";
import { createId } from "@paralleldrive/cuid2";
import { db } from "~/services/db.server";
import { emitter } from "~/services/emitter.server";
import { rooms } from "~/services/schema";
export async function action({ request, params, context }: ActionFunctionArgs) {
const { userId } = await getAuth({ context, params, request });
if (!userId) {
return json("Not Signed In!", {
status: 403,
statusText: "UNAUTHORIZED!",
});
}
const data = await request.json();
const room = await db
.insert(rooms)
.values({
id: `room_${createId()}`,
created_at: Date.now().toString(),
userId: userId || "",
roomName: data.name,
storyName: "First Story!",
scale: "0.5,1,2,3,5,8",
visible: 0,
})
.returning();
const success = room.length > 0;
if (success) {
emitter.emit("roomlist");
return json(room, {
status: 200,
statusText: "SUCCESS",
});
}
}

View file

@ -0,0 +1,42 @@
import { getAuth } from "@clerk/remix/ssr.server";
import { ActionFunctionArgs, json } from "@remix-run/node";
import { eq } from "drizzle-orm";
import { db } from "~/services/db.server";
import { emitter } from "~/services/emitter.server";
import { rooms } from "~/services/schema";
export async function action({ request, params, context }: ActionFunctionArgs) {
const { userId } = await getAuth({ context, params, request });
if (!userId) {
return json("Not Signed In!", {
status: 403,
statusText: "UNAUTHORIZED!",
});
}
const roomId = params.roomId;
if (!roomId) {
return json("RoomId Missing!", {
status: 400,
statusText: "BAD REQUEST!",
});
}
const deletedRoom = await db
.delete(rooms)
.where(eq(rooms.id, roomId))
.returning();
const success = deletedRoom.length > 0;
if (success) {
emitter.emit("roomlist");
return json(deletedRoom, {
status: 200,
statusText: "SUCCESS",
});
}
}

View file

@ -0,0 +1,66 @@
import { getAuth } from "@clerk/remix/ssr.server";
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { eq } from "drizzle-orm";
import { eventStream } from "remix-utils/sse/server";
import { db } from "~/services/db.server";
import { emitter } from "~/services/emitter.server";
import { rooms } from "~/services/schema";
// Get Room List
export async function loader({ context, params, request }: LoaderFunctionArgs) {
const { userId } = await getAuth({ context, params, request });
const roomId = params.roomId;
if (!roomId) {
return json("RoomId Missing!", {
status: 400,
statusText: "BAD REQUEST!",
});
}
if (!userId) {
return json("Not Signed In!", {
status: 403,
statusText: "UNAUTHORIZED!",
});
}
return eventStream(request.signal, function setup(send) {
async function handler() {
const roomFromDb = await db.query.rooms.findFirst({
where: eq(rooms.id, roomId || ""),
with: {
logs: true,
},
});
send({
event: `room-${roomId}`,
data: JSON.stringify(roomFromDb),
});
}
// Initial fetch
console.log("HI");
db.query.rooms
.findFirst({
where: eq(rooms.id, roomId || ""),
with: {
logs: true,
},
})
.then((roomFromDb) => {
console.log(roomId);
return send({
event: `room-${roomId}`,
data: JSON.stringify(roomFromDb),
});
});
emitter.on("room", handler);
return function clear() {
emitter.off("room", handler);
};
});
}

View file

@ -0,0 +1,44 @@
import { getAuth } from "@clerk/remix/ssr.server";
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { eq } from "drizzle-orm";
import { eventStream } from "remix-utils/sse/server";
import { db } from "~/services/db.server";
import { emitter } from "~/services/emitter.server";
import { rooms } from "~/services/schema";
// Get Room List
export async function loader({ context, params, request }: LoaderFunctionArgs) {
const { userId } = await getAuth({ context, params, request });
if (!userId) {
return json("Not Signed In!", {
status: 403,
statusText: "UNAUTHORIZED!",
});
}
return eventStream(request.signal, function setup(send) {
async function handler() {
const roomList = await db.query.rooms.findMany({
where: eq(rooms.userId, userId || ""),
});
send({ event: userId!, data: JSON.stringify(roomList) });
}
// Initial fetch
db.query.rooms
.findMany({
where: eq(rooms.userId, userId || ""),
})
.then((roomList) => {
send({ event: userId!, data: JSON.stringify(roomList) });
});
emitter.on("roomlist", handler);
return function clear() {
emitter.off("roomlist", handler);
};
});
}

View file

@ -0,0 +1,82 @@
import { getAuth } from "@clerk/remix/ssr.server";
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { and, eq } from "drizzle-orm";
import { eventStream } from "remix-utils/sse/server";
import { db } from "~/services/db.server";
import { emitter } from "~/services/emitter.server";
import { presence } from "~/services/schema";
import { createId } from "@paralleldrive/cuid2";
export async function loader({ context, params, request }: LoaderFunctionArgs) {
const { userId, sessionClaims } = await getAuth({ context, params, request });
const roomId = params.roomId;
if (!roomId) {
return json("RoomId Missing!", {
status: 400,
statusText: "BAD REQUEST!",
});
}
if (!userId) {
return json("Not Signed In!", {
status: 403,
statusText: "UNAUTHORIZED!",
});
}
const name = sessionClaims.name as string;
const image = sessionClaims.image as string;
return eventStream(request.signal, function setup(send) {
async function handler() {
const presenceData = await db.query.presence.findMany({
where: and(eq(presence.roomId, roomId || "")),
});
send({
event: `${userId}-${params.roomId}`,
data: JSON.stringify(presenceData),
});
}
db.insert(presence)
.values({
id: `presence_${createId()}`,
roomId: roomId || "",
userFullName: name,
userId: userId,
userImageUrl: image,
isAdmin: 0,
isVIP: 0,
})
.onConflictDoUpdate({
target: [presence.userId, presence.roomId],
set: {
roomId: roomId || "",
userFullName: name,
userId: userId,
userImageUrl: image,
isAdmin: 0,
isVIP: 0,
},
})
.then(async () => {
emitter.emit("presence");
});
// Initial fetch
emitter.on("presence", handler);
return function clear() {
db.delete(presence)
.where(and(eq(presence.roomId, roomId), eq(presence.userId, userId)))
.returning()
.then(async () => {
emitter.emit("presence");
});
emitter.off("presence", handler);
};
});
}

View file

@ -0,0 +1,87 @@
import { getAuth } from "@clerk/remix/ssr.server";
import { ActionFunctionArgs, json } from "@remix-run/node";
import { createId } from "@paralleldrive/cuid2";
import { db } from "~/services/db.server";
import { emitter } from "~/services/emitter.server";
import { logs, rooms, votes } from "~/services/schema";
import { eq } from "drizzle-orm";
export async function action({ request, params, context }: ActionFunctionArgs) {
const { userId } = await getAuth({ context, params, request });
if (!userId) {
return json("Not Signed In!", {
status: 403,
statusText: "UNAUTHORIZED!",
});
}
const data = await request.json();
const roomId = params.roomId;
if (data.log) {
const oldRoom = await db.query.rooms.findFirst({
where: eq(rooms.id, params.roomId || ""),
with: {
votes: true,
logs: true,
},
});
oldRoom &&
(await db.insert(logs).values({
id: `log_${createId()}`,
created_at: Date.now().toString(),
userId: userId || "",
roomId: roomId || "",
scale: oldRoom.scale,
votes: JSON.stringify(
oldRoom.votes.map((vote) => {
return {
name: vote.userId,
value: vote.value,
};
})
),
roomName: oldRoom.roomName,
storyName: oldRoom.storyName,
}));
}
if (data.reset) {
await db.delete(votes).where(eq(votes.roomId, params.roomId || ""));
}
const newRoom = data.reset
? await db
.update(rooms)
.set({
storyName: data.name,
visible: data.visible,
scale: [...new Set(data.scale.split(","))]
.filter((item) => item !== "")
.toString(),
})
.where(eq(rooms.id, params.roomId || ""))
.returning()
: await db
.update(rooms)
.set({
visible: data.visible,
})
.where(eq(rooms.id, params.roomId || ""))
.returning();
const success = newRoom.length > 0;
if (success) {
console.log(success);
emitter.emit("room");
emitter.emit("votes");
return json(newRoom, {
status: 200,
statusText: "SUCCESS",
});
}
}

View file

@ -0,0 +1,50 @@
import { getAuth } from "@clerk/remix/ssr.server";
import { ActionFunctionArgs, json } from "@remix-run/node";
import { createId } from "@paralleldrive/cuid2";
import { db } from "~/services/db.server";
import { emitter } from "~/services/emitter.server";
import { votes } from "~/services/schema";
export async function action({ request, params, context }: ActionFunctionArgs) {
const { userId } = await getAuth({ context, params, request });
if (!userId) {
return json("Not Signed In!", {
status: 403,
statusText: "UNAUTHORIZED!",
});
}
const data = await request.json();
const roomId = params.roomId;
const upsertResult = await db
.insert(votes)
.values({
id: `vote_${createId()}`,
created_at: Date.now().toString(),
value: data.value,
userId: userId || "",
roomId: roomId || "",
})
.onConflictDoUpdate({
target: [votes.userId, votes.roomId],
set: {
created_at: Date.now().toString(),
value: data.value,
userId: userId || "",
roomId: roomId,
},
});
const success = upsertResult.rowsAffected > 0;
if (success) {
emitter.emit("votes");
return json(upsertResult, {
status: 200,
statusText: "SUCCESS",
});
}
}

View file

@ -0,0 +1,55 @@
import { getAuth } from "@clerk/remix/ssr.server";
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { eq } from "drizzle-orm";
import { eventStream } from "remix-utils/sse/server";
import { db } from "~/services/db.server";
import { emitter } from "~/services/emitter.server";
import { votes } from "~/services/schema";
// Get Room List
export async function loader({ context, params, request }: LoaderFunctionArgs) {
const { userId } = await getAuth({ context, params, request });
const roomId = params.roomId;
if (!roomId) {
return json("RoomId Missing!", {
status: 400,
statusText: "BAD REQUEST!",
});
}
if (!userId) {
return json("Not Signed In!", {
status: 403,
statusText: "UNAUTHORIZED!",
});
}
return eventStream(request.signal, function setup(send) {
async function handler() {
const votesByRoomId = await db.query.votes.findMany({
where: eq(votes.roomId, roomId || ""),
});
send({ event: `votes-${roomId}`, data: JSON.stringify(votesByRoomId) });
}
// Initial fetch
db.query.votes
.findMany({
where: eq(votes.roomId, roomId || ""),
})
.then((votesByRoomId) => {
return send({
event: `votes-${roomId}`,
data: JSON.stringify(votesByRoomId),
});
});
emitter.on("votes", handler);
return function clear() {
emitter.off("votes", handler);
};
});
}

View file

@ -0,0 +1,79 @@
import { ActionFunctionArgs, json } from "@remix-run/node";
import { Webhook } from "svix";
import { WebhookEvent } from "@clerk/remix/api.server";
import {
onUserCreatedHandler,
onUserDeletedHandler,
} from "~/services/webhookhelpers.server";
export async function action({ request, params, context }: ActionFunctionArgs) {
// Get the headers
const headerPayload = request.headers;
const svix_id = headerPayload.get("svix-id");
const svix_timestamp = headerPayload.get("svix-timestamp");
const svix_signature = headerPayload.get("svix-signature");
// If there are no headers, error out
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response("Error occured -- no svix headers", {
status: 400,
});
}
// Get the body
const body = JSON.stringify(await request.json());
// Create a new SVIX instance with your secret.
const wh = new Webhook(process.env.CLERK_WEBHOOK_SIGNING_SECRET!);
let evt: WebhookEvent;
// Verify the payload with the headers
try {
evt = wh.verify(body, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
}) as WebhookEvent;
} catch (err) {
console.error("Error verifying webhook:", err);
return new Response("Error occured", {
status: 400,
});
}
// Get the ID and type
const { id } = evt.data;
const eventType = evt.type;
let success = false;
switch (eventType) {
case "user.created":
success = await onUserCreatedHandler(id);
if (success) {
return json(
{ result: "USER CREATED" },
{ status: 200, statusText: "USER CREATED" }
);
} else {
return json(
{ result: "USER WITH THIS ID NOT FOUND" },
{ status: 404, statusText: "USER WITH THIS ID NOT FOUND" }
);
}
case "user.deleted":
success = await onUserDeletedHandler(id);
return json(
{ result: "USER DELETED" },
{ status: 200, statusText: "USER DELETED" }
);
default:
return json(
{ result: "INVALID WEBHOOK EVENT TYPE" },
{ status: 400, statusText: "INVALID WEBHOOK EVENT TYPE" }
);
}
}

155
app/routes/dashboard.tsx Normal file
View file

@ -0,0 +1,155 @@
import { getAuth } from "@clerk/remix/ssr.server";
import { LoaderFunction, redirect } from "@remix-run/node";
import { Link } from "@remix-run/react";
import { LogInIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import LoadingIndicator from "~/components/LoadingIndicator";
import { useEventSource } from "remix-utils/sse/react";
import { useAuth } from "@clerk/remix";
export const loader: LoaderFunction = async (args) => {
const { userId } = await getAuth(args);
if (!userId) {
return redirect("/sign-in");
}
return {};
};
type RoomsResponse =
| {
id: string;
createdAt: Date;
roomName: string;
}[]
| {
roomName: string | null;
id: string;
created_at: Date | null;
userId: string;
storyName: string | null;
visible: boolean;
scale: string;
}[]
| null
| undefined;
export default function Dashboard() {
const { userId } = useAuth();
let roomsFromDb = useEventSource("/api/room/get/all", { event: userId! });
let roomsFromDbParsed = JSON.parse(roomsFromDb!) as RoomsResponse;
const [roomName, setRoomName] = useState<string>("");
const createRoomHandler = async () => {
await fetch("/api/room/create", {
cache: "no-cache",
method: "POST",
body: JSON.stringify({ name: roomName }),
});
setRoomName("");
(document.querySelector("#roomNameInput") as HTMLInputElement).value = "";
(document.querySelector("#new-room-modal") as HTMLInputElement).checked =
false;
};
const deleteRoomHandler = async (roomId: string) => {
await fetch(`/api/room/delete/${roomId}`, {
cache: "no-cache",
method: "DELETE",
});
};
return (
<div className="flex flex-col items-center justify-center gap-8">
{/* Modal for Adding Rooms */}
<input type="checkbox" id="new-room-modal" className="modal-toggle" />
<div className="modal modal-bottom sm:modal-middle">
<div className="modal-box flex-col flex text-center justify-center items-center">
<label
htmlFor="new-room-modal"
className="btn btn-sm btn-circle absolute right-2 top-2"
>
</label>
<h3 className="font-bold text-lg">Create a new room!</h3>
<div className="form-control w-full max-w-xs">
<label className="label">
<span className="label-text">Room Name</span>
</label>
<input
id="roomNameInput"
type="text"
placeholder="Type here"
className="input input-bordered w-full max-w-xs"
onChange={(event) => {
setRoomName(event.target.value);
}}
/>
</div>
<div className="modal-action">
{roomName.length > 0 && (
<label
htmlFor="new-room-modal"
className="btn btn-primary"
onClick={() => void createRoomHandler()}
>
Submit
</label>
)}
</div>
</div>
</div>
{roomsFromDbParsed && roomsFromDbParsed.length > 0 && (
<div className="overflow-x-auto">
<table className="table text-center">
{/* head */}
<thead>
<tr className="border-white">
<th>Room Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody className="">
{roomsFromDbParsed?.map((room) => {
return (
<tr key={room.id} className="hover border-white">
<td className="break-all max-w-[200px] md:max-w-[400px]">
{room.roomName}
</td>
<td>
<Link
className="m-2 no-underline"
to={`/room/${room.id}`}
>
<LogInIcon className="text-xl inline-block hover:text-primary" />
</Link>
<button
className="m-2"
onClick={() => void deleteRoomHandler(room.id)}
>
<TrashIcon className="text-xl inline-block hover:text-error" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<label htmlFor="new-room-modal" className="btn btn-primary">
New Room
</label>
{!roomsFromDbParsed && <LoadingIndicator />}
</div>
);
}

456
app/routes/room.$roomId.tsx Normal file
View file

@ -0,0 +1,456 @@
import { getAuth } from "@clerk/remix/ssr.server";
import { LoaderFunction, redirect } from "@remix-run/node";
import { Link, useParams } from "@remix-run/react";
import {
CheckCircleIcon,
CopyIcon,
CrownIcon,
DownloadIcon,
EyeIcon,
EyeOffIcon,
HourglassIcon,
RefreshCwIcon,
SaveIcon,
ShieldIcon,
StarIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import LoadingIndicator from "~/components/LoadingIndicator";
import { useEventSource } from "remix-utils/sse/react";
import { PresenceItem, RoomResponse, VoteResponse } from "~/services/types";
import { isAdmin, jsonToCsv } from "~/services/helpers";
import { useUser } from "@clerk/remix";
export const loader: LoaderFunction = async (args) => {
const { userId } = await getAuth(args);
if (!userId) {
return redirect("/sign-in");
}
return {};
};
export default function Room() {
const { user } = useUser();
const params = useParams();
const roomId = params.roomId;
let roomFromDb = useEventSource(`/api/room/get/${roomId}`, {
event: `room-${params.roomId}`,
});
let votesFromDb = useEventSource(`/api/votes/get/${roomId}`, {
event: `votes-${params.roomId}`,
});
let presenceData = useEventSource(`/api/room/presence/get/${roomId}`, {
event: `${user?.id}-${params.roomId}`,
});
let roomFromDbParsed = JSON.parse(roomFromDb!) as RoomResponse | undefined;
let votesFromDbParsed = JSON.parse(votesFromDb!) as VoteResponse | undefined;
let presenceDateParsed = JSON.parse(presenceData!) as
| PresenceItem[]
| undefined;
const [storyNameText, setStoryNameText] = useState<string>("");
const [roomScale, setRoomScale] = useState<string>("");
const [copied, setCopied] = useState<boolean>(false);
// Handlers
// =================================
async function getRoomHandler() {
const response = await fetch(`/api/internal/room/${roomId}`, {
cache: "no-cache",
method: "GET",
});
return (await response.json()) as RoomResponse;
}
async function getVotesHandler() {
const dbVotesResponse = await fetch(`/api/internal/room/${roomId}/votes`, {
cache: "no-cache",
method: "GET",
});
const dbVotes = (await dbVotesResponse.json()) as VoteResponse;
return dbVotes;
}
async function setVoteHandler(value: string) {
if (roomFromDb) {
await fetch(`/api/vote/set/${roomId}`, {
cache: "no-cache",
method: "PUT",
body: JSON.stringify({
value,
}),
});
}
}
async function setRoomHandler(data: {
visible: boolean;
reset: boolean | undefined;
log: boolean | undefined;
}) {
if (roomFromDb) {
await fetch(`/api/room/set/${roomId}`, {
cache: "no-cache",
method: "PUT",
body: JSON.stringify({
name: storyNameText,
visible: data.visible,
scale: roomScale,
reset: data.reset ? data.reset : false,
log: data.log ? data.log : false,
}),
});
}
}
// Helpers
// =================================
const getVoteForCurrentUser = () => {
if (roomFromDb) {
return (
votesFromDbParsed &&
votesFromDbParsed.find((vote) => vote.userId === user?.id)
);
} else {
return null;
}
};
const downloadLogs = () => {
if (roomFromDb && votesFromDb) {
const jsonObject = roomFromDbParsed?.logs
.map((item) => {
return {
id: item.id,
created_at: item.created_at,
userId: item.userId,
roomId: item.roomId,
roomName: item.roomName,
storyName: item.storyName,
scale: item.scale,
votes: item.votes,
};
})
.concat({
id: "LATEST",
created_at: new Date(),
userId: roomFromDbParsed.userId,
roomId: roomFromDbParsed.id,
roomName: roomFromDbParsed.roomName,
storyName: storyNameText,
scale: roomScale,
votes: votesFromDbParsed?.map((vote) => {
return {
value: vote.value,
};
}),
});
jsonToCsv(jsonObject!, `sp_${roomId}.csv`);
}
};
const copyRoomURLHandler = () => {
navigator.clipboard
.writeText(window.location.href)
.then(() => {
console.log(`Copied Room Link to Clipboard!`);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
})
.catch(() => {
console.log(`Error Copying Room Link to Clipboard!`);
});
};
const voteString = (
visible: boolean,
votes: typeof votesFromDbParsed,
presenceItem: PresenceItem
) => {
const matchedVote = votes?.find(
(vote) => vote.userId === presenceItem.userId
);
if (visible) {
if (!!matchedVote) {
return <div>{matchedVote.value}</div>;
} else {
return <HourglassIcon className="text-xl text-error" />;
}
} else if (!!matchedVote) {
return <CheckCircleIcon className="text-xl text-success" />;
} else {
return <HourglassIcon className="text-xl animate-spin text-warning" />;
}
};
// Hooks
// =================================
useEffect(() => {
if (roomFromDb) {
setStoryNameText(roomFromDbParsed?.storyName || "");
setRoomScale(roomFromDbParsed?.scale || "ERROR");
}
}, [roomFromDb]);
// UI
// =================================
// Room is loading
if (!roomFromDbParsed) {
return <LoadingIndicator />;
// Room has been loaded
} else {
return roomFromDb ? (
<div className="flex flex-col gap-4 text-center justify-center items-center">
<div className="text-2xl">{roomFromDbParsed.roomName}</div>
<div className="flex flex-row flex-wrap text-center justify-center items-center gap-1 text-md">
<div>ID:</div>
<div>{roomFromDbParsed.id}</div>
<button>
{copied ? (
<CheckCircleIcon className="mx-1 text-success animate-bounce" />
) : (
<CopyIcon
className="mx-1 hover:text-primary"
onClick={copyRoomURLHandler}
/>
)}
</button>
</div>
{roomFromDb && (
<div className="card card-compact bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title mx-auto">
Story: {roomFromDbParsed.storyName}
</h2>
<ul className="p-0 flex flex-row flex-wrap justify-center items-center text-ceter gap-4">
{presenceData &&
presenceDateParsed
?.filter(
(value, index, self) =>
index ===
self.findIndex(
(presenceItem) => presenceItem.userId === value.userId
)
)
.map((presenceItem) => {
return (
<li
key={presenceItem.userId}
className="flex flex-row items-center justify-center gap-2"
>
<div className="w-10 rounded-full avatar">
<img
src={presenceItem.userImageUrl}
alt={`${presenceItem.userFullName}'s Profile Picture`}
height={32}
width={32}
/>
</div>
<p className="flex flex-row flex-wrap text-center justify-center items-center gap-1 text-md">
{presenceItem.userFullName}{" "}
{presenceItem.isAdmin && (
<span
className="tooltip tooltip-primary"
data-tip="Admin"
>
<ShieldIcon className="inline-block text-primary" />
</span>
)}{" "}
{presenceItem.isVIP && (
<span
className="tooltip tooltip-secondary"
data-tip="VIP"
>
<StarIcon className="inline-block text-secondary" />
</span>
)}{" "}
{presenceItem.userId ===
roomFromDbParsed?.userId && (
<span
className="tooltip tooltip-warning"
data-tip="Room Owner"
>
<CrownIcon className="inline-block text-yellow-500" />
</span>
)}
{" : "}
</p>
{roomFromDb &&
votesFromDb &&
voteString(
roomFromDbParsed?.visible!,
votesFromDbParsed,
presenceItem
)}
</li>
);
})}
</ul>
<div className="join md:btn-group-horizontal mx-auto">
{roomFromDbParsed.scale?.split(",").map((scaleItem, index) => {
return (
<button
key={index}
className={`join-item ${
getVoteForCurrentUser()?.value === scaleItem
? "btn btn-active btn-primary"
: "btn"
}`}
onClick={() => void setVoteHandler(scaleItem)}
>
{scaleItem}
</button>
);
})}
</div>
</div>
</div>
)}
{!!roomFromDbParsed &&
(roomFromDbParsed.userId === user?.id ||
isAdmin(user?.publicMetadata)) && (
<>
<div className="card card-compact bg-base-100 shadow-xl">
<div className="card-body flex flex-col flex-wrap">
<h2 className="card-title">Room Settings</h2>
<label className="label">
{"Vote Scale (Comma Separated):"}{" "}
</label>
<input
type="text"
placeholder="Scale (Comma Separated)"
className="input input-bordered"
value={roomScale}
onChange={(event) => {
setRoomScale(event.target.value);
}}
/>
<label className="label">{"Story Name:"} </label>
<input
type="text"
placeholder="Story Name"
className="input input-bordered"
value={storyNameText}
onChange={(event) => {
setStoryNameText(event.target.value);
}}
/>
<div className="flex flex-row flex-wrap text-center items-center justify-center gap-2">
<div>
<button
onClick={() =>
void setRoomHandler({
visible: !roomFromDbParsed?.visible,
reset: false,
log: false,
})
}
className="btn btn-primary inline-flex"
>
{roomFromDbParsed.visible ? (
<>
<EyeOffIcon className="text-xl mr-1" />
Hide
</>
) : (
<>
<EyeIcon className="text-xl mr-1" />
Show
</>
)}
</button>
</div>
<div>
<button
onClick={() =>
void setRoomHandler({
visible: false,
reset: true,
log:
roomFromDbParsed?.storyName === storyNameText ||
votesFromDb?.length === 0
? false
: true,
})
}
className="btn btn-primary inline-flex"
disabled={
[...new Set(roomScale.split(","))].filter(
(item) => item !== ""
).length <= 1
}
>
{roomFromDbParsed?.storyName === storyNameText ||
votesFromDb?.length === 0 ? (
<>
<RefreshCwIcon className="text-xl mr-1" /> Reset
</>
) : (
<>
<SaveIcon className="text-xl mr-1" /> Save
</>
)}
</button>
</div>
{votesFromDb &&
(roomFromDbParsed?.logs.length > 0 ||
votesFromDb.length > 0) && (
<div>
<button
onClick={() => downloadLogs()}
className="btn btn-primary inline-flex hover:animate-pulse"
>
<>
<DownloadIcon className="text-xl" />
</>
</button>
</div>
)}
</div>
</div>
</div>
</>
)}
</div>
) : (
<span className="text-center">
<h1 className="text-5xl font-bold m-2">404</h1>
<h1 className="text-5xl font-bold m-2">
Oops! This room does not appear to exist, or may have been deleted! 😢
</h1>
<Link
about="Back to home."
to="/"
className="btn btn-secondary normal-case text-xl m-2"
>
Back to Home
</Link>
</span>
);
}
}

10
app/routes/sign-in.$.tsx Normal file
View file

@ -0,0 +1,10 @@
import { SignIn } from "@clerk/remix";
export default function SignInPage() {
return (
<div>
<h1>Sign In route</h1>
<SignIn />
</div>
);
}

10
app/routes/sign-up.$.tsx Normal file
View file

@ -0,0 +1,10 @@
import { SignUp } from "@clerk/remix";
export default function SignUpPage() {
return (
<div>
<h1>Sign Up route</h1>
<SignUp />
</div>
);
}

10
app/services/db.server.ts Normal file
View file

@ -0,0 +1,10 @@
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import * as schema from "./schema";
const client = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_AUTH_TOKEN!,
});
export const db = drizzle(client, { schema });

View file

@ -0,0 +1,18 @@
import { EventEmitter } from "events";
let emitter: EventEmitter;
declare global {
var __emitter: EventEmitter | undefined;
}
if (process.env.NODE_ENV === "production") {
emitter = new EventEmitter();
} else {
if (!global.__emitter) {
global.__emitter = new EventEmitter();
}
emitter = global.__emitter;
}
export { emitter };

47
app/services/helpers.ts Normal file
View file

@ -0,0 +1,47 @@
import { json2csv } from "csv42";
export const jsonToCsv = (jsonObject: Array<object>, fileName: string) => {
const csv = json2csv(jsonObject);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", fileName);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
export function isAdmin(meta: UserPublicMetadata | undefined) {
return (meta?.isAdmin as boolean | undefined) || false;
}
export function isVIP(meta: UserPublicMetadata | undefined) {
return (meta?.isVIP as boolean | undefined) || false;
}
export const writeToLogs = (
level: "warn" | "info" | "error" | "success",
message: string
) => {
switch (level) {
case "info":
console.log(`[ INFO]: ${message}`);
break;
case "warn":
console.log(`[⚠️ WARN]: ${message}`);
break;
case "error":
console.log(`[❌ ERROR]: ${message}`);
break;
case "success":
console.log(`[✅ SUCCESS]: ${message}`);
break;
default:
console.log(`[ INFO]: ${message}`);
break;
}
};

95
app/services/schema.ts Normal file
View file

@ -0,0 +1,95 @@
import {
sqliteTable,
integer,
text,
unique,
index,
} from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm";
export const rooms = sqliteTable("Room", {
id: text("id", { length: 255 }).notNull().primaryKey(),
created_at: text("created_at"),
userId: text("userId", { length: 255 }).notNull(),
roomName: text("roomName", { length: 255 }),
storyName: text("storyName", { length: 255 }),
visible: integer("visible").default(0).notNull(),
scale: text("scale", { length: 255 }).default("0.5,1,2,3,5").notNull(),
});
export const roomsRelations = relations(rooms, ({ many }) => ({
votes: many(votes),
logs: many(logs),
}));
export const votes = sqliteTable(
"Vote",
{
id: text("id", { length: 255 }).notNull().primaryKey(),
created_at: text("created_at"),
userId: text("userId", { length: 255 }).notNull(),
roomId: text("roomId", { length: 255 })
.notNull()
.references(() => rooms.id, { onDelete: "cascade" }),
value: text("value", { length: 255 }).notNull(),
},
(table) => {
return {
unq: unique().on(table.userId, table.roomId),
userVoteIdx: index("user_vote_idx").on(table.userId),
};
}
);
export const votesRelations = relations(votes, ({ one }) => ({
room: one(rooms, {
fields: [votes.roomId],
references: [rooms.id],
}),
}));
export const logs = sqliteTable(
"Log",
{
id: text("id", { length: 255 }).notNull().primaryKey(),
created_at: text("created_at"),
userId: text("userId", { length: 255 }).notNull(),
roomId: text("roomId", { length: 255 }).notNull(),
scale: text("scale", { length: 255 }),
votes: text("votes"),
roomName: text("roomName", { length: 255 }),
storyName: text("storyName", { length: 255 }),
},
(table) => {
return {
userLogIdx: index("user_log_idx").on(table.userId),
};
}
);
export const logsRelations = relations(logs, ({ one }) => ({
room: one(rooms, {
fields: [logs.roomId],
references: [rooms.id],
}),
}));
export const presence = sqliteTable(
"Presence",
{
id: text("id", { length: 255 }).notNull().primaryKey(),
userId: text("userId", { length: 255 }).notNull(),
userFullName: text("userFullName", { length: 255 }).notNull(),
userImageUrl: text("userImageUrl", { length: 255 }).notNull(),
isVIP: integer("isVIP").default(0).notNull(),
isAdmin: integer("isAdmin").default(0).notNull(),
roomId: text("roomId", { length: 255 })
.notNull()
.references(() => rooms.id, { onDelete: "cascade" }),
},
(table) => {
return {
unq: unique().on(table.userId, table.roomId),
};
}
);

71
app/services/types.ts Normal file
View file

@ -0,0 +1,71 @@
type BetterEnum<T> = T[keyof T];
export const EventTypes = {
ROOM_LIST_UPDATE: "room.list.update",
ROOM_UPDATE: "room.update",
VOTE_UPDATE: "vote.update",
} as const;
export type EventType = BetterEnum<typeof EventTypes>;
export interface PresenceItem {
id: string;
userId: string;
userFullName: string;
userImageUrl: string;
roomId: string;
value: string;
isAdmin: boolean;
isVIP: boolean;
}
export type RoomsResponse =
| {
id: string;
createdAt: Date;
roomName: string;
}[]
| {
roomName: string | null;
id: string;
created_at: Date | null;
userId: string;
storyName: string | null;
visible: boolean;
scale: string;
}[]
| null
| undefined;
export type RoomResponse =
| {
id: string;
created_at: Date | null;
userId: string;
roomName: string | null;
storyName: string | null;
visible: boolean;
scale: string | null;
logs: {
id: string;
created_at: Date | null;
userId: string;
roomId: string;
roomName: string | null;
storyName: string | null;
scale: string | null;
votes: unknown;
}[];
}
| undefined
| null;
export type VoteResponse =
| {
id: string;
value: string;
created_at: Date | null;
userId: string;
roomId: string;
}[]
| null
| undefined;

View file

@ -0,0 +1,44 @@
import { eq } from "drizzle-orm";
import { db } from "./db.server";
import { rooms } from "./schema";
export const onUserDeletedHandler = async (userId: string | undefined) => {
if (!userId) {
return false;
}
try {
await db.delete(rooms).where(eq(rooms.userId, userId));
return true;
} catch (error) {
return false;
}
};
export const onUserCreatedHandler = async (userId: string | undefined) => {
if (!userId) {
return false;
}
const userUpdateResponse = await fetch(
`https://api.clerk.com/v1/users/${userId}/metadata`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.CLERK_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
public_metadata: {
isVIP: false,
isAdmin: false,
},
private_metadata: {},
unsafe_metadata: {},
}),
}
);
return userUpdateResponse.ok;
};

3
app/tailwind.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

13
drizzle.config.ts Normal file
View file

@ -0,0 +1,13 @@
import type { Config } from "drizzle-kit";
import "dotenv/config";
export default {
schema: "./app/services/schema.ts",
out: "./drizzle/generated",
driver: "turso",
breakpoints: true,
dbCredentials: {
url: `${process.env.DATABASE_URL}`,
authToken: `${process.env.DATABASE_AUTH_TOKEN}`,
},
} satisfies Config;

17
fly.toml Normal file
View file

@ -0,0 +1,17 @@
# fly.toml app configuration file generated for sprintpadawan on 2023-11-22T13:18:40-07:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = "sprintpadawan"
primary_region = "sea"
[build]
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ["app"]

49
package.json Normal file
View file

@ -0,0 +1,49 @@
{
"name": "sprintpadawan",
"version": "4.0.0",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build": "remix build",
"dev": "remix dev --manual",
"start": "remix-serve ./build/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@clerk/remix": "^3.1.5",
"@libsql/client": "0.4.0-pre.2",
"@paralleldrive/cuid2": "^2.2.2",
"@remix-run/css-bundle": "^2.3.1",
"@remix-run/node": "^2.3.1",
"@remix-run/react": "^2.3.1",
"@remix-run/serve": "^2.3.1",
"ably": "1.2.48",
"csv42": "^5.0.0",
"drizzle-orm": "^0.29.0",
"ioredis": "^5.3.2",
"isbot": "^3.7.1",
"lucide-react": "^0.292.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remix-utils": "^7.1.0",
"svix": "^1.14.0"
},
"devDependencies": {
"@flydotio/dockerfile": "^0.4.11",
"@remix-run/dev": "^2.3.1",
"@remix-run/eslint-config": "^2.3.1",
"@types/react": "^18.2.38",
"@types/react-dom": "^18.2.17",
"better-sqlite3": "^9.1.1",
"daisyui": "^4.4.2",
"dotenv": "^16.3.1",
"drizzle-kit": "^0.20.4",
"eslint": "^8.54.0",
"tailwindcss": "^3.3.5",
"typescript": "^5.3.2"
},
"engines": {
"node": ">=18.0.0"
}
}

8335
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

9
remix.config.js Normal file
View file

@ -0,0 +1,9 @@
/** @type {import('@remix-run/dev').AppConfig} */
export default {
ignoredRouteFiles: ["**/.*"],
serverDependenciesToBundle: [/^ably\/react/],
// appDirectory: "app",
// assetsBuildDirectory: "public/build",
// publicPath: "/build/",
// serverBuildPath: "build/index.js",
};

2
remix.env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node" />

9
tailwind.config.ts Normal file
View file

@ -0,0 +1,9 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [require("daisyui")],
} satisfies Config;

22
tsconfig.json Normal file
View file

@ -0,0 +1,22 @@
{
"include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ESNext",
"strict": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
// Remix takes care of building everything in `remix build`.
"noEmit": true
}
}