Skip to content

Commit 58a00e5

Browse files
feat: add content scan component (codse#374)
1 parent ecb313a commit 58a00e5

File tree

4 files changed

+339
-1
lines changed

4 files changed

+339
-1
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import ContentScan from "@/animata/feature-cards/content-scan";
2+
import { Meta, StoryObj } from "@storybook/react";
3+
4+
const meta = {
5+
title: "Feature Cards/Content Scan",
6+
component: ContentScan,
7+
parameters: {
8+
layout: "centered",
9+
},
10+
tags: ["autodocs"],
11+
argTypes: {},
12+
} satisfies Meta<typeof ContentScan>;
13+
14+
export default meta;
15+
type Story = StoryObj<typeof meta>;
16+
17+
export const Primary: Story = {
18+
args: {
19+
content:
20+
"Ten years ago there were only five private prisons in the country, with a population of 2,000 inmates; now, 30 there are 100, with 62,000 inmates. It is expected that by the coming decade, the number will hit 360,000 according to reports. The private contracting of prisoners for work fosters.",
21+
highlightWords: [
22+
"Ten years ago",
23+
"only five private prisons",
24+
"now, 30",
25+
"62,000",
26+
"are 100",
27+
"62\\,000 inmates",
28+
"expected",
29+
"coming decade",
30+
],
31+
scanDuration: 4,
32+
reverseDuration: 1,
33+
},
34+
};
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import { motion, useAnimation } from "framer-motion";
3+
4+
interface ContentScannerProps {
5+
content: string;
6+
highlightWords: string[];
7+
scanDuration?: number;
8+
reverseDuration?: number;
9+
}
10+
11+
const ContentScanner: React.FC<ContentScannerProps> = ({
12+
content,
13+
highlightWords,
14+
scanDuration = 3,
15+
reverseDuration = 1,
16+
}) => {
17+
const [scanning, setScanning] = useState(false);
18+
const [aiProbability, setAiProbability] = useState(0);
19+
const containerRef = useRef<HTMLDivElement>(null);
20+
const scannerRef = useRef<HTMLDivElement>(null);
21+
const contentRef = useRef<HTMLDivElement>(null);
22+
const scannerAnimation = useAnimation();
23+
const [highlightedWords, setHighlightedWords] = useState<string[]>([]);
24+
const [animationPhase, setAnimationPhase] = useState<"idle" | "forward" | "paused" | "reverse">(
25+
"idle",
26+
);
27+
28+
const startScanning = async () => {
29+
if (scanning || !containerRef.current) return;
30+
31+
setScanning(true);
32+
setAiProbability(0);
33+
setHighlightedWords([]);
34+
setAnimationPhase("forward");
35+
36+
const containerWidth = containerRef.current.offsetWidth - 110;
37+
38+
// Forward scan
39+
await scannerAnimation.start({
40+
x: containerWidth,
41+
transition: { duration: scanDuration, ease: "linear" },
42+
});
43+
44+
setAnimationPhase("paused");
45+
46+
// Pause
47+
await new Promise((resolve) => setTimeout(resolve, 200));
48+
49+
setAnimationPhase("reverse");
50+
51+
// Backward scan
52+
await scannerAnimation.start({
53+
x: "-87%",
54+
transition: { duration: reverseDuration, ease: "linear" },
55+
});
56+
57+
setScanning(false);
58+
setHighlightedWords([]);
59+
setAnimationPhase("idle");
60+
};
61+
62+
useEffect(() => {
63+
let interval: NodeJS.Timeout;
64+
let pauseTimeout: NodeJS.Timeout;
65+
66+
if (animationPhase === "forward") {
67+
interval = setInterval(
68+
() => {
69+
setAiProbability((prev) =>
70+
Math.min(prev + 1, Math.floor(content.length / highlightWords.length)),
71+
);
72+
},
73+
(scanDuration * 1000) / 55,
74+
);
75+
} else if (animationPhase === "paused") {
76+
//delay before starting reverse
77+
pauseTimeout = setTimeout(() => {
78+
setAnimationPhase("reverse");
79+
}, 200);
80+
} else if (animationPhase === "reverse") {
81+
interval = setInterval(
82+
() => {
83+
setAiProbability((prev) => Math.max(prev - 1, 0));
84+
},
85+
(reverseDuration * 1000) / 40,
86+
);
87+
}
88+
89+
return () => {
90+
clearInterval(interval);
91+
clearTimeout(pauseTimeout);
92+
};
93+
}, [animationPhase, scanDuration, reverseDuration, content.length, highlightWords.length]);
94+
95+
useEffect(() => {
96+
if (scanning && scannerRef.current && contentRef.current) {
97+
const updateHighlightedWords = () => {
98+
const scannerRect = scannerRef.current!.getBoundingClientRect();
99+
const contentRect = contentRef.current!.getBoundingClientRect();
100+
const scannerRightEdge = scannerRect.right - contentRect.left;
101+
102+
const newHighlightedWords = highlightWords.filter((phrase) => {
103+
const phraseElements = contentRef.current!.querySelectorAll(`[data-phrase="${phrase}"]`);
104+
return Array.from(phraseElements).some((element) => {
105+
const elementRect = element.getBoundingClientRect();
106+
const elementRightEdge = elementRect.right - contentRect.left;
107+
return elementRightEdge <= scannerRightEdge;
108+
});
109+
});
110+
111+
setHighlightedWords(newHighlightedWords);
112+
};
113+
114+
const animationFrame = requestAnimationFrame(function animate() {
115+
updateHighlightedWords();
116+
if (scanning) {
117+
requestAnimationFrame(animate);
118+
}
119+
});
120+
121+
return () => cancelAnimationFrame(animationFrame);
122+
}
123+
}, [scanning, highlightWords]);
124+
125+
const highlightText = (text: string) => {
126+
let result = text;
127+
highlightWords.forEach((phrase) => {
128+
const regex = new RegExp(`(${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
129+
result = result.replace(
130+
regex,
131+
(match) =>
132+
`<span class="highlight ${highlightedWords.includes(phrase) ? "active" : ""}" data-phrase="${phrase}">${match}</span>`,
133+
);
134+
});
135+
return result;
136+
};
137+
138+
const renderAiProbability = (probability: number) => {
139+
const digits = probability.toString().padStart(2, "0").split("").map(Number);
140+
141+
const digitVariants = {
142+
initial: { y: 0 },
143+
animate: {
144+
y: [0, -30, 0],
145+
transition: {
146+
repeat: Infinity,
147+
repeatType: "loop" as const,
148+
duration: 1.5,
149+
ease: "easeInOut",
150+
},
151+
},
152+
};
153+
154+
return (
155+
<>
156+
<div className="inline-flex items-center">
157+
<div className="inline-flex h-8 overflow-hidden">
158+
{digits.map((digit, index) => (
159+
<motion.div
160+
key={`${index}-${digit}`}
161+
variants={digitVariants}
162+
initial="initial"
163+
animate="animate"
164+
className="inline-flex h-8 w-6 flex-col items-center justify-center"
165+
>
166+
{[digit, (digit + 1) % 10, (digit + 2) % 10].map((n, i) => (
167+
<span key={i} className="font-bold leading-8 text-purple-900">
168+
{n}
169+
</span>
170+
))}
171+
</motion.div>
172+
))}
173+
</div>
174+
</div>
175+
</>
176+
);
177+
};
178+
179+
return (
180+
<div className="relative mx-auto w-full max-w-2xl rounded-lg bg-white p-14 shadow-md">
181+
<div className="pb-5 text-center">
182+
<p className="p-5 text-2xl font-bold">Free AI Content Detector</p>
183+
<p className="pb-8">Brand new content in seconds. Remove any form of plagiarism</p>
184+
</div>
185+
186+
<motion.div
187+
ref={containerRef}
188+
className="relative overflow-hidden rounded bg-white p-4 shadow-lg"
189+
style={{ minHeight: "120px" }}
190+
initial={{ y: 100, opacity: 0 }}
191+
animate={{ y: 0, opacity: 1 }}
192+
transition={{ duration: 0.4, ease: "easeOut" }}
193+
>
194+
<div
195+
ref={contentRef}
196+
className="relative"
197+
dangerouslySetInnerHTML={{ __html: highlightText(content) }}
198+
style={{ color: "#666" }}
199+
/>
200+
<motion.div
201+
ref={scannerRef}
202+
className="pointer-events-none absolute -top-5 left-0 h-[calc(100%+40px)]"
203+
initial={{ x: "-87%" }}
204+
animate={scannerAnimation}
205+
>
206+
<div className="flex h-full flex-row-reverse">
207+
<div className="h-full w-1.5 bg-[#887FF2]" />
208+
<div className="h-full w-24 bg-custom-gradient" />
209+
</div>
210+
</motion.div>
211+
</motion.div>
212+
213+
<div className="rounded">
214+
<div className="flex justify-center">
215+
<button
216+
onClick={startScanning}
217+
className="mt-4 rounded bg-[#887FF2] px-4 py-2 text-white"
218+
disabled={scanning}
219+
>
220+
{scanning ? "Scanning..." : "Start Scan"}
221+
</button>
222+
</div>
223+
<div className="relative mt-2 overflow-hidden text-center text-sm text-black">
224+
<div className="flex items-center justify-center">
225+
{aiProbability > 0 && renderAiProbability(Math.floor(aiProbability))}
226+
<span className="ml-1 font-bold text-purple-900">%</span>
227+
<span className="ml-1">AI Content Probability</span>
228+
</div>
229+
</div>
230+
</div>
231+
232+
<style>{`
233+
.highlight {
234+
transition: background-color 0.3s ease;
235+
box-decoration-break: clone;
236+
-webkit-box-decoration-break: clone;
237+
}
238+
.highlight.active {
239+
background-color: #DAD9FE;
240+
}
241+
.scanned-text {
242+
color: #4B0082;
243+
}
244+
`}</style>
245+
</div>
246+
);
247+
};
248+
249+
export default ContentScanner;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
title: Content Scan
3+
description: A scanning component to highlight detected words to predict AI content probability
4+
author: MEbandhan
5+
---
6+
7+
<ComponentPreview name="feature-cards-content-scan--docs" />
8+
9+
## Installation
10+
11+
<Steps>
12+
<Step>Install dependencies</Step>
13+
14+
```bash
15+
npm install framer-motion
16+
```
17+
18+
<Step>Update `tailwind.config.js`</Step>
19+
20+
Add the following to your tailwind.config.js file.
21+
22+
```json
23+
module.exports = {
24+
theme: {
25+
extend: {
26+
backgroundImage: {
27+
"custom-gradient": "linear-gradient(to left, rgba(136,127,242,0.7) 0%, transparent 100%)",
28+
},
29+
},
30+
}
31+
}
32+
```
33+
34+
<Step>Run the following command</Step>
35+
36+
It will create a new file `content-scan.tsx` inside the `components/animata/feature-cards` directory.
37+
38+
```bash
39+
mkdir -p components/animata/feature-cards && touch components/animata/feature-cards/content-scan.tsx
40+
```
41+
42+
<Step>Paste the code</Step>{" "}
43+
44+
Open the newly created file and paste the following code:
45+
46+
```jsx file=<rootDir>/animata/feature-cards/content-scan.tsx
47+
48+
```
49+
50+
</Steps>
51+
52+
## Credits
53+
54+
Built by [Bandhan Majumder](https://github.com/bandhan-majumder)

tailwind.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ const config = {
1515
extend: {
1616
backgroundImage: {
1717
striped:
18-
"repeating-linear-gradient(45deg, #3B3A3D, #3B3A3D 5px, transparent 5px, transparent 20px)",
18+
"repeating-linear-gradient(45deg, #3B3A3D 0px, #3B3A3D 5px, transparent 5px, transparent 20px)",
19+
"custom-gradient": "linear-gradient(to left, rgba(136,127,242,0.7) 0%, transparent 100%)",
1920
},
2021
colors: {
2122
border: "hsl(var(--border))",

0 commit comments

Comments
 (0)