More metadata, change name to mafap

This commit is contained in:
Elizabeth Hunt 2023-04-05 00:30:03 -06:00
parent 9e99bf0a32
commit 98d984e5f4
Signed by: simponic
GPG Key ID: 52B3774857EB24B1
18 changed files with 344 additions and 165 deletions

View File

@ -1,9 +0,0 @@
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

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ node_modules
# Keep environment variables out of version control # Keep environment variables out of version control
.env .env
.env.dev .env.dev
.env.prod

11
TODO.md Normal file
View File

@ -0,0 +1,11 @@
If people end up using this, more features to add:
+ Update / delete timers, if referenced friend or created by
- move creation modal to another form component
+ Show history of past times refreshed - more details page. Part of this is
done already at `/api/timers/:id/refreshes`.
And bad decisions to fix:
+ friend-tabs should queried from a url path, not from this weird passed-down
on-select bullshit, then we can use `<Link>`s

View File

@ -1,16 +1,14 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/people-hugging.svg" /> <link rel="icon" type="image/svg+xml" href="/people-hugging.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content=
"width=device-width, initial-scale=1.0">
<title>Lizzy's Friends</title> <title>MAFAP - My Awesome Friends Are Predictable</title>
</head> </head>
<body>
<body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@ -27,38 +27,90 @@ const replaceReferencedFriendsInName = (
}); });
}; };
const refreshTimer = (id: number) =>
fetch(`/api/timers/${id}/refresh`, {
method: "POST",
});
export type TimerCardProps = { export type TimerCardProps = {
timer: TimerResponse; timer: TimerResponse;
onSelect: (select?: TimersFilter) => void; onSelect: (select?: TimersFilter) => void;
}; };
export default function TimerCard({ timer, onSelect }: TimerCardProps) { export default function TimerCard({ timer, onSelect }: TimerCardProps) {
const [since, setSince] = useState<string>(ago(timer.start)); const [since, setSince] = useState<string>("");
useEffect(() => { useEffect(() => {
const start = new Date(timer.start);
let updateTimersInterval: ReturnType<typeof setInterval>; let updateTimersInterval: ReturnType<typeof setInterval>;
const msTillNextSecond = 1000 - (timer.start.getTime() % 1000); const msTillNextSecond = 1000 - (start.getTime() % 1000);
setSince(ago(start));
setTimeout(() => { setTimeout(() => {
updateTimersInterval = setInterval( updateTimersInterval = setInterval(() => setSince(ago(start)), 1_000);
() => setSince(ago(timer.start)),
1_000
);
}, msTillNextSecond); }, msTillNextSecond);
return () => clearInterval(updateTimersInterval); return () => clearInterval(updateTimersInterval);
}, []); }, [timer.start]);
return ( return (
<h1> <div className="card grid-card">
<code>{since}</code>{" "} <div>
<header>
<h4 className="is-center">
<code>{since || "..."}</code>
</h4>
</header>
<p>
{replaceReferencedFriendsInName( {replaceReferencedFriendsInName(
timer.name, timer.name,
timer.referenced_friends, timer.referenced_friends,
onSelect onSelect
).map((element: JSX.Element | string, i: number) => ( ).map((element: JSX.Element | string, i: number) => (
<span key={i}>{element}</span> <span style={{ overflowWrap: "anywhere", hyphens: "auto" }} key={i}>
{element}
</span>
))} ))}
</h1> </p>
</div>
<div className="timer-metadata text-grey italic">
<div>
<a
onClick={() =>
onSelect({ friendId: timer.timer_refreshes[0].refreshed_by.id })
}
>
{" "}
{timer.created_by.name}
</a>{" "}
is tracking this
</div>
<div>
{timer.timer_refreshes && timer.timer_refreshes.length ? (
<span>
<a
onClick={() =>
onSelect({
friendId: timer.timer_refreshes[0].refreshed_by.id,
})
}
>
{timer.timer_refreshes[0].refreshed_by.name}
</a>{" "}
refreshed it last
</span>
) : (
"has not yet been refreshed..."
)}
</div>
<button
onClick={() => refreshTimer(timer.id)}
className="button outline"
>
Refresh
</button>
</div>
</div>
); );
} }

