Old stuff
This commit is contained in:
commit
b086f719c8
45 changed files with 10423 additions and 0 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
/public/build
|
||||
.env
|
24
.env.example
Normal file
24
.env.example
Normal 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
4
.eslintrc.cjs
Normal 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
15
.github/workflows/fly.yml
vendored
Normal 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
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
/public/build
|
||||
.env
|
1
.npmrc
Normal file
1
.npmrc
Normal file
|
@ -0,0 +1 @@
|
|||
auto-install-peers=true
|
49
Dockerfile
Normal file
49
Dockerfile
Normal 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
38
README.md
Normal 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
33
app/components/Footer.tsx
Normal 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
59
app/components/Header.tsx
Normal 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;
|
9
app/components/LoadingIndicator.tsx
Normal file
9
app/components/LoadingIndicator.tsx
Normal 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
18
app/entry.client.tsx
Normal 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
137
app/entry.server.tsx
Normal 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
61
app/root.tsx
Normal 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
40
app/routes/_index.tsx
Normal 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>
|
||||
);
|
||||
}
|
43
app/routes/api.room.create.tsx
Normal file
43
app/routes/api.room.create.tsx
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
42
app/routes/api.room.delete.$roomId.tsx
Normal file
42
app/routes/api.room.delete.$roomId.tsx
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
66
app/routes/api.room.get.$roomId.tsx
Normal file
66
app/routes/api.room.get.$roomId.tsx
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
44
app/routes/api.room.get.all.tsx
Normal file
44
app/routes/api.room.get.all.tsx
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
82
app/routes/api.room.presence.get.$roomId.tsx
Normal file
82
app/routes/api.room.presence.get.$roomId.tsx
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
87
app/routes/api.room.set.$roomId.tsx
Normal file
87
app/routes/api.room.set.$roomId.tsx
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
50
app/routes/api.vote.set.$roomId.tsx
Normal file
50
app/routes/api.vote.set.$roomId.tsx
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
55
app/routes/api.votes.get.$roomId.tsx
Normal file
55
app/routes/api.votes.get.$roomId.tsx
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
79
app/routes/api.webhooks.clerk.ts
Normal file
79
app/routes/api.webhooks.clerk.ts
Normal 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
155
app/routes/dashboard.tsx
Normal 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
456
app/routes/room.$roomId.tsx
Normal 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">4️⃣0️⃣4️⃣</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
10
app/routes/sign-in.$.tsx
Normal 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
10
app/routes/sign-up.$.tsx
Normal 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
10
app/services/db.server.ts
Normal 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 });
|
18
app/services/emitter.server.ts
Normal file
18
app/services/emitter.server.ts
Normal 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
47
app/services/helpers.ts
Normal 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
95
app/services/schema.ts
Normal 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
71
app/services/types.ts
Normal 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;
|
44
app/services/webhookhelpers.server.ts
Normal file
44
app/services/webhookhelpers.server.ts
Normal 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
3
app/tailwind.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
13
drizzle.config.ts
Normal file
13
drizzle.config.ts
Normal 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
17
fly.toml
Normal 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
49
package.json
Normal 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
8335
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
public/logo.webp
Normal file
BIN
public/logo.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
9
remix.config.js
Normal file
9
remix.config.js
Normal 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
2
remix.env.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="@remix-run/dev" />
|
||||
/// <reference types="@remix-run/node" />
|
9
tailwind.config.ts
Normal file
9
tailwind.config.ts
Normal 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
22
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue