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

View File

@ -27,38 +27,90 @@ const replaceReferencedFriendsInName = (
});
};
const refreshTimer = (id: number) =>
fetch(`/api/timers/${id}/refresh`, {
method: "POST",
});
export type TimerCardProps = {
timer: TimerResponse;
onSelect: (select?: TimersFilter) => void;
};
export default function TimerCard({ timer, onSelect }: TimerCardProps) {
const [since, setSince] = useState<string>(ago(timer.start));
const [since, setSince] = useState<string>("");
useEffect(() => {
const start = new Date(timer.start);
let updateTimersInterval: ReturnType<typeof setInterval>;
const msTillNextSecond = 1000 - (timer.start.getTime() % 1000);
const msTillNextSecond = 1000 - (start.getTime() % 1000);
setSince(ago(start));
setTimeout(() => {
updateTimersInterval = setInterval(
() => setSince(ago(timer.start)),
1_000
);
updateTimersInterval = setInterval(() => setSince(ago(start)), 1_000);
}, msTillNextSecond);
return () => clearInterval(updateTimersInterval);
}, []);
}, [timer.start]);
return (
<h1>
<code>{since}</code>{" "}
{replaceReferencedFriendsInName(
timer.name,
timer.referenced_friends,
onSelect
).map((element: JSX.Element | string, i: number) => (
<span key={i}>{element}</span>
))}
</h1>
<div className="card grid-card">
<div>
<header>
<h4 className="is-center">
<code>{since || "..."}</code>
</h4>
</header>
<p>
{replaceReferencedFriendsInName(
timer.name,
timer.referenced_friends,
onSelect
).map((element: JSX.Element | string, i: number) => (
<span style={{ overflowWrap: "anywhere", hyphens: "auto" }} key={i}>
{element}
</span>
))}
</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 { Mention, MentionsInput } from "react-mentions";
import { useAuthContext } from "../context/authContext";
import { Friend, TimersFilter, TimerResponse } from "../utils/types";
import mentionStyles from "../styles/mention";
import modalStyles from "../styles/modal";
import { Friend, TimersFilter, TimerResponse } from "../utils/types";
Modal.setAppElement("#root");
export type TimerHeaderProps = {
friends: Friend[];
@ -62,49 +60,46 @@ export default function TimerHeader({
onRequestClose={() => setModalOpen(false)}
style={modalStyles}
>
<div id="createTimerModal">
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h4 style={{ margin: "none" }}>New Timer</h4>
<a onClick={() => setModalOpen(false)} className="button outline">
&times;
</a>
</div>
<div>
<div className="my-modal-header">
<div>
<form onSubmit={createTimer}>
<MentionsInput
style={mentionStyles}
value={newTimerName}
onChange={(e: any) => setNewTimerName(e.target.value)}
>
<Mention
trigger="@"
data={friends.map(({ id, name }: Friend) => ({
id: `@<${id}>`,
display: `@${name}`,
}))}
/>
</MentionsInput>
{errors.length ? (
errors.map((error, i) => (
<div key={i} className="text-error">
{error}
</div>
))
) : (
<></>
)}
<button type="submit">Add</button>
</form>
<h4>New Timer</h4>
<p>
Use <code>@</code> and the autocomplete menu to reference a user
</p>
</div>
<a onClick={() => setModalOpen(false)} className="button outline">
&times;
</a>
</div>
<div>
<form onSubmit={createTimer}>
<MentionsInput
placeholder="since @..."
style={mentionStyles}
value={newTimerName}
onChange={(e: any) => setNewTimerName(e.target.value)}
>
<Mention
trigger="@"
data={friends.map(({ id, name }: Friend) => ({
id: `@<${id}>`,
display: `@${name}`,
}))}
/>
</MentionsInput>
{errors.length ? (
errors.map((error, i) => (
<div key={i} className="text-error">
{error}
</div>
))
) : (
<></>
)}
<button type="submit">Add</button>
</form>
</div>
</div>
</Modal>
@ -135,8 +130,8 @@ export default function TimerHeader({
</div>
<div className="nav-right">
<a
onClick={() => setModalOpen(true)}
style={{ marginTop: "1rem" }}
onClick={() => setModalOpen(true)}
className="button outline"
>
+
@ -145,7 +140,7 @@ export default function TimerHeader({
<summary style={{ marginTop: "1rem" }} className="button outline">
{friendName}
</summary>
<a className="button outline text-error" onClick={logout}>
<a className="button outline text-error bg-light" onClick={logout}>
Logout
</a>
</details>

View File

@ -25,7 +25,7 @@ const AuthContext = createContext<authContext>({
export const useAuthContext = () => useContext(AuthContext);
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 [friendId, setFriendId] = useState<number | null>(null);
const [friendName, setFriendName] = useState<string | null>(null);

View File

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

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import { useAuthContext } from "../context/authContext";
import { Navigate } from "react-router-dom";
import { SignThisTokenResponse, TokenResponse } from "../utils/types";
import "../styles/login.css";
@ -28,6 +28,7 @@ const submitSignedToken = async (signature: string): Promise<TokenResponse> =>
export default function Login() {
const [token, setToken] = useState<string>("");
const [errors, setErrors] = useState<string[]>([]);
const [authFinished, setAuthFinished] = useState<boolean>(false);
const { signedIn, setSignedIn, setSessionOver, setFriendId, setFriendName } =
useAuthContext();
@ -58,6 +59,8 @@ export default function Login() {
setFriendId(friend.id);
setFriendName(friend.name);
setAuthFinished(true);
return;
}
@ -66,10 +69,6 @@ export default function Login() {
}
};
if (signedIn) {
return <Navigate to="/" />;
}
if (!token)
return (
<div className="body-centered">
@ -87,38 +86,41 @@ export default function Login() {
<></>
)}
<button type="submit">Request Token</button>
<button type="submit">Request A Token</button>
</div>
</form>
</div>
);
return (
<div className="body-centered">
<div className="login card">
<div>Please sign the following payload with your PGP key:</div>
<code>{token}</code>
<hr />
<form onSubmit={signTokenFormSubmission}>
<textarea
id="signature"
name="signature"
rows={6}
placeholder="-----BEGIN PGP SIGNED MESSAGE-----"
/>
else if (token && !authFinished) {
return (
<div className="body-centered">
<div className="login card">
<div>Please sign the following payload with your PGP key:</div>
<code>{token}</code>
<hr />
<form onSubmit={signTokenFormSubmission}>
<textarea
id="signature"
name="signature"
rows={6}
placeholder="-----BEGIN PGP SIGNED MESSAGE-----"
/>
{errors.length ? (
errors.map((error, i) => (
<div key={i} className="text-error">
{error}
</div>
))
) : (
<></>
)}
{errors.length ? (
errors.map((error, i) => (
<div key={i} className="text-error">
{error}
</div>
))
) : (
<></>
)}
<button type="submit">Log In</button>
</form>
<button type="submit">Log In</button>
</form>
</div>
</div>
</div>
);
);
}
return <Navigate to="/" />;
}

View File

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

View File

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

View File

@ -1,3 +1,49 @@
a {
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",
marginRight: "-50%",
transform: "translate(-50%, -50%)",
width: "40vw",
maxWidth: "800px",
width: "60vw",
maxWidth: "500px",
},
};

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, ValidateIf } from 'class-validator';
import { IsNotEmpty, ValidateIf, Max, Min, MaxLength } from 'class-validator';
export class SignedGodTokenDTO {
@IsNotEmpty()
@ -17,10 +17,21 @@ export class RetrieveFriendDTO {
export class CreateTimerDTO {
@IsNotEmpty()
@MaxLength(80)
name: string;
}
export class RefreshTimerDTO {
export class RetrieveTimerDTO {
@Type(() => 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 { AuthService } from '../auth/auth.service';
import {
RefreshTimerDTO,
RetrieveTimerDTO,
CreateTimerDTO,
RetrieveFriendDTO,
GetPageDTO,
} from '../dto/dtos';
import { AuthGuard } from 'src/auth/auth.guard';
import { Prisma } from '@prisma/client';
@ -42,8 +43,22 @@ export class TimerController {
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')
public async refreshTimer(@Param() { id }: RefreshTimerDTO, @Req() req) {
public async refreshTimer(@Param() { id }: RetrieveTimerDTO, @Req() req) {
const timer = await this.timerService.findTimerById(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 { PrismaService } from '../prisma/prisma.service';
import { AuthService } from '../auth/auth.service';
import { GetPageDTO } from 'src/dto/dtos';
@Injectable()
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() {
return this.prismaService.timer.findMany({
select: {
...TimerService.TIMER_SELECT,
...TimerService.INCLUDE_FRIENDS_SELECT,
timer_refreshes: {
orderBy: {
end: 'desc',
},
take: 1,
select: TimerService.REFRESHED_SELECT,
},
},
});
}
@ -36,6 +61,13 @@ export class TimerService {
select: {
...TimerService.TIMER_SELECT,
...TimerService.INCLUDE_FRIENDS_SELECT,
timer_refreshes: {
orderBy: {
end: 'desc',
},
take: 1,
select: TimerService.REFRESHED_SELECT,
},
},
where: {
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) {
return this.prismaService.timer.findUnique({
where: { id },
@ -57,17 +102,6 @@ export class TimerService {
public async refreshTimer(timer: Timer, friend: Friend) {
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({
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(