View File

@ -2,11 +2,9 @@ import Modal from "react-modal";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Mention, MentionsInput } from "react-mentions"; import { Mention, MentionsInput } from "react-mentions";
import { useAuthContext } from "../context/authContext"; import { useAuthContext } from "../context/authContext";
import { Friend, TimersFilter, TimerResponse } from "../utils/types";
import mentionStyles from "../styles/mention"; import mentionStyles from "../styles/mention";
import modalStyles from "../styles/modal"; import modalStyles from "../styles/modal";
import { Friend, TimersFilter, TimerResponse } from "../utils/types";
Modal.setAppElement("#root");
export type TimerHeaderProps = { export type TimerHeaderProps = {
friends: Friend[]; friends: Friend[];
@ -62,17 +60,14 @@ export default function TimerHeader({
onRequestClose={() => setModalOpen(false)} onRequestClose={() => setModalOpen(false)}
style={modalStyles} style={modalStyles}
> >
<div id="createTimerModal">
<div> <div>
<div <div className="my-modal-header">
style={{ <div>
display: "flex", <h4>New Timer</h4>
justifyContent: "space-between", <p>
alignItems: "center", Use <code>@</code> and the autocomplete menu to reference a user
marginBottom: "1rem", </p>
}} </div>
>
<h4 style={{ margin: "none" }}>New Timer</h4>
<a onClick={() => setModalOpen(false)} className="button outline"> <a onClick={() => setModalOpen(false)} className="button outline">
&times; &times;
@ -81,6 +76,7 @@ export default function TimerHeader({
<div> <div>
<form onSubmit={createTimer}> <form onSubmit={createTimer}>
<MentionsInput <MentionsInput
placeholder="since @..."
style={mentionStyles} style={mentionStyles}
value={newTimerName} value={newTimerName}
onChange={(e: any) => setNewTimerName(e.target.value)} onChange={(e: any) => setNewTimerName(e.target.value)}
@ -106,7 +102,6 @@ export default function TimerHeader({
</form> </form>
</div> </div>
</div> </div>
</div>
</Modal> </Modal>
<nav className="nav"> <nav className="nav">
@ -135,8 +130,8 @@ export default function TimerHeader({
</div> </div>
<div className="nav-right"> <div className="nav-right">
<a <a
onClick={() => setModalOpen(true)}
style={{ marginTop: "1rem" }} style={{ marginTop: "1rem" }}
onClick={() => setModalOpen(true)}
className="button outline" className="button outline"
> >
+ +
@ -145,7 +140,7 @@ export default function TimerHeader({
<summary style={{ marginTop: "1rem" }} className="button outline"> <summary style={{ marginTop: "1rem" }} className="button outline">
{friendName} {friendName}
</summary> </summary>
<a className="button outline text-error" onClick={logout}> <a className="button outline text-error bg-light" onClick={logout}>
Logout Logout
</a> </a>
</details> </details>

View File

@ -25,7 +25,7 @@ const AuthContext = createContext<authContext>({
export const useAuthContext = () => useContext(AuthContext); export const useAuthContext = () => useContext(AuthContext);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => { export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [signedIn, setSignedIn] = useState<boolean>(false); const [signedIn, setSignedIn] = useState<boolean | undefined>();
const [sessionOver, setSessionOver] = useState<Date>(new Date()); const [sessionOver, setSessionOver] = useState<Date>(new Date());
const [friendId, setFriendId] = useState<number | null>(null); const [friendId, setFriendId] = useState<number | null>(null);
const [friendName, setFriendName] = useState<string | null>(null); const [friendName, setFriendName] = useState<string | null>(null);

View File

@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import Modal from "react-modal";
import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { AuthProvider } from "./context/authContext"; import { AuthProvider } from "./context/authContext";
@ -27,6 +28,8 @@ const router = createBrowserRouter([
}, },
]); ]);
Modal.setAppElement("#root");
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<AuthProvider> <AuthProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import { useAuthContext } from "../context/authContext"; import { useAuthContext } from "../context/authContext";
import { Navigate } from "react-router-dom";
import { SignThisTokenResponse, TokenResponse } from "../utils/types"; import { SignThisTokenResponse, TokenResponse } from "../utils/types";
import "../styles/login.css"; import "../styles/login.css";
@ -28,6 +28,7 @@ const submitSignedToken = async (signature: string): Promise<TokenResponse> =>
export default function Login() { export default function Login() {
const [token, setToken] = useState<string>(""); const [token, setToken] = useState<string>("");
const [errors, setErrors] = useState<string[]>([]); const [errors, setErrors] = useState<string[]>([]);
const [authFinished, setAuthFinished] = useState<boolean>(false);
const { signedIn, setSignedIn, setSessionOver, setFriendId, setFriendName } = const { signedIn, setSignedIn, setSessionOver, setFriendId, setFriendName } =
useAuthContext(); useAuthContext();
@ -58,6 +59,8 @@ export default function Login() {
setFriendId(friend.id); setFriendId(friend.id);
setFriendName(friend.name); setFriendName(friend.name);
setAuthFinished(true);
return; return;
} }
@ -66,10 +69,6 @@ export default function Login() {
} }
}; };
if (signedIn) {
return <Navigate to="/" />;
}
if (!token) if (!token)
return ( return (
<div className="body-centered"> <div className="body-centered">
@ -87,11 +86,12 @@ export default function Login() {
<></> <></>
)} )}
<button type="submit">Request Token</button> <button type="submit">Request A Token</button>
</div> </div>
</form> </form>
</div> </div>
); );
else if (token && !authFinished) {
return ( return (
<div className="body-centered"> <div className="body-centered">
<div className="login card"> <div className="login card">
@ -121,4 +121,6 @@ export default function Login() {
</div> </div>
</div> </div>
); );
}
return <Navigate to="/" />;
} }

View File

@ -9,7 +9,7 @@ export type ProtectedRouteProps = {
export default function ProtectedRoute({ children }: ProtectedRouteProps) { export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { signedIn } = useAuthContext(); const { signedIn } = useAuthContext();
if (!signedIn) return <Navigate to="/login" />; if (signedIn === false) return <Navigate to="/login" />;
if (signedIn) return children;
return children; return <></>; // While it's undefined - we're checking localstorage
} }

View File

@ -18,7 +18,6 @@ export default function Timers() {
data: timers, data: timers,
refreshData: refreshTimers, refreshData: refreshTimers,
setData: setTimers, setData: setTimers,
query,
setQuery, setQuery,
socket, socket,
setEndpoint, setEndpoint,
@ -64,24 +63,22 @@ export default function Timers() {
return ( return (
<div className="container"> <div className="container">
<TimerHeader friends={friends} selected={selected} onSelect={onSelect} /> <TimerHeader friends={friends} selected={selected} onSelect={onSelect} />
<div className="card-grid">
{timers ? ( {timers ? (
timers timers
.map((timer) => ({
...timer,
start: new Date(timer.start),
}))
.sort( .sort(
( (
{ start: startA }: { start: Date }, { start: startA }: { start: string },
{ start: startB }: { start: Date } { start: startB }: { start: string }
) => startB.getTime() - startA.getTime() ) => new Date(startB).getTime() - new Date(startA).getTime()
) )
.map((timer) => ( .map((timer) => (
<TimerCard onSelect={onSelect} timer={timer} key={timer.id} /> <TimerCard key={timer.id} onSelect={onSelect} timer={timer} />
)) ))
) : ( ) : (
<></> <></>
)} )}
</div> </div>
</div>
); );
} }

View File

@ -1,3 +1,49 @@
a { a {
cursor: pointer; cursor: pointer;
} }
nav {
margin-bottom: 1rem;
}
.my-modal-header {
display: flex;
justify-content: space-between;
align-items: start;
}
.card-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-gap: 1rem;
}
.grid-card {
display: flex;
flex-flow: column;
justify-content: space-between;
}
@media only screen and (max-width: 800px) {
.card-grid {
grid-template-columns: 1fr;
}
}
@media only screen and (max-width: 1200px) {
.card-grid {
grid-template-columns: 1fr 1fr 1fr;
}
}
.italic {
font-style: italic;
}
.timer-metadata {
padding-top: 0.75rem;
}
.container {
margin-bottom: 2rem;
}

View File

@ -6,7 +6,7 @@ export default {
bottom: "auto", bottom: "auto",
marginRight: "-50%", marginRight: "-50%",
transform: "translate(-50%, -50%)", transform: "translate(-50%, -50%)",
width: "40vw", width: "60vw",
maxWidth: "800px", maxWidth: "500px",
}, },
}; };

View File

@ -10,11 +10,18 @@ export type Friend = {
export type TimerResponse = { export type TimerResponse = {
error?: string; error?: string;
message?: string; message?: string;
id: number; id?: number;
name: string; name?: string;
start: Date; start?: Date;
created_by: Friend; created_by?: Friend;
referenced_friends: Friend[]; referenced_friends?: Friend[];
timer_refreshes?: TimerRefresh[];
};
export type TimerRefresh = {
start: string;
end: string;
refreshed_by: Friend;
}; };
export type TokenResponse = { export type TokenResponse = {
@ -22,7 +29,7 @@ export type TokenResponse = {
message?: string; message?: string;
token?: string; token?: string;
expiration?: string; expiration?: string;
friend: Friend; friend?: Friend;
}; };
export type SignThisTokenResponse = { export type SignThisTokenResponse = {

View File

@ -31,7 +31,6 @@ services:
env_file: .env.dev env_file: .env.dev
build: build:
context: ./server context: ./server
target: development
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
volumes: volumes:
- ./server:/usr/src/app - ./server:/usr/src/app

View File

@ -1,5 +1,5 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsNotEmpty, ValidateIf } from 'class-validator'; import { IsNotEmpty, ValidateIf, Max, Min, MaxLength } from 'class-validator';
export class SignedGodTokenDTO { export class SignedGodTokenDTO {
@IsNotEmpty() @IsNotEmpty()
@ -17,10 +17,21 @@ export class RetrieveFriendDTO {
export class CreateTimerDTO { export class CreateTimerDTO {
@IsNotEmpty() @IsNotEmpty()
@MaxLength(80)
name: string; name: string;
} }
export class RefreshTimerDTO { export class RetrieveTimerDTO {
@Type(() => Number) @Type(() => Number)
id: number; id: number;
} }
export class GetPageDTO {
@Type(() => Number)
@Max(500)
@Min(1)
take = 100;
@Type(() => Number)
skip = 0;
}

View File

@ -14,9 +14,10 @@ import { TimerService } from './timer.service';
import { TimerGateway } from './timer.gateway'; import { TimerGateway } from './timer.gateway';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { import {
RefreshTimerDTO, RetrieveTimerDTO,
CreateTimerDTO, CreateTimerDTO,
RetrieveFriendDTO, RetrieveFriendDTO,
GetPageDTO,
} from '../dto/dtos'; } from '../dto/dtos';
import { AuthGuard } from 'src/auth/auth.guard'; import { AuthGuard } from 'src/auth/auth.guard';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
@ -42,8 +43,22 @@ export class TimerController {
return await this.timerService.friendTimers(friend); return await this.timerService.friendTimers(friend);
} }
@Get('/:id/refreshes')
public async getTimerAndRefreshes(
@Param() { id }: RetrieveTimerDTO,
@Query() page: GetPageDTO,
) {
const timer = await this.timerService.findTimerById(id);
if (!timer) throw new NotFoundException('No such timer with id');
return {
...timer,
timer_refreshes: await this.timerService.getRefreshesPaged(timer, page),
};
}
@Post('/:id/refresh') @Post('/:id/refresh')
public async refreshTimer(@Param() { id }: RefreshTimerDTO, @Req() req) { public async refreshTimer(@Param() { id }: RetrieveTimerDTO, @Req() req) {
const timer = await this.timerService.findTimerById(id); const timer = await this.timerService.findTimerById(id);
if (!timer) throw new NotFoundException('No such timer with id'); if (!timer) throw new NotFoundException('No such timer with id');

View File

@ -2,6 +2,7 @@ import { Friend, Timer, Prisma } from '@prisma/client';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { GetPageDTO } from 'src/dto/dtos';
@Injectable() @Injectable()
export class TimerService { export class TimerService {
@ -22,11 +23,35 @@ export class TimerService {
}, },
}; };
static REFRESHED_SELECT = {
id: true,
start: true,
end: true,
refreshed_by: {
select: AuthService.FRIEND_SELECT,
},
};
static LAST_REFRESH = {
orderBy: {
end: 'desc',
},
take: 1,
select: TimerService.REFRESHED_SELECT,
};
public getAll() { public getAll() {
return this.prismaService.timer.findMany({ return this.prismaService.timer.findMany({
select: { select: {
...TimerService.TIMER_SELECT, ...TimerService.TIMER_SELECT,
...TimerService.INCLUDE_FRIENDS_SELECT, ...TimerService.INCLUDE_FRIENDS_SELECT,
timer_refreshes: {
orderBy: {
end: 'desc',
},
take: 1,
select: TimerService.REFRESHED_SELECT,
},
}, },
}); });
} }
@ -36,6 +61,13 @@ export class TimerService {
select: { select: {
...TimerService.TIMER_SELECT, ...TimerService.TIMER_SELECT,
...TimerService.INCLUDE_FRIENDS_SELECT, ...TimerService.INCLUDE_FRIENDS_SELECT,
timer_refreshes: {
orderBy: {
end: 'desc',
},
take: 1,
select: TimerService.REFRESHED_SELECT,
},
}, },
where: { where: {
referenced_friends: { referenced_friends: {
@ -49,6 +81,19 @@ export class TimerService {
}); });
} }
public async getRefreshesPaged(timer: Timer, { skip, take }: GetPageDTO) {
return this.prismaService.timerRefreshes.findMany({
take,
skip,
where: {
timer: {
id: timer.id,
},
},
select: TimerService.REFRESHED_SELECT,
});
}
public findTimerById(id: number) { public findTimerById(id: number) {
return this.prismaService.timer.findUnique({ return this.prismaService.timer.findUnique({
where: { id }, where: { id },
@ -57,17 +102,6 @@ export class TimerService {
public async refreshTimer(timer: Timer, friend: Friend) { public async refreshTimer(timer: Timer, friend: Friend) {
const now = new Date(); const now = new Date();
const select = {
...TimerService.TIMER_SELECT,
...TimerService.INCLUDE_FRIENDS_SELECT,
};
const refreshedTimer = await this.prismaService.timer.update({
where: { id: timer.id },
data: {
start: now,
},
select,
});
await this.prismaService.timerRefreshes.create({ await this.prismaService.timerRefreshes.create({
data: { data: {
@ -84,9 +118,26 @@ export class TimerService {
}, },
}, },
}, },
select: TimerService.REFRESHED_SELECT,
}); });
return refreshedTimer; return this.prismaService.timer.update({
where: { id: timer.id },
data: {
start: now,
},
select: {
...TimerService.TIMER_SELECT,
...TimerService.INCLUDE_FRIENDS_SELECT,
timer_refreshes: {
orderBy: {
end: 'desc',
},
take: 1,
select: TimerService.REFRESHED_SELECT,
},
},
});
} }
public createTimerWithFriends( public createTimerWithFriends(