Skip to content
This repository was archived by the owner on Dec 9, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@neynar/react",
"version": "0.8.2",
"version": "0.8.3",
"description": "Farcaster frontend component library powered by Neynar",
"main": "dist/bundle.cjs.js",
"module": "dist/bundle.es.js",
Expand Down
35 changes: 4 additions & 31 deletions src/components/atoms/icons/ExternalLinkIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,10 @@
import React from 'react';

interface ExternalLinkIconProps {
width?: number;
height?: number;
className?: string;
style?: React.CSSProperties;
}

const ExternalLinkIcon: React.FC<ExternalLinkIconProps> = ({
width = 12,
height = 12,
className = 'ml-1 text-faint',
style = {},
}) => {
const ExternalLinkIcon = () => {
return (
<svg
aria-hidden="true"
focusable="false"
role="img"
viewBox="0 0 16 16"
width={width}
height={height}
fill="currentColor"
className={className}
style={{
display: 'inline-block',
userSelect: 'none',
verticalAlign: 'text-bottom',
overflow: 'visible',
...style,
}}
>
<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"></path>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.85855 0.555664H8.31281M8.31281 0.555664V2.73754M8.31281 0.555664L4.31445 4.11122" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.31445 1H1.31445C0.762168 1 0.314453 1.44772 0.314453 2V8C0.314453 8.55228 0.762168 9 1.31445 9H7.31445C7.86674 9 8.31445 8.55228 8.31445 8V6H7.31445V8H1.31445V2H3.31445V1Z" fill="#FFFFFF"/>
</svg>
);
};
Expand Down
7 changes: 7 additions & 0 deletions src/components/atoms/icons/LightningIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const LightningIcon = () => {
return(
<svg width="10" height="14" viewBox="0 0 10 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.01451 12.5187L4.96833 12.5174L4.93914 12.522C4.93113 12.516 4.92402 12.5087 4.91808 12.5005L4.51231 12.7926L4.91808 12.5005C4.9049 12.4822 4.89824 12.46 4.89913 12.4375L4.89953 12.4275V12.4176V8.81194V8.31194H4.39953H1.8876H1.88753C1.76188 8.31196 1.63853 8.27825 1.53036 8.21433C1.42218 8.1504 1.33315 8.05862 1.27256 7.94854C1.21196 7.83847 1.18203 7.71415 1.18588 7.58856C1.18973 7.46301 1.2272 7.34079 1.29438 7.23466C1.2944 7.23463 1.29442 7.23459 1.29444 7.23456L4.90001 1.54377L4.90567 1.53483L4.91095 1.52567C4.92227 1.50601 4.93988 1.49074 4.96094 1.48232C4.982 1.47389 5.00528 1.47281 5.02703 1.47924L5.02912 1.47985C5.05077 1.48614 5.06969 1.4995 5.08286 1.5178C5.09603 1.53609 5.1027 1.55827 5.1018 1.58079L5.10141 1.59073V1.60067V5.20631V5.70631H5.60141H8.11333H8.1134C8.23905 5.70629 8.3624 5.74 8.47058 5.80392C8.57875 5.86784 8.66778 5.95963 8.72838 6.06971C8.78897 6.17978 8.81891 6.3041 8.81506 6.42969C8.81121 6.55528 8.77371 6.67753 8.70649 6.78369L5.10232 12.4723C5.10219 12.4725 5.10205 12.4727 5.10191 12.4729C5.09255 12.4873 5.07969 12.499 5.06452 12.507C5.04914 12.5152 5.03191 12.5192 5.01451 12.5187Z" stroke="#FFFFFF"/>
</svg>
)
}
152 changes: 113 additions & 39 deletions src/components/molecules/FrameCard.tsx
Original file line number Diff line number Diff line change
@@ -1,96 +1,152 @@
import React, { useState } from "react";
import React, { useState, useRef, useEffect } from "react";
import { styled } from "@pigment-css/react";
import ExternalLinkIcon from "../atoms/icons/ExternalLinkIcon";
import { NeynarFrame } from "../organisms/NeynarFrameCard";
import { LightningIcon } from "../atoms/icons/LightningIcon";

export type FrameCardProps = {
frames: NeynarFrame[];
frame: NeynarFrame | null;
onFrameBtnPress: (
btnIndex: number,
localFrame: NeynarFrame,
setLocalFrame: React.Dispatch<React.SetStateAction<NeynarFrame>>,
inputValue?: string
) => void;
) => Promise<void>;
};

const FrameButton = styled.button({
border: "1px solid rgba(0, 0, 0, 0.75)",
borderRadius: "12px",
padding: "4px 16px",
fontSize: "12px",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "8px",
padding: "6px 16px",
fontSize: "14px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
'@media (min-width: 768px)': {
fontSize: "16px",
},
cursor: 'pointer'
cursor: 'pointer',
backgroundColor: "#1E1E1E",
color: "white",
flex: "1 1 0",
minWidth: "0",
'&:hover': {
backgroundColor: "#2E2E2E",
}
});

const FrameContainer = styled.div({
border: "0.5px solid rgba(128, 128, 128, 0.7)",
border: "1px solid rgba(255, 255, 255, 0.1)",
margin: "8px 0",
padding: "16px",
backgroundColor: "transparent",
backgroundColor: "#121212",
borderRadius: "12px",
display: "flex",
flexDirection: "column",
alignItems: "center",
alignItems: "right",
width: "100%",
maxWidth: "400px",
position: "relative",
});

const ButtonContainer = styled.div({
margin: "8px 0",
margin: "7px",
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
gap: "8px",
justifyContent: "center",
width: "97%",
});

const FrameImage = styled.img({
width: "100%",
height: "auto",
borderRadius: "8px",
borderTopLeftRadius: "8px",
borderTopRightRadius: "8px",
});

const FrameDomain = styled.div({
fontSize: "12px",
color: "#aaa",
marginTop: "4px",
textAlign: "right",
color: "#888",
marginTop: "auto",
width: "100%",
padding: "4px 0"
});

const FlexContainer = styled.div({
display: "flex",
flexDirection: "column",
gap: "4px",
width: "100%",
});

const InputField = styled.input({
border: "1px solid rgba(0, 0, 0, 0.75)",
borderRadius: "12px",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "8px",
padding: "8px",
marginTop: "8px",
width: "80%",
fontSize: "16px",
'@media (min-width: 768px)': {
fontSize: "16px",
},
width: "100%",
fontSize: "14px",
backgroundColor: "#1E1E1E",
color: "white",
});

