diff --git a/.env.prod b/.env.prod
new file mode 100644
index 0000000..5b0e01e
--- /dev/null
+++ b/.env.prod
@@ -0,0 +1,9 @@
+NODE_ENV=production
+
+POSTGRES_USER=friends
+POSTGRES_PASSWORD=password
+POSTGRES_DB=friends
+POSTGRES_HOSTNAME=friendsdbprod
+POSTGRES_PORT=5432
+
+DATABASE_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOSTNAME:$POSTGRES_PORT/$POSTGRES_DB
diff --git a/client/index.html b/client/index.html
index 71799b5..dc4d2f6 100644
--- a/client/index.html
+++ b/client/index.html
@@ -1,17 +1,16 @@
-
-
-
-
+
+
+
+
- Simponic's Friends
-
+ Lizzy's Friends
+
-
-
-
+
+
+
+
diff --git a/client/package-lock.json b/client/package-lock.json
index 4738066..8f62b35 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -19,6 +19,8 @@
"devDependencies": {
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
+ "@types/react-mentions": "^4.1.8",
+ "@types/react-modal": "^3.13.1",
"@vitejs/plugin-react": "^3.1.0",
"typescript": "^4.9.3",
"vite": "^4.2.0"
@@ -826,6 +828,24 @@
"@types/react": "*"
}
},
+ "node_modules/@types/react-mentions": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@types/react-mentions/-/react-mentions-4.1.8.tgz",
+ "integrity": "sha512-Go86ozdnh0FTNbiGiDPAcNqYqtab9iGzLOgZPYUKrnhI4539jGzfJtP6rFHcXgi9Koe58yhkeyKYib6Ucul/sQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/react-modal": {
+ "version": "3.13.1",
+ "resolved": "https://registry.npmjs.org/@types/react-modal/-/react-modal-3.13.1.tgz",
+ "integrity": "sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg==",
+ "dev": true,
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/scheduler": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
diff --git a/client/package.json b/client/package.json
index dd29d51..79f4e84 100644
--- a/client/package.json
+++ b/client/package.json
@@ -20,6 +20,8 @@
"devDependencies": {
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
+ "@types/react-mentions": "^4.1.8",
+ "@types/react-modal": "^3.13.1",
"@vitejs/plugin-react": "^3.1.0",
"typescript": "^4.9.3",
"vite": "^4.2.0"
diff --git a/client/public/people-hugging.svg b/client/public/people-hugging.svg
new file mode 100644
index 0000000..03eaa07
--- /dev/null
+++ b/client/public/people-hugging.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/public/vite.svg b/client/public/vite.svg
deleted file mode 100644
index e7b8dfb..0000000
--- a/client/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/client/src/assets/react.svg b/client/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/client/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/client/src/components/timerCard.tsx b/client/src/components/timerCard.tsx
index 55fee4a..b58ca98 100644
--- a/client/src/components/timerCard.tsx
+++ b/client/src/components/timerCard.tsx
@@ -1,28 +1,42 @@
-import { ago } from "../utils/ago";
import { useEffect, useState } from "react";
+import { ago } from "../utils/ago";
+import { TimerResponse, Friend, TimersFilter } from "../utils/types";
-const replaceReferencedFriendsInName = (name, referencedFriends, onSelect) => {
- const friendIdToFriend = referencedFriends.reduce((friendMap, friend) => {
- friendMap[friend.id] = friend;
- return friendMap;
- }, {});
- return name.split(/(@\<\d+\>)/g).map((s) => {
+const replaceReferencedFriendsInName = (
+ name: string,
+ referencedFriends: Friend[],
+ onSelect: (select?: TimersFilter) => void
+) => {
+ const friendIdToFriend = referencedFriends.reduce(
+ (friendMap: Record, friend) => {
+ friendMap[friend.id.toString()] = friend;
+ return friendMap;
+ },
+ {}
+ );
+
+ return name.split(/(@\<\d+\>)/g).map((s: string) => {
const matches = /@\<(\d+)\>/g.exec(s);
if (matches) {
const [_match, id] = matches;
const name = friendIdToFriend[id].name;
- return onSelect({ friendId: id })}>{name};
+ return onSelect({ friendId: Number(id) })}>{name};
}
return s;
});
};
-export default function TimerCard({ timer, onSelect }) {
- const [since, setSince] = useState(ago(timer.start));
+export type TimerCardProps = {
+ timer: TimerResponse;
+ onSelect: (select?: TimersFilter) => void;
+};
+
+export default function TimerCard({ timer, onSelect }: TimerCardProps) {
+ const [since, setSince] = useState(ago(timer.start));
useEffect(() => {
- let updateTimersInterval;
+ let updateTimersInterval: ReturnType;
const msTillNextSecond = 1000 - (timer.start.getTime() % 1000);
setTimeout(() => {
@@ -42,8 +56,8 @@ export default function TimerCard({ timer, onSelect }) {
timer.name,
timer.referenced_friends,
onSelect
- ).map((s, i) => (
- {s}
+ ).map((element: JSX.Element | string, i: number) => (
+ {element}
))}
);
diff --git a/client/src/components/timerHeader.tsx b/client/src/components/timerHeader.tsx
index 5f4e679..358974f 100644
--- a/client/src/components/timerHeader.tsx
+++ b/client/src/components/timerHeader.tsx
@@ -4,20 +4,31 @@ import { Mention, MentionsInput } from "react-mentions";
import { useAuthContext } from "../context/authContext";
import mentionStyles from "../styles/mention";
import modalStyles from "../styles/modal";
+import { Friend, TimersFilter, TimerResponse } from "../utils/types";
Modal.setAppElement("#root");
-export default function TimerHeader({ friends, selected, onSelect }) {
- const [modalOpen, setModalOpen] = useState(false);
- const [newTimerName, setNewTimerName] = useState("");
- const [errors, setErrors] = useState([]);
+export type TimerHeaderProps = {
+ friends: Friend[];
+ selected?: TimersFilter;
+ onSelect: (selected?: TimersFilter) => void;
+};
+
+export default function TimerHeader({
+ friends,
+ selected,
+ onSelect,
+}: TimerHeaderProps) {
+ const [modalOpen, setModalOpen] = useState(false);
+ const [newTimerName, setNewTimerName] = useState("");
+ const [errors, setErrors] = useState([]);
const { friendName, setSignedIn } = useAuthContext();
const logout = () => {
fetch("/api/auth/logout").then(() => setSignedIn(false));
};
- const createTimer = (e) => {
+ const createTimer = (e: any) => {
e.preventDefault();
fetch("/api/timers", {
@@ -33,7 +44,7 @@ export default function TimerHeader({ friends, selected, onSelect }) {
},
})
.then((r) => r.json())
- .then((r) => {
+ .then((r: TimerResponse) => {
if (r.message) {
setErrors([r.message]);
return;
@@ -72,10 +83,11 @@ export default function TimerHeader({ friends, selected, onSelect }) {
setNewTimerName(e.target.value)}
+ onChange={(e: any) => setNewTimerName(e.target.value)}
>
({
+ trigger="@"
+ data={friends.map(({ id, name }: Friend) => ({
id: `@<${id}>`,
display: `@${name}`,
}))}
@@ -108,7 +120,7 @@ export default function TimerHeader({ friends, selected, onSelect }) {
>
all
- {friends.map((friend) => (
+ {friends.map((friend: Friend) => (
{
diff --git a/client/src/context/authContext.tsx b/client/src/context/authContext.tsx
index d5f0635..54f89a0 100644
--- a/client/src/context/authContext.tsx
+++ b/client/src/context/authContext.tsx
@@ -38,7 +38,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
useEffect(() => {
if (friendId) {
- localStorage.setItem("friendId", friendId);
+ localStorage.setItem("friendId", friendId.toString());
}
}, [friendId]);
diff --git a/client/src/hooks/useInitialData.ts b/client/src/hooks/useInitialData.ts
index 815f289..bf601df 100644
--- a/client/src/hooks/useInitialData.ts
+++ b/client/src/hooks/useInitialData.ts
@@ -1,4 +1,4 @@
-import { io } from "socket.io-client";
+import { io, Socket } from "socket.io-client";
import { useState, useEffect } from "react";
export interface UseInitialDataProps {
@@ -11,7 +11,7 @@ export const useInitialData = (props: UseInitialDataProps) => {
const [data, setData] = useState();
const [query, setQuery] = useState(props?.query);
const [endpoint, setEndpoint] = useState(props.initialDataEndpoint);
- const [socket, setSocket] = useState();
+ const [socket, setSocket] = useState();
const refreshData = () =>
fetch(endpoint)
diff --git a/client/src/main.tsx b/client/src/main.tsx
index da866e2..9238530 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -6,7 +6,7 @@ import { AuthProvider } from "./context/authContext";
import NotFound from "./routes/notFound";
import Login from "./routes/login";
import Timers from "./routes/timers";
-import ProtectedRoute from "./routes/protected.tsx";
+import ProtectedRoute from "./routes/protected";
import "chota";
import "./styles/index.css";
diff --git a/client/src/routes/login.tsx b/client/src/routes/login.tsx
index c466196..0a58290 100644
--- a/client/src/routes/login.tsx
+++ b/client/src/routes/login.tsx
@@ -1,9 +1,12 @@
import { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import { useAuthContext } from "../context/authContext";
+import { SignThisTokenResponse, TokenResponse } from "../utils/types";
import "../styles/login.css";
-const requestTokenSubmit = async (name) =>
+const requestTokenSubmit = async (
+ name: string
+): Promise =>
fetch(
"/api/auth?" +
new URLSearchParams({
@@ -11,7 +14,7 @@ const requestTokenSubmit = async (name) =>
})
).then((r) => r.json());
-const submitSignedToken = async (signature) =>
+const submitSignedToken = async (signature: string): Promise =>
fetch("/api/auth", {
method: "POST",
body: JSON.stringify({
@@ -23,41 +26,43 @@ const submitSignedToken = async (signature) =>
}).then((r) => r.json());
export default function Login() {
- const [token, setToken] = useState("");
- const [errors, setErrors] = useState([]);
+ const [token, setToken] = useState("");
+ const [errors, setErrors] = useState([]);
const { signedIn, setSignedIn, setSessionOver, setFriendId, setFriendName } =
useAuthContext();
- const getTokenFormSubmission = async (e) => {
+ const getTokenFormSubmission = async (e: any) => {
e.preventDefault();
const { error, message, token } = await requestTokenSubmit(
e.target.name.value
);
- if (error && message) {
+ if (message && error) {
setErrors([message]);
return;
}
- setErrors([]);
- setToken(token);
+ if (token) {
+ setErrors([]);
+ setToken(token);
+ }
};
- const signTokenFormSubmission = async (e) => {
+ const signTokenFormSubmission = async (e: any) => {
e.preventDefault();
const { error, message, token, expiration, friend } =
await submitSignedToken(e.target.signature.value);
- if (token) {
+ if (token && expiration && friend) {
setSignedIn(true);
setSessionOver(new Date(expiration));
- setFriendId(friend.id.toString());
+ setFriendId(friend.id);
setFriendName(friend.name);
return;
}
- if (error & message) {
- setErrors([message]);
+ if (error && message) {
+ setErrors([message as string]);
}
};
@@ -97,7 +102,7 @@ export default function Login() {
diff --git a/client/src/routes/protected.tsx b/client/src/routes/protected.tsx
index a0f4dbd..8c29b16 100644
--- a/client/src/routes/protected.tsx
+++ b/client/src/routes/protected.tsx
@@ -1,7 +1,12 @@
+import { ReactElement } from "react";
import { Navigate } from "react-router-dom";
import { useAuthContext } from "../context/authContext";
-export default function ProtectedRoute({ children }) {
+export type ProtectedRouteProps = {
+ children: ReactElement;
+};
+
+export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { signedIn } = useAuthContext();
if (!signedIn) return ;
diff --git a/client/src/routes/timers.tsx b/client/src/routes/timers.tsx
index 69f9bbb..92f8bf9 100644
--- a/client/src/routes/timers.tsx
+++ b/client/src/routes/timers.tsx
@@ -4,24 +4,9 @@ import TimerCard from "../components/timerCard";
import TimerHeader from "../components/timerHeader";
import { useInitialData } from "../hooks/useInitialData";
-export type TimersFilter = {
- friendId: undefined | number; // when undefined, get all
-};
+import { Friend, TimersFilter, TimerResponse } from "../utils/types";
-export type Friend = {
- id: number;
- name: number;
-};
-
-export type TimerResponse = {
- id: number;
- name: string;
- start: Date;
- created_by: Friend;
- referenced_friends: Friend[];
-};
-
-const makeEndpoint = (filter: TimersFilter) => {
+const makeEndpoint = (filter?: TimersFilter) => {
let url = "/api/timers";
if (filter && typeof filter.friendId !== "undefined")
url += `/friend?id=${filter.friendId}`;
@@ -38,20 +23,19 @@ export default function Timers() {
socket,
setEndpoint,
} = useInitialData({
- initialDataEndpoint: makeEndpoint({}),
+ initialDataEndpoint: makeEndpoint(),
namespace: "/events/timers",
- query: {},
});
- const [friends, setFriends] = useState([]);
- const [selected, setSelected] = useState();
+ const [friends, setFriends] = useState([]);
+ const [selected, setSelected] = useState();
useEffect(() => {
fetch("/api/auth/friends")
.then((r) => r.json())
- .then((friends) => setFriends(friends));
+ .then((friends: Friend[]) => setFriends(friends));
}, []);
- const onSelect = (selected: TimersFilter) => {
+ const onSelect = (selected?: TimersFilter) => {
setSelected(selected);
setEndpoint(makeEndpoint(selected));
setQuery(selected);
@@ -60,7 +44,7 @@ export default function Timers() {
useEffect(() => {
socket?.on("refreshed", (newTimer: TimerResponse) => {
setTimers((timers) =>
- timers.map((timer) => {
+ timers?.map((timer) => {
if (timer.id === newTimer.id) return newTimer;
return timer;
})
@@ -68,7 +52,12 @@ export default function Timers() {
});
socket?.on("created", (newTimer: TimerResponse) => {
- setTimers((timers) => [...timers, newTimer]);
+ setTimers((timers) => {
+ if (timers) {
+ return [...timers, newTimer];
+ }
+ return [newTimer];
+ });
});
}, [socket]);
@@ -81,7 +70,12 @@ export default function Timers() {
...timer,
start: new Date(timer.start),
}))
- .sort(({ start: startA }, { start: startB }) => startB - startA)
+ .sort(
+ (
+ { start: startA }: { start: Date },
+ { start: startB }: { start: Date }
+ ) => startB.getTime() - startA.getTime()
+ )
.map((timer) => (
))
diff --git a/client/src/utils/ago.ts b/client/src/utils/ago.ts
index 4620d3c..3e58978 100644
--- a/client/src/utils/ago.ts
+++ b/client/src/utils/ago.ts
@@ -1,5 +1,5 @@
// thanks, chatgpt
-export function ago(date) {
+export function ago(date: Date) {
const timeElapsed = Date.now() - date.getTime();
const days = Math.floor(timeElapsed / (1000 * 60 * 60 * 24));
const hours = Math.floor(
diff --git a/client/src/utils/types.ts b/client/src/utils/types.ts
new file mode 100644
index 0000000..4d9a0cd
--- /dev/null
+++ b/client/src/utils/types.ts
@@ -0,0 +1,32 @@
+export type TimersFilter = {
+ friendId: undefined | number; // when undefined, get all
+};
+
+export type Friend = {
+ id: number;
+ name: string;
+};
+
+export type TimerResponse = {
+ error?: string;
+ message?: string;
+ id: number;
+ name: string;
+ start: Date;
+ created_by: Friend;
+ referenced_friends: Friend[];
+};
+
+export type TokenResponse = {
+ error?: string;
+ message?: string;
+ token?: string;
+ expiration?: string;
+ friend: Friend;
+};
+
+export type SignThisTokenResponse = {
+ error?: string;
+ message?: string;
+ token?: string;
+};
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 45c2f82..1d0c0e6 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -44,7 +44,6 @@ services:
container_name: friendsclient
build:
context: ./client
- target: development
dockerfile: Dockerfile.dev
networks:
- app
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..190231d
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,45 @@
+version: '3'
+
+services:
+ postgres:
+ container_name: friendsdbprod
+ image: postgres:15
+ restart: always
+ env_file: .env.prod
+ networks:
+ - db
+ volumes:
+ - pgdata:/var/lib/postgresql/data/
+
+ nginx:
+ container_name: friendsproxyprod
+ restart: always
+ ports:
+ - 25822:80
+ networks:
+ - app
+ depends_on:
+ - server
+ build:
+ context: ./
+ dockerfile: ./nginx/Dockerfile.prod
+
+ server:
+ container_name: friendsserver
+ restart: always
+ env_file: .env.prod
+ build:
+ context: ./server
+ dockerfile: Dockerfile.prod
+ networks:
+ - app
+ - db
+
+networks:
+ app:
+ driver: bridge
+ db:
+ driver: bridge
+
+volumes:
+ pgdata:
diff --git a/nginx/Dockerfile.prod b/nginx/Dockerfile.prod
new file mode 100644
index 0000000..2b1006a
--- /dev/null
+++ b/nginx/Dockerfile.prod
@@ -0,0 +1,17 @@
+FROM node:16-alpine as build
+
+WORKDIR /app
+COPY ./client/ /app/
+
+# prepare for build
+RUN npm install --silent
+RUN npm run build
+
+# now, start nginx
+
+FROM nginx
+RUN rm /etc/nginx/conf.d/default.conf
+COPY ./nginx/nginx.prod.conf /etc/nginx/conf.d/nginx.conf
+COPY --from=build /app/dist /usr/share/nginx/html
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/nginx/nginx.prod.conf b/nginx/nginx.prod.conf
new file mode 100644
index 0000000..c2bd1cc
--- /dev/null
+++ b/nginx/nginx.prod.conf
@@ -0,0 +1,32 @@
+server {
+ listen 80;
+
+ location /api {
+ proxy_pass http://friendsserver:4000;
+
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $host;
+
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ location /socket.io {
+ proxy_pass http://friendsserver:4000;
+
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $host;
+
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ }
+
+ location / {
+ root /usr/share/nginx/html;
+ index index.html index.htm index.nginx-debian.html;
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index 65b1d9f..0000000
--- a/package-lock.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "name": "friends",
- "lockfileVersion": 2,
- "requires": true,
- "packages": {}
-}
diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev
index 57bdcb2..31c7771 100644
--- a/server/Dockerfile.dev
+++ b/server/Dockerfile.dev
@@ -1,4 +1,4 @@
-FROM node:16.20.0-alpine AS development
+FROM node:16-alpine
WORKDIR /usr/src/app
@@ -10,5 +10,4 @@ RUN npm install --only=development
COPY . .
-
CMD npx -y prisma generate ; npm run start:dev
diff --git a/server/Dockerfile.prod b/server/Dockerfile.prod
new file mode 100644
index 0000000..fa70e78
--- /dev/null
+++ b/server/Dockerfile.prod
@@ -0,0 +1,34 @@
+FROM node:16-alpine AS development
+WORKDIR /usr/src/app
+COPY --chown=node:node package*.json ./
+
+RUN apk add --no-cache python3 make gcc g++
+
+RUN npm ci
+COPY --chown=node:node . .
+USER node
+
+FROM node:16-alpine AS build
+WORKDIR /usr/src/app
+COPY --chown=node:node package*.json ./
+COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules
+COPY --chown=node:node . .
+
+RUN npx -y prisma generate
+RUN npm run build
+
+ENV NODE_ENV production
+RUN apk add --no-cache python3 make gcc g++
+RUN npm ci --omit=dev && npm cache clean --force
+USER node
+
+FROM node:16-alpine AS production
+WORKDIR /usr/src/app
+
+RUN apk add --no-cache python3
+
+COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules
+COPY --chown=node:node --from=build /usr/src/app/prisma ./prisma
+COPY --chown=node:node --from=build /usr/src/app/dist ./dist
+
+CMD npx -y prisma migrate deploy ; node dist/main.js
diff --git a/server/src/auth/words.ts b/server/src/auth/words.ts
index 22c0c4c..f980230 100644
--- a/server/src/auth/words.ts
+++ b/server/src/auth/words.ts
@@ -173,7 +173,6 @@ export default [
"start",
"hand",
"might",
- "American",
"show",
"part",
"about",
@@ -542,7 +541,6 @@ export default [
"court",
"produce",
"eat",
- "American",
"teach",
"oil",
"half",
@@ -1208,7 +1206,6 @@ export default [
"beginning",
"date",
"generally",
- "African",
"very",
"sorry",
"crisis",
@@ -1279,7 +1276,6 @@ export default [
"Ms",
"contract",
"crowd",
- "Christian",
"express",
"apartment",
"willing",
@@ -1810,7 +1806,6 @@ export default [
"influence",
"surgery",
"correct",
- "Jewish",
"blame",
"estimate",
"due",
@@ -2023,7 +2018,6 @@ export default [
"expose",
"rural",
"AIDS",
- "Jew",
"narrow",
"cream",
"secretary",
@@ -2616,7 +2610,6 @@ export default [
"phrase",
"ingredient",
"stake",
- "Muslim",
"dream",
"fiber",
"activist",
@@ -3191,7 +3184,6 @@ export default [
"versus",
"manufacturing",
"risk",
- "Christian",
"complex",
"absolute",
"chef",
@@ -3341,7 +3333,6 @@ export default [
"rope",
"concrete",
"prescription",
- "African-American",
"chase",
"document",
"brick",
@@ -3547,8 +3538,6 @@ export default [
"administrative",
"elbow",
"deadly",
- "Muslim",
- "Hispanic",
"allegation",
"tip",
"confuse",
@@ -4337,7 +4326,6 @@ export default [
"breeze",
"costly",
"ambitious",
- "Christianity",
"presumably",
"influential",
"translation",