Skip to content

Commit

Permalink
feat: version 1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
klaatucarpenter committed Apr 1, 2023
1 parent 7da9732 commit 7f5066f
Show file tree
Hide file tree
Showing 56 changed files with 4,753 additions and 545 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"extends": "next/core-web-vitals"
"extends": ["next/core-web-vitals", "prettier"]
}
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,12 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

## Code formatting and linter

Before committing, in the main directory run:

```
npx prettier --write .
npx eslint .
```
16 changes: 16 additions & 0 deletions components/ArrowBack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Arrow from "../public/arrow.svg";

export default function ArrowBack({
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
className={
"flex items-center px-5 hover:cursor-pointer " + props.className
}
>
<Arrow className="text-xl" />
</div>
);
}
26 changes: 26 additions & 0 deletions components/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
interface Props {
src: string;
size: "sm" | "md" | "lg";
}

export default function Avatar({ src, size }: Props) {
const getClassName = () => {
let retval = "rounded-full";
switch (size) {
case "sm":
return retval + " h-12 w-12";
case "md":
return retval + " h-24 w-24";
case "lg":
return retval + " h-36 w-36";
default:
return retval;
}
};

return (
<div className="shrink-0">
<img className={getClassName()} src={src} alt="" />
</div>
);
}
181 changes: 181 additions & 0 deletions components/Chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { User } from "@/types/backendTypes";
import { getTime } from "@/utils/getTime";
import Camera from "../public/camera.svg";
import Dots from "../public/dots.svg";
import Phone from "../public/phone.svg";
import Star from "../public/star.svg";
import Smile from "../public/smile.svg";
import PaperPlane from "../public/paperPlane.svg";
import Avatar from "./Avatar";
import ArrowBack from "./ArrowBack";
import { Dispatch, FormEvent, useEffect, useRef, useState } from "react";
import { conversations } from "@/lib/conversations";
import Link from "next/link";
import { Action } from "./Layout";
import IntroBetaLogo from "../public/IntroBetaLogo.svg";

interface Props {
user?: User;
toggleShowUserDetails: () => void;
dispatch: Dispatch<Action>;
}

export default function Chat({ user, toggleShowUserDetails, dispatch }: Props) {
const [input, setInput] = useState<string>();
const chatWindow = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const conversation = user
? conversations.getConversation(user?.login.uuid)
: undefined;

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const content = data.get("msg-content")?.toString().trim();
if (!content || !user) return;
conversations.addMessage(user.login.uuid, {
authorId: conversations.getMyId(),
content,
});
setInput("");
if (!inputRef.current) return;
inputRef.current.value = "";
inputRef.current.focus();
dispatch({
type: "setActiveConversationLength",
payload: { length: conversation?.messages.length ?? 0 },
});
};

useEffect(() => {
if (!chatWindow.current) return;
chatWindow.current.scrollTop = chatWindow.current.scrollHeight;
}, [user?.login.uuid, input]);

useEffect(() => {
if (conversation && conversation?.numberOfUnreadMessages > 0) {
conversations.markAsRead(conversation.id);
}
});