function CastFrameBtn({ number, text, actionType, target, frameUrl, handleOnClick }: any) {
const SpinnerOverlay = styled.div({
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
borderRadius: "12px",
zIndex: 10,
});

const FrameSpinnerSVG = () => {
const spinnerRef = useRef<SVGSVGElement>(null);

useEffect(() => {
if (spinnerRef.current) {
let rotation = 0;
const animate = () => {
rotation += 6;
if (spinnerRef.current) {
spinnerRef.current.style.transform = `rotate(${rotation}deg)`;
}
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}
}, []);

return (
<svg
ref={spinnerRef}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="size-6 text-white"
style={{ width: '24px', height: '24px' }}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
);
};

function CastFrameBtn({ number, text, actionType, target, frameUrl, handleOnClick }: any) {
return (
<FrameButton onClick={() => handleOnClick(number)}>
{text}
{(actionType === "link" || actionType === "post_redirect" || actionType === "mint") && <ExternalLinkIcon />}
{actionType === "tx" && <LightningIcon />}
</FrameButton>
)
}

function CastFrame({ frame, onFrameBtnPress }: { frame: NeynarFrame, onFrameBtnPress: FrameCardProps['onFrameBtnPress'] }) {
const [localFrame, setLocalFrame] = useState<NeynarFrame>(frame);
const [inputValue, setInputValue] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);

const renderFrameButtons = () => {
const buttons = localFrame.buttons.map((btn) => (
Expand All @@ -101,7 +157,11 @@ function CastFrame({ frame, onFrameBtnPress }: { frame: NeynarFrame, onFrameBtnP
actionType={btn.action_type}
target={btn.target}
frameUrl={frame.frames_url}
handleOnClick={(btnIndex: number) => onFrameBtnPress(btnIndex, localFrame, setLocalFrame, inputValue)}
handleOnClick={(btnIndex: number) => {
setLoading(true);
onFrameBtnPress(btnIndex, localFrame, setLocalFrame, inputValue)
.finally(() => setLoading(false));
}}
/>
));
return <ButtonContainer>{buttons}</ButtonContainer>;
Expand All @@ -119,13 +179,27 @@ function CastFrame({ frame, onFrameBtnPress }: { frame: NeynarFrame, onFrameBtnP
}
};

const getImageStyle = () => {
switch (localFrame.image_aspect_ratio) {
case "1:1":
return { aspectRatio: "1 / 1" };
case "1.91:1":
return { aspectRatio: "1.91 / 1" };
default:
return {};
}
};

return (
<>
<FrameContainer>
{loading && (
<SpinnerOverlay><FrameSpinnerSVG /></SpinnerOverlay>
)}
{localFrame.frames_url && (
<>
<a href={localFrame.frames_url} target="_blank" rel="noopener noreferrer">
<FrameImage src={localFrame.image} alt={`Frame image for ${localFrame.frames_url}`} />
<a href={localFrame.frames_url} target="_blank" rel="noopener noreferrer" style={{ width: '100%' }}>
<FrameImage src={localFrame.image} alt={`Frame image for ${localFrame.frames_url}`} style={getImageStyle()} />
</a>
{localFrame.input?.text && (
<InputField
Expand All @@ -139,17 +213,17 @@ function CastFrame({ frame, onFrameBtnPress }: { frame: NeynarFrame, onFrameBtnP
</>
)}
</FrameContainer>
<FrameDomain>{extractDomain(localFrame.frames_url)}</FrameDomain>
{localFrame.frames_url && <FrameDomain>{extractDomain(localFrame.frames_url)}</FrameDomain>}
</>
);
}

export const FrameCard: React.FC<FrameCardProps> = ({ frames, onFrameBtnPress }) => {
export const FrameCard: React.FC<FrameCardProps> = ({ frame, onFrameBtnPress }) => {
return (
<FlexContainer>
{frames.map((frame: NeynarFrame, index: number) => (
<CastFrame key={`cast-frame-${index}`} frame={frame} onFrameBtnPress={onFrameBtnPress} />
))}
{frame ?
<CastFrame frame={frame} onFrameBtnPress={onFrameBtnPress} />
: <></>}
</FlexContainer>
);
};
Expand Down
3 changes: 2 additions & 1 deletion src/components/organisms/NeynarFrameCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type NeynarFrame = {
version: string;
title: string;
image: string;
image_aspect_ratio: string;
buttons: {
index: number;
title: string;
Expand Down Expand Up @@ -158,7 +159,7 @@ export const NeynarFrameCard: React.FC<NeynarFrameCardProps> = ({ url, onFrameBt

return (
<FrameCard
frames={frame ? [frame] : []}
frame={frame}
onFrameBtnPress={handleFrameBtnPress}
/>
);
Expand Down
8 changes: 3 additions & 5 deletions src/components/stories/NeynarFrameCard.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const TemplateWithInteractions: StoryFn<NeynarFrameCardProps> = (args) => (

export const Primary = Template.bind({});
Primary.args = {
frames: [
frame:
{
version: "vNext",
title: "Introducing Smart Wallets on Paragraph",
Expand Down Expand Up @@ -59,12 +59,11 @@ Primary.args = {
state: {},
frames_url: "https://paragraph.xyz/@blog/introducing-smart-wallets",
},
],
};

Primary.argTypes = {
hash: { table: { disable: true } },
frames: { table: { disable: true } },
frame: { table: { disable: true } },
};

export const EventsFrame = TemplateWithInteractions.bind({});
Expand All @@ -87,8 +86,7 @@ ZoraFrame.args = {
url: "https://zora.co/collect/base:0xcf6e80defd9be067f5adda2924b55c2186d3e930/5"
};


ParagraphFrame.argTypes = {
hash: { table: { disable: true } },
frames: { table: { disable: true } },
frame: { table: { disable: true } },
};