Skip to content

Commit

Permalink
Add devcard component
Browse files Browse the repository at this point in the history
  • Loading branch information
foxyblocks committed Jun 15, 2023
1 parent 77ac7de commit 8eab96c
Show file tree
Hide file tree
Showing 9 changed files with 684 additions and 8 deletions.
2 changes: 1 addition & 1 deletion components/atoms/Pill/pill.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";

interface PillProps {
export interface PillProps {
className?: string;
text: string | number;
color?: "slate" | "green" | "yellow" | "red" | "purple";
Expand Down
308 changes: 308 additions & 0 deletions components/molecules/DevCard/dev-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
"use client";

import Image from "next/image";
import cntl from "cntl";
import { useEffect, useRef, useState } from "react";
import { useMouse } from "react-use";
import Button from "components/atoms/Button/button";
import { ArrowTrendingUpIcon, MinusSmallIcon, ArrowSmallUpIcon, ArrowSmallDownIcon } from "@heroicons/react/24/solid";
import { GiftIcon } from "@heroicons/react/24/outline";
import Pill, { PillProps } from "components/atoms/Pill/pill";
import Icon from "components/atoms/Icon/icon";
import CardSauceBGSVG from "img/card-sauce-bg.svg";
import openSaucedImg from "img/openSauced-icon.png";
import PRIcon from "img/icons/pr-icon.svg";
import Link from "next/link";

type Activity = "high" | "mid";

export interface DevCardProps {
username: string;
name?: string;
avatarURL: string;
prs?: number;
contributions?: number;
isLoading: boolean;
activity?: Activity;
bio?: string;
prVelocity?: number;
flipped?: boolean;
onFlip?: () => void;
}

const card = cntl`
DevCard-card
relative
rounded-3xl
`;

const face = cntl`
flex
items-stretch
justify-stretch
w-full
h-full
left-0
top-0
overflow-hidden
absolute
rounded-3xl
border-white
border-[2px]
`;

export default function DevCard(props: DevCardProps) {
const ref = useRef(null);
const { docX, docY, posX, posY, elW, elH } = useMouse(ref);
const [isFlipped, setIsFlipped] = useState(props.flipped);

useEffect(() => {
setIsFlipped(props.flipped);
}, [props.flipped]);

const halfWidth = elW / 2;
const halfHeight = elH / 2;
const elCenterX = posX + halfWidth;
const elCenterY = posY + halfHeight;
const mouseOffsetX = elCenterX - docX;
const mouseOffsetY = elCenterY - docY;
// Cap the offset so that it asymptomatically approaches the max offset

const calcAngleX = asymptoticLinear(mouseOffsetX / 20, 20, 0.1) + (isFlipped ? 180 : 0);
const calcAngleY = asymptoticLinear(mouseOffsetY / 40, 20, 0.1);

const glareX = (1 - mouseOffsetX / elW) * 100;
const glareY = (1 - mouseOffsetY / elH) * 100;
const calcShadowX = asymptoticLinear((mouseOffsetX - halfWidth) / 12, 20, 0.1);
const calcShadowY = asymptoticLinear((mouseOffsetY - halfHeight) / 24, 20, 0.1);

const glareStyle: React.CSSProperties = {
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 2,
mixBlendMode: "hard-light",
opacity: 0.5,
transform: "translateZ(80px)",
background: `radial-gradient(circle at ${glareX}% ${glareY}%, rgb(199 198 243), transparent)`,
};

const profileHref = `/user/${props.username}`;

function handleCardClick(event: React.MouseEvent<HTMLElement, MouseEvent>) {
// flip the card if the click is not on the button
if (!(event.target instanceof HTMLAnchorElement || event.target instanceof HTMLButtonElement)) {
if (props["flipped"] === undefined) {
setIsFlipped(!isFlipped);
} else {
props.onFlip?.();
}
}
}

return (
<div
onClick={handleCardClick}
style={{
cursor: "pointer",
perspective: "1000px",
width: "245px",
height: "348px",
}}
ref={ref}
>
<div
className={card}
style={{
width: "100%",
height: "100%",
position: "relative",
boxShadow: `${-calcShadowX}px ${-calcShadowY}px 50px -12px rgba(0, 0, 0, 0.25)`,
transform: `rotateY(${calcAngleX}deg) rotateX(${-calcAngleY}deg)`,
transition: "transform 0.2s ease-out",
transformStyle: "preserve-3d",
willChange: "transform, box-shadow",
}}
>
<div
className={`DevCard-front ${face}`}
style={{
background:
"#11181C linear-gradient(152.13deg, rgba(217, 217, 217, 0.6) 4.98%, rgba(217, 217, 217, 0.1) 65.85%)",
backfaceVisibility: "hidden",
pointerEvents: "none",
}}
>
<div className="grid grid-rows-2 grid-cols-1 flex-shrink-0 w-full h-full">
<div
className="DevCard-top"
style={{
backgroundImage: `url(${CardSauceBGSVG.src})`,
}}
>
<div className=" absolute left-[10px] top-[10px] flex items-center gap-1 cursor-pointer">
<Image className="rounded" alt="Open Sauced Logo" width={13} height={13} src={openSaucedImg} />
<p className={"font-semibold text-white"} style={{ fontSize: "8px" }}>
OpenSauced
</p>
</div>
</div>
<div className="DevCard-bottom relative text-white flex flex-col items-center pt-10">
{props.activity && (
<div className="absolute right-[8px] top-[8px]">
<ActivityPill activity={props.activity} />
</div>
)}
<div className="text-sm mb-3 font-semibold">@{props.username}</div>
<div className="w-full flex justify-center gap-6">
<div className="text-center">
<div className="text-6xl font-black">{props.isLoading ? "-" : props.prs}</div>
<div className="text-xs">PRs created</div>
</div>
<div className="text-center">
<div className="text-6xl font-black">{props.isLoading ? "-" : props.contributions}</div>
<div className="text-xs">Contributions</div>
</div>
</div>
</div>
</div>
<Image
src={props.avatarURL}
alt="avatar"
width={116}
height={116}
className="absolute top-1/2 left-1/2 border-white border-2 block rounded-full w-28 h-28"
style={{ transform: "translate(-50%, -75%)" }}
/>
{isFlipped ? null : <div className="glare" style={glareStyle} />}
</div>
<div
className={`DevCard-back ${face}`}
style={{
background:
"#11181C linear-gradient(152.13deg, rgba(217, 217, 217, 0.6) 4.98%, rgba(217, 217, 217, 0.1) 65.85%)",
backfaceVisibility: "hidden",
transform: "rotateY(180deg)",
}}
>
<div className="p-2 pt-4 w-full flex flex-col">
<div
className="text-white rounded-full w-full bg-[#F98026] mb-2 flex items-center"
style={{
boxShadow: "0px 10px 15px -3px rgba(249, 128, 38, 0.1), 0px 4px 6px -2px rgba(249, 128, 38, 0.05);",
}}
>
<Image
src={props.avatarURL}
alt="avatar"
width={36}
height={36}
className="border-white border-[2px] block rounded-full mr-2"
/>
<div className="py-0.5">
<div className="text-xs font-semibold">{props.name}</div>
<div className="flex items-center gap-2">
<div className="flex items-center">
<Icon IconImage={PRIcon} className="w-3 h-3 mr-1" />
<div className="flex text-xs">{props.prs} PR</div>
</div>
<div className="flex items-center">
<GiftIcon className="w-3 h-3 mr-1" />
<div className="flex text-xs">4d</div>
</div>
</div>
</div>
</div>
<div className="px-2 mt-auto">
{props.activity && (
<>
<div className="flex justify-between items-center">
<div className="text-xs text-slate-300">Activity</div>
<ActivityPill activity={props.activity} size="small" />
</div>
<Seperator />
</>
)}
{props.prVelocity && (
<>
<div className="flex justify-between items-center">
<div className="text-xs text-slate-300">PRs Velocity</div>
<div className="flex items-center ml-auto gap-1">
<div className="text-xs text-slate-300 font-extralight">2 Days</div>
<VelocityPill velocity={props.prVelocity} size="small" />
</div>
</div>
<Seperator />
</>
)}
<div className="text-xs text-slate-300 text-ellipsis">{props.bio}</div>
<Link href={profileHref} passHref>
<Button variant="primary" className="w-full text-center justify-center mt-4 !py-1">
View Profile
</Button>
</Link>
<div className="flex justify-center mt-2">
<Image className="rounded" alt="Open Sauced Logo" width={13} height={13} src={openSaucedImg} />
<p className={"font-semibold text-white ml-1"} style={{ fontSize: "8px" }}>
OpenSauced
</p>
</div>
</div>
</div>
{isFlipped ? <div className="glare" style={glareStyle} /> : null}
</div>
</div>
</div>
);
}

function VelocityPill({ velocity, ...props }: { velocity: number } & Omit<PillProps, "text" | "icon">) {
const icon =
velocity > 0 ? <ArrowSmallUpIcon color="purple" className="h-4 w-4" /> : <ArrowSmallDownIcon className="h-4 w-4" />;
return <Pill color="purple" icon={icon} text={`${velocity}%`} {...props} />;
}

function ActivityPill({ activity, ...props }: { activity: Activity } & Omit<PillProps, "text" | "icon">) {
const color = activity === "high" ? "green" : "yellow";
const activityText = activity === "high" ? "High" : "Mid";
const icon =
activity === "high" ? (
<ArrowTrendingUpIcon color="green" className="h-4 w-4" />
) : (
<MinusSmallIcon className="h-4 w-4 text-amber-500" />
);

return <Pill color={color} icon={icon} text={activityText} {...props} />;
}

function Seperator() {
return (
<div
className="my-2 h-[1px]"
style={{ background: "linear-gradient(90deg, hsla(206, 12%, 89%, 0.6), hsla(206, 12%, 89%, 0.01)" }}
></div>
);
}

/**
* Computes the value of a linear function that asymptotically approaches a capped value.
*
* @param {number} value - The input value
* @param {number} cap - The capped value that the function will never exceed.
* @param {number} slope - The slope controlling the rate at which the function approaches the cap.
* @returns {number} The computed value based on the input value, cap, and slope.
*/
function asymptoticLinear(value: number, cap: number, slope: number = 0.1) {
// Calculate the scale factor to ensure z never exceeds cap
const scaleFactor = cap / (Math.PI / 2);

// Use the inverse tangent function to achieve asymptotic behavior
const asymptoticValue = Math.atan(slope * value);

// Scale the result and return z
const z = scaleFactor * asymptoticValue;
return z;
}
61 changes: 61 additions & 0 deletions img/card-sauce-bg.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions img/icons/flip-card-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions img/icons/pr-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 8eab96c

Please sign in to comment.