return (
<div className="grid h-screen grid-rows-[max-content_minmax(0,_1fr)_max-content] overflow-hidden">
{user ? (
<>
<div className="grid grid-cols-[minmax(0,_1fr)_auto] content-center shadow md:shadow-none">
<div className="flex md:shadow">
<Link href="/" className="py-5 lg:hidden">
<ArrowBack />
</Link>
<div className="flex items-center overflow-hidden py-5 pl-2 font-bold tracking-widest text-lavender-500 md:grow">
<p
className="overflow-hidden text-ellipsis whitespace-nowrap font-bold uppercase tracking-widest text-lavender-500 hover:cursor-pointer"
onClick={toggleShowUserDetails}
>
{user?.name.first} {user?.name.last}
</p>
</div>
</div>
<div className="flex items-center px-5 hover:cursor-pointer md:hidden">
<Dots className="text-3xl" />
</div>
<div className="content-stretch hidden grid-cols-3 stroke-lavender-500 hover:cursor-pointer md:grid">
<div className="flex h-full content-center items-center stroke-2 px-5 shadow hover:stroke-[3]">
<Phone className="text-xl" />
</div>
<div className="flex h-full content-center items-center px-5 shadow hover:stroke-[3]">
<Camera className="fill-lavender-500 text-2xl" />
</div>
<div className="flex h-full content-center items-center stroke-[1.5px] px-5 shadow hover:stroke-2">
<Star className="text-2xl" />
</div>
</div>
</div>
<div className="z-10 overflow-scroll shadow" ref={chatWindow}>
<ul
role="list"
className="flex min-h-full flex-col-reverse whitespace-pre-wrap"
>
{conversation?.messages.map((msg, index) => (
<li key={index} className="flex w-full gap-4 px-6 py-2">
{msg.authorId == user?.login.uuid ? (
<>
<Avatar src={user?.picture.thumbnail} size="sm" />
<div className="grow">
<div className="flex">
<div className="mt-4 inline-block w-3 overflow-hidden">
<div className="h-4 origin-top-right -rotate-45 transform bg-lightgray"></div>
</div>
<div className="rounded-lg bg-lightgray p-4">
{msg.content}
</div>
</div>
<div className="pl-6">
<p className="text-[0.7rem] text-slate-500">
{getTime(new Date(msg.timestamp))}
</p>
</div>
</div>
</>
) : (
<div className="grow">
<div className="flex flex-col items-end">
<div className="grid grid-cols-[minmax(0,_1fr)_max-content] pl-16">
<div className="rounded-lg bg-blue-100 p-4">
{msg.content}
</div>
<div className="mt-4 inline-block w-3 overflow-hidden">
<div className="h-4 origin-top-left rotate-45 transform bg-blue-100"></div>
</div>
</div>
<div className="pr-6">
<p className="text-[0.7rem] text-slate-500">
{getTime(new Date(msg.timestamp))}
</p>
</div>
</div>
</div>
)}
</li>
))}
</ul>
</div>
<div className="grid w-full grid-cols-[max-content_minmax(0,_1fr)] place-content-center bg-white py-3 shadow ">
<div className="flex items-center self-end px-6 pb-4">
<button role="button">
<Smile className="fill-slate-400 text-2xl" />
</button>
</div>
<form className="flex items-center" onSubmit={handleSubmit}>
<div
className='grid grow self-center after:invisible after:max-h-48 after:overflow-hidden after:whitespace-pre-wrap after:p-2 after:content-[attr(data-replicated-value)_"_"] after:[grid-area:_1_/_1_/_2_/_2]'
data-replicated-value={input}
onClick={() => inputRef.current?.focus()}
>
<textarea
ref={inputRef}
className="max-h-48 resize-none overflow-scroll whitespace-pre-wrap rounded-lg p-2 [grid-area:_1_/_1_/_2_/_2] focus:bg-blue-50 focus:outline-none"
onInput={(event) => setInput(event.currentTarget.value)}
placeholder="Type your message here..."
id="msg-content"
name="msg-content"
rows={1}
/>
</div>
<button
className="group mx-3 flex h-14 w-14 items-center justify-center self-end rounded-full bg-lavender-500 p-3"
type="submit"
>
<PaperPlane className="rotate-12 fill-white stroke-white text-2xl group-hover:stroke-2" />
</button>
</form>
</div>
</>
) : (
<div className="grid h-[inherit] items-center justify-items-center">
<IntroBetaLogo className="h-auto w-1/2" />
</div>
)}
</div>
);
}
16 changes: 16 additions & 0 deletions components/Icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
interface Props {
Svg: any;
className?: string;
}

