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