From 0e575636e6f81fcd3d02faff8940146aa8924320 Mon Sep 17 00:00:00 2001
From: David Lin <13061926+davidlhw@users.noreply.github.com>
Date: Sat, 3 Feb 2024 15:06:10 +0800
Subject: [PATCH] feat(ui): review item component (#64)
---
src/app/(school)/layout.tsx | 4 +-
src/app/components/page.tsx | 52 +++++++++++++
src/common/components/Button/Button.theme.ts | 13 ++++
src/common/components/Button/Button.tsx | 2 +
.../CustomIcon/ThumbUpFilledIcon.tsx | 12 +++
src/common/components/CustomIcon/index.ts | 1 +
.../LockCtaOverlay/LockCtaOverlay.theme.ts | 38 +++++++---
.../LockCtaOverlay/LockCtaOverlay.tsx | 32 ++++++--
.../components/MobileHeader/MobileHeader.tsx | 6 +-
.../Modal/ModalHeader/ModalHeader.tsx | 2 +-
.../RatingSection/RatingSection.tsx | 2 +-
.../components/ReviewItem/ProfileReviewer.tsx | 17 +++++
.../components/ReviewItem/ProfileSchool.tsx | 17 +++++
.../components/ReviewItem/ReviewBody.tsx | 37 ++++++++++
.../components/ReviewItem/ReviewItem.theme.ts | 70 ++++++++++++++++++
.../components/ReviewItem/ReviewItem.tsx | 74 +++++++++++++++++++
src/common/components/ReviewItem/index.ts | 2 +
src/common/components/Sidebar/Sidebar.tsx | 2 +-
.../components/SidebarItem/SidebarItem.tsx | 2 +-
.../getHumanReadableTimestampDelta.ts | 38 ++++++++++
src/common/functions/index.ts | 3 +
21 files changed, 400 insertions(+), 26 deletions(-)
create mode 100644 src/common/components/CustomIcon/ThumbUpFilledIcon.tsx
create mode 100644 src/common/components/ReviewItem/ProfileReviewer.tsx
create mode 100644 src/common/components/ReviewItem/ProfileSchool.tsx
create mode 100644 src/common/components/ReviewItem/ReviewBody.tsx
create mode 100644 src/common/components/ReviewItem/ReviewItem.theme.ts
create mode 100644 src/common/components/ReviewItem/ReviewItem.tsx
create mode 100644 src/common/components/ReviewItem/index.ts
create mode 100644 src/common/functions/getHumanReadableTimestampDelta.ts
create mode 100644 src/common/functions/index.ts
diff --git a/src/app/(school)/layout.tsx b/src/app/(school)/layout.tsx
index 1e6e62ec..105a27b2 100644
--- a/src/app/(school)/layout.tsx
+++ b/src/app/(school)/layout.tsx
@@ -1,4 +1,4 @@
-import { cn } from "@/common/functions/cn";
+import { cn } from "@/common/functions";
import { type PropsWithChildren } from "react";
export default function SchoolLayout({ children }: PropsWithChildren) {
@@ -6,7 +6,7 @@ export default function SchoolLayout({ children }: PropsWithChildren) {
({
@@ -83,6 +84,40 @@ const CommandContent = () => (
>
);
+const review = {
+ body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec auctor nunc a velit congue, et faucibus sapien iaculis. Quisque id felis non sapien egestas ultricies vulputate posuere quam. Vestibulum scelerisque arcu leo, sit amet interdum enim suscipit ut. Sed dolor turpis, tincidunt sed elementum at, posuere ac justo. Curabitur sem turpis, porttitor at ante sed, laoreet condimentum magna. Suspendisse ex orci, laoreet in cursus nec, rhoncus quis eros. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aliquam lacinia varius quam, ut blandit quam suscipit nec. Morbi facilisis mauris erat, quis porttitor purus consequat id. Maecenas.",
+ courseCode: "COR-MGMT1302",
+ username: "Anonymous",
+ likeCount: 10,
+ createdAt: 1705745162,
+ labels: [
+ {
+ name: "Engaging",
+ typeof: "professor",
+ },
+ {
+ name: "Fair Grading",
+ typeof: "professor",
+ },
+ {
+ name: "Effective Teaching",
+ typeof: "professor",
+ },
+ {
+ name: "Interesting",
+ typeof: "course",
+ },
+ {
+ name: "Practical",
+ typeof: "course",
+ },
+ {
+ name: "Gained New Skills",
+ typeof: "course",
+ },
+ ] as ReviewLabel[],
+};
+
export default function Components() {
const [isMounted, setIsMounted] = useState(false);
const { theme, setTheme } = useTheme();
@@ -480,6 +515,23 @@ export default function Components() {
+
+
+ Home Page
+
+
+
+
+ Professor Page
+
+
+
+
+ Course Page
+
+
+
+
);
}
diff --git a/src/common/components/Button/Button.theme.ts b/src/common/components/Button/Button.theme.ts
index 0ec2dcde..dfc02d6e 100644
--- a/src/common/components/Button/Button.theme.ts
+++ b/src/common/components/Button/Button.theme.ts
@@ -127,6 +127,19 @@ export const buttonTheme = tv(
"[&>*:not(.loading)]:invisible",
],
},
+ rounded: {
+ true: [
+ "inline-flex",
+ "min-w-14",
+ "py-[0.125rem]",
+ "px-3",
+ "gap-2",
+ "rounded-[6.1875rem]",
+ "after:rounded-[6.1875rem]",
+ "border-solid",
+ "border-border-default",
+ ],
+ },
},
compoundVariants: [
{
diff --git a/src/common/components/Button/Button.tsx b/src/common/components/Button/Button.tsx
index a0a24ebe..463291b0 100644
--- a/src/common/components/Button/Button.tsx
+++ b/src/common/components/Button/Button.tsx
@@ -46,6 +46,7 @@ export const Button = forwardRef(
iconLeft,
iconRight,
isResponsive = false,
+ rounded,
fullWidth,
disabled,
loading,
@@ -104,6 +105,7 @@ export const Button = forwardRef(
iconOnly,
size,
as,
+ rounded,
fullWidth,
disabled,
loading,
diff --git a/src/common/components/CustomIcon/ThumbUpFilledIcon.tsx b/src/common/components/CustomIcon/ThumbUpFilledIcon.tsx
new file mode 100644
index 00000000..7a4ca920
--- /dev/null
+++ b/src/common/components/CustomIcon/ThumbUpFilledIcon.tsx
@@ -0,0 +1,12 @@
+import { CustomIcon, type CustomIconProps } from "./CustomIcon";
+
+export const ThumbUpFilledIcon = (props: CustomIconProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/src/common/components/CustomIcon/index.ts b/src/common/components/CustomIcon/index.ts
index a8fc0e97..3ceaf4ff 100644
--- a/src/common/components/CustomIcon/index.ts
+++ b/src/common/components/CustomIcon/index.ts
@@ -12,6 +12,7 @@ export * from "./LockIcon";
export * from "./MinusIcon";
export * from "./SearchIcon";
export * from "./StarLineAltIcon";
+export * from "./ThumbUpFilledIcon";
export * from "./WarningCircleIcon";
export * from "./XCloseIcon";
export * from "./SchoolIcon";
diff --git a/src/common/components/LockCtaOverlay/LockCtaOverlay.theme.ts b/src/common/components/LockCtaOverlay/LockCtaOverlay.theme.ts
index fb445d6d..ef9671a8 100644
--- a/src/common/components/LockCtaOverlay/LockCtaOverlay.theme.ts
+++ b/src/common/components/LockCtaOverlay/LockCtaOverlay.theme.ts
@@ -19,26 +19,42 @@ export const lockCtaOverlayTheme = tv(
],
wrapper: [
"inline-flex",
+ "absolute",
+ "left-0",
+ "top-0",
"items-center",
"justify-center",
"h-full",
"w-full",
- "gap-1",
"text-text-em-low",
+ "z-10",
],
- icon: ["h-6", "w-6"],
- ctaTextContainer: [
- "flex",
- "items-center",
- "gap-1",
- "text-lg",
- "font-medium",
- ],
+ icon: [],
+ ctaTextContainer: ["flex", "items-center", "gap-1", "font-medium"],
},
variants: {
size: {
- md: {},
- sm: {},
+ md: {
+ wrapper: ["gap-2"],
+ icon: ["h-6", "w-6"],
+ ctaTextContainer: ["text-lg"],
+ },
+ sm: {
+ wrapper: ["gap-[0.375rem]"],
+ icon: ["h-4", "w-4"],
+ ctaTextContainer: ["text-sm"],
+ },
+ },
+ variant: {
+ border: {
+ overlay: ["hidden"],
+ wrapper: [
+ "rounded-lg",
+ "border-2",
+ "border-solid",
+ "border-border-default",
+ ],
+ },
},
},
},
diff --git a/src/common/components/LockCtaOverlay/LockCtaOverlay.tsx b/src/common/components/LockCtaOverlay/LockCtaOverlay.tsx
index 16cf9ded..3b7819a4 100644
--- a/src/common/components/LockCtaOverlay/LockCtaOverlay.tsx
+++ b/src/common/components/LockCtaOverlay/LockCtaOverlay.tsx
@@ -1,20 +1,40 @@
import { Button } from "@/common/components/Button";
import { LockIcon } from "@/common/components/CustomIcon/LockIcon";
-import { lockCtaOverlayTheme } from "./LockCtaOverlay.theme";
+import {
+ lockCtaOverlayTheme,
+ type LockCtaOverlayVariants,
+} from "./LockCtaOverlay.theme";
-export const LockCtaOverlay = () => {
- const { wrapper, overlay, ctaTextContainer, icon } = lockCtaOverlayTheme();
+const ctaTextMap = {
+ rating: "to see rating",
+ review: "to see review",
+} as const;
+
+export type LockCtaOverlayProps = LockCtaOverlayVariants & {
+ ctaType?: keyof typeof ctaTextMap;
+};
+
+export const LockCtaOverlay = ({
+ variant,
+ size = "md",
+ ctaType = "rating",
+}: LockCtaOverlayProps) => {
+ const { wrapper, overlay, ctaTextContainer, icon } = lockCtaOverlayTheme({
+ variant,
+ size,
+ });
return (
-
+ <>
+
- to see rating
+ {ctaTextMap[ctaType]}
-
+ >
);
};
diff --git a/src/common/components/MobileHeader/MobileHeader.tsx b/src/common/components/MobileHeader/MobileHeader.tsx
index f8b1c838..f7a1ed33 100644
--- a/src/common/components/MobileHeader/MobileHeader.tsx
+++ b/src/common/components/MobileHeader/MobileHeader.tsx
@@ -3,7 +3,7 @@
import { Button } from "@/common/components/Button";
import { AfterclassIcon } from "@/common/components/CustomIcon";
import { Sidebar } from "@/common/components/Sidebar";
-import { cn } from "@/common/functions/cn";
+import { cn } from "@/common/functions";
import { Icon } from "@iconify-icon/react";
import Link from "next/link";
import { useState, type ComponentPropsWithoutRef } from "react";
@@ -56,7 +56,7 @@ export const MobileHeader = ({ ...props }: MobileHeaderProps) => {
{/* Overlay */}
{
{/* Sidebar */}
{
className?: string;
diff --git a/src/common/components/RatingSection/RatingSection.tsx b/src/common/components/RatingSection/RatingSection.tsx
index fae3a6bf..12ebea95 100644
--- a/src/common/components/RatingSection/RatingSection.tsx
+++ b/src/common/components/RatingSection/RatingSection.tsx
@@ -3,7 +3,7 @@ import {
type RatingSectionVariants,
} from "./RatingSection.theme";
import { HeartIcon } from "@/common/components/CustomIcon";
-import { LockCtaOverlay } from "@/common/components/LockCtaOverlay/LockCtaOverlay";
+import { LockCtaOverlay } from "@/common/components/LockCtaOverlay";
import { StatItem, type StatItemProps } from "@/common/components/StatItem";
export type RatingSectionProps = RatingSectionVariants & {
diff --git a/src/common/components/ReviewItem/ProfileReviewer.tsx b/src/common/components/ReviewItem/ProfileReviewer.tsx
new file mode 100644
index 00000000..551826ee
--- /dev/null
+++ b/src/common/components/ReviewItem/ProfileReviewer.tsx
@@ -0,0 +1,17 @@
+import { reviewItemTheme, type ReviewItemVariants } from "./ReviewItem.theme";
+
+export type ProfileReviewerProps = ReviewItemVariants & {
+ name: string;
+};
+
+export const ProfileReviewer = ({ name }: ProfileReviewerProps) => {
+ const { profileContainer, profileName, profileIcon } = reviewItemTheme();
+ return (
+
+ );
+};
diff --git a/src/common/components/ReviewItem/ProfileSchool.tsx b/src/common/components/ReviewItem/ProfileSchool.tsx
new file mode 100644
index 00000000..39a0f403
--- /dev/null
+++ b/src/common/components/ReviewItem/ProfileSchool.tsx
@@ -0,0 +1,17 @@
+import { reviewItemTheme, type ReviewItemVariants } from "./ReviewItem.theme";
+
+export type ProfileSchoolProps = ReviewItemVariants & {
+ courseCode: string;
+};
+
+export const ProfileSchool = ({ courseCode }: ProfileSchoolProps) => {
+ const { schoolContainer, schoolCourseCode, schoolIcon } = reviewItemTheme();
+ return (
+
+ );
+};
diff --git a/src/common/components/ReviewItem/ReviewBody.tsx b/src/common/components/ReviewItem/ReviewBody.tsx
new file mode 100644
index 00000000..8522809c
--- /dev/null
+++ b/src/common/components/ReviewItem/ReviewBody.tsx
@@ -0,0 +1,37 @@
+import { Button } from "@/common/components/Button";
+import { reviewItemTheme, type ReviewItemVariants } from "./ReviewItem.theme";
+import { Review } from "./ReviewItem";
+
+export type ReviewBodyProps = ReviewItemVariants & {
+ review: Review;
+ isDetailed: boolean;
+ variant?: "home" | "subpage";
+};
+
+export const ReviewBody = ({ review, isDetailed }: ReviewBodyProps) => {
+ const { body, labels } = reviewItemTheme();
+ return (
+
+ {isDetailed && (
+
+ {review.labels
+ .filter((label) => label.typeof === "professor")
+ .map((label) => (
+ {label.name}
+ ))}
+
+ )}
+ {isDetailed && (
+
+ {review.labels
+ .filter((label) => label.typeof === "course")
+ .map((label) => (
+ {label.name}
+ ))}
+
+ )}
+
{review.body}
+ {isDetailed &&
}
+
+ );
+};
diff --git a/src/common/components/ReviewItem/ReviewItem.theme.ts b/src/common/components/ReviewItem/ReviewItem.theme.ts
new file mode 100644
index 00000000..c97687a4
--- /dev/null
+++ b/src/common/components/ReviewItem/ReviewItem.theme.ts
@@ -0,0 +1,70 @@
+import { type VariantProps, tv } from "tailwind-variants";
+
+export type ReviewItemVariants = VariantProps
;
+
+export const reviewItemTheme = tv(
+ {
+ slots: {
+ wrapper: [
+ "flex",
+ "flex-col",
+ "p-2",
+ "items-start",
+ "w-[45rem]",
+ "gap-[0.375rem]",
+ ],
+ headingContainer: [
+ "flex",
+ "self-stretch",
+ "justify-between",
+ "items-center",
+ "rounded-none",
+ "w-full",
+ "gap-10",
+ ],
+ schoolContainer: ["flex", "items-center", "gap-2"],
+ schoolIcon: [],
+ schoolCourseCode: [
+ "flex",
+ "overflow-hidden",
+ "text-sm",
+ "text-ellipsis",
+ "text-text-em-mid",
+ ],
+ metadataContainer: ["flex", "gap-4", "items-center", "justify-end"],
+ profileContainer: ["flex", "items-center", "gap-2"],
+ profileName: [
+ "overflow-hidden",
+ "text-sm",
+ "text-ellipsis",
+ "text-text-em-mid",
+ ],
+ profileIcon: [],
+ timedelta: [
+ "overflow-hidden",
+ "text-sm",
+ "text-ellipsis",
+ "text-text-em-low",
+ ],
+ body: [
+ "relative",
+ "flex",
+ "h-16",
+ "w-full",
+ "self-stretch",
+ "overflow-hidden",
+ "text-sm",
+ "text-ellipsis",
+ "text-text-em-high",
+ ],
+ labels: [
+ "text-sm",
+ "text-text-on-secondary",
+ "flex",
+ "gap-4",
+ "items-start",
+ ],
+ },
+ },
+ { responsiveVariants: true },
+);
diff --git a/src/common/components/ReviewItem/ReviewItem.tsx b/src/common/components/ReviewItem/ReviewItem.tsx
new file mode 100644
index 00000000..6e942261
--- /dev/null
+++ b/src/common/components/ReviewItem/ReviewItem.tsx
@@ -0,0 +1,74 @@
+import { reviewItemTheme, type ReviewItemVariants } from "./ReviewItem.theme";
+import { ReviewBody } from "./ReviewBody";
+import { ProfileSchool } from "./ProfileSchool";
+import { ProfileReviewer } from "./ProfileReviewer";
+import { getHumanReadableTimestampDelta } from "@/common/functions";
+import { ThumbUpFilledIcon } from "@/common/components/CustomIcon";
+import { LockCtaOverlay } from "@/common/components/LockCtaOverlay";
+import { Button } from "@/common/components/Button";
+
+// TODO: to replace with prisma generated types
+export type ReviewLabel = {
+ name: string;
+ typeof: "professor" | "course";
+};
+
+export type Review = {
+ body: string;
+ courseCode: string;
+ username: string;
+ likeCount: number;
+ labels: ReviewLabel[];
+ createdAt: number;
+};
+
+export type ReviewItemProps = ReviewItemVariants & {
+ review: Review;
+ isLocked?: boolean;
+ variant?: "home" | "professor" | "course";
+};
+
+export const ReviewItem = ({
+ review,
+ isLocked,
+ variant = "home",
+}: ReviewItemProps) => {
+ const { wrapper, headingContainer, metadataContainer, timedelta, body } =
+ reviewItemTheme();
+
+ return (
+
+
+ {variant === "home" ? (
+
+ ) : (
+
+ )}
+
+ {variant === "home" &&
}
+ {variant === "professor" && (
+
+ )}
+
}
+ >
+ {review.likeCount}
+
+
+ {getHumanReadableTimestampDelta(review.createdAt)}
+
+
+
+ {isLocked ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/src/common/components/ReviewItem/index.ts b/src/common/components/ReviewItem/index.ts
new file mode 100644
index 00000000..fc57828e
--- /dev/null
+++ b/src/common/components/ReviewItem/index.ts
@@ -0,0 +1,2 @@
+export * from "./ReviewItem";
+export * from "./ReviewItem.theme";
diff --git a/src/common/components/Sidebar/Sidebar.tsx b/src/common/components/Sidebar/Sidebar.tsx
index 233a508e..0f001ac5 100644
--- a/src/common/components/Sidebar/Sidebar.tsx
+++ b/src/common/components/Sidebar/Sidebar.tsx
@@ -13,7 +13,7 @@ import { Icon } from "@iconify-icon/react";
import { SidebarItem } from "@/common/components/SidebarItem";
import { Logo } from "@/common/components/Logo";
import { Input } from "@/common/components/Input";
-import { cn } from "@/common/functions/cn";
+import { cn } from "@/common/functions";
const SIDEBAR_ITEMS = [
{
diff --git a/src/common/components/SidebarItem/SidebarItem.tsx b/src/common/components/SidebarItem/SidebarItem.tsx
index 6b944e50..d50bb249 100644
--- a/src/common/components/SidebarItem/SidebarItem.tsx
+++ b/src/common/components/SidebarItem/SidebarItem.tsx
@@ -1,5 +1,5 @@
import { type SidebarItemType } from "@/common/components/Sidebar/Sidebar";
-import { cn } from "@/common/functions/cn";
+import { cn } from "@/common/functions";
import Link from "next/link";
export type SidebarListItem = SidebarItemType & {
diff --git a/src/common/functions/getHumanReadableTimestampDelta.ts b/src/common/functions/getHumanReadableTimestampDelta.ts
new file mode 100644
index 00000000..dddfd60b
--- /dev/null
+++ b/src/common/functions/getHumanReadableTimestampDelta.ts
@@ -0,0 +1,38 @@
+/**
+ * Calculates the human-readable time delta between two Unix epoch timestamps.
+ *
+ * @param t0 - The Unix epoch timestamp to compare against (in seconds).
+ * @param t1 - [optional] The Unix epoch timestamp to compare with (in seconds).
+ * If omitted, the current time is used.
+ * @returns A human-readable representation of the time delta. eg `1d`, `2h`, `just now`.
+ *
+ * @example
+ * const t0 = 1642694400; // Unix timestamp for 2022-01-21 00:00:00
+ * const t1 = 1642780800; // Unix timestamp for 2022-01-22 00:00:00
+ * const delta = getHumanReadableTimestampDelta(t0, t1);
+ * console.log(delta); // Output: '1d'
+ *
+ * @example
+ * const delta = getHumanReadableTimestampDelta(Date.now() / 1000);
+ * console.log(delta); // Output: 'just now'
+ */
+export function getHumanReadableTimestampDelta(
+ t0: number,
+ t1: number = Date.now() / 1000,
+) {
+ const deltaSeconds = Math.abs(t1 - t0);
+ const denominators = [
+ { unit: "y", seconds: 365 * 24 * 60 * 60 },
+ { unit: "mo", seconds: 30 * 24 * 60 * 60 },
+ { unit: "w", seconds: 7 * 24 * 60 * 60 },
+ { unit: "d", seconds: 24 * 60 * 60 },
+ { unit: "h", seconds: 60 * 60 },
+ { unit: "m", seconds: 60 },
+ { unit: "s", seconds: 1 },
+ ];
+ for (const denominator of denominators) {
+ const value = Math.floor(deltaSeconds / denominator.seconds);
+ if (value > 0) return `${value}${denominator.unit}`;
+ }
+ return "just now"; // If the delta is less than a second
+}
diff --git a/src/common/functions/index.ts b/src/common/functions/index.ts
new file mode 100644
index 00000000..914281ba
--- /dev/null
+++ b/src/common/functions/index.ts
@@ -0,0 +1,3 @@
+export * from "./cn";
+export * from "./formatPercentage";
+export * from "./getHumanReadableTimestampDelta";