export default function Icon({ Svg, ...props }: Props) {
return (
<Svg
{...props}
className={
"fill-slate-200 group-hover:stroke-white group-hover:stroke-2 group-data-active:stroke-white group-data-active:stroke-2 " +
props.className
}
/>
);
}
16 changes: 16 additions & 0 deletions components/IconButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export default function IconButton({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
className={
"group flex aspect-square items-center justify-center shadow-sm shadow-slate-400 hover:cursor-pointer hover:bg-lavender-500 data-active:bg-lavender-500 " +
props.className
}
>
{children}
</div>
);
}
120 changes: 120 additions & 0 deletions components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { conversations } from "@/lib/conversations";
import { User } from "@/types/backendTypes";
import { useEffect, useReducer, useState } from "react";
import Chat from "./Chat";
import Navbar from "./Navbar";
import UserDetails from "./UserDetails";
import UsersSection from "./UsersSection/UsersSection";

interface LayoutState {
search: string;
isActiveSearch: boolean;
activeConversationLength: number;
}

export type Action =
| { type: "setSearch"; payload: { search: string } }
| { type: "toggleActiveSearch"; payload?: {} }
| { type: "setActiveConversationLength"; payload: { length: number } };

function reducer(state: LayoutState, action: Action) {
const { type, payload } = action;
switch (type) {
case "setSearch":
return {
...state,
search: payload.search,
};
case "toggleActiveSearch":
return {
...state,
isActiveSearch: !state.isActiveSearch,
};
case "setActiveConversationLength":
return {
...state,
activeConversationLength: payload.length,
};
default:
return state;
}
}

interface Props {
activeUser?: User;
}

export default function Layout({ activeUser }: Props) {
const [state, dispatch] = useReducer(reducer, {
search: "",
isActiveSearch: false,
activeConversationLength: 0,
});
const [showUserDetails, setShowUserDetails] = useState(false);
const [numberOfUnreadMessages, setNumberOfUnreadMessages] = useState(0);

useEffect(() => {
const pollingIntervalMS = 1000;
const polling = setInterval(() => {
const numberOfMsgs = conversations.getNumberOfUnreadMessages();
if (numberOfUnreadMessages != numberOfMsgs) {
setNumberOfUnreadMessages(numberOfMsgs);
}
}, pollingIntervalMS);

return () => clearInterval(polling);
}, [numberOfUnreadMessages]);

const minifyUserList =
!!activeUser && !showUserDetails && !state.isActiveSearch;

return (
<div
className={[
"grid",
"grid-cols-1",
minifyUserList ? "sm:grid-cols-[4.5rem_5rem_minmax(0,_1fr)]" : "",
"lg:grid-cols-[4.5rem_35%_minmax(0,_1fr)]",
activeUser
? "xl:grid-cols-[4.5rem_minmax(0,_3fr)_minmax(0,_6fr)_minmax(0,_3fr)]"
: "xl:grid-cols-[4.5rem_minmax(0,_3fr)_minmax(0,_9fr)]",
].join(" ")}
>
<div
data-ui={activeUser && !showUserDetails && "active"}
className={
"relative grid grid-cols-[4.5rem_minmax(0,_1fr)] items-stretch shadow data-active:hidden sm:col-span-2 sm:data-active:grid"
}
>
<Navbar />
<UsersSection
activeUser={activeUser}
search={state.search}
dispatch={dispatch}
minifyUserList={minifyUserList}
/>
</div>
<div
data-ui={activeUser && !showUserDetails ? "active" : undefined}
className="hidden data-active:block sm:data-active:grid lg:grid"
>
<Chat
user={activeUser}
toggleShowUserDetails={() => setShowUserDetails(!showUserDetails)}
dispatch={dispatch}
/>
</div>
{activeUser && (
<div
data-ui={showUserDetails ? "active" : undefined}
className="absolute inset-0 z-10 hidden h-screen overflow-y-scroll bg-white shadow data-active:block xl:relative xl:block"
>
<UserDetails
user={activeUser}
toggleShowUserDetails={() => setShowUserDetails(!showUserDetails)}
/>
</div>
)}
</div>
);
}
Loading

0 comments on commit 7f5066f

Please sign in to comment.