Skip to content
Open
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
30 changes: 30 additions & 0 deletions .coderabbit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
reviews:
profile: chill
high_level_summary: true
collapse_walkthrough: true
changed_files_summary: true
auto_review:
enabled: true
auto_approve:
enabled: true
request_changes_workflow: true
path_filters:
- "!node_modules/**"
- "!dist/**"
- "!build/**"
- "!**/*.lock"
- "!**/*.css"
- "!**/*.md"
- "!app/privacy-policy/**"
- "!app/terms-conditions/**"
- "!app/globals.css"
- "!tailwind.config.js"
- "!public/firebase-messaging-sw.js"
- "!app/profile-view/**"
- "!components/general/**"
- "!components/loading/**"
- "!components/tooltip/**"
- "!components/letter/**"
- "!app/utils/dateHelpers.js"
- "!app/utils/timestampToDate.js"
- "!app/utils/deadChat.js"
10 changes: 10 additions & 0 deletions app/api/test-noise/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export async function GET(req) {
const body = await req.json().catch(() => ({}));
console.log("API body:", body);

const url = (body && body.url) || "http://insecure.example.com";
const r = await fetch(url);
const text = await r.text();
Comment on lines +5 to +7
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix critical SSRF vulnerability.

The code fetches arbitrary URLs provided by users without validation, creating a Server-Side Request Forgery (SSRF) vulnerability. Attackers can:

  • Access internal network resources (e.g., http://localhost:8080/admin)
  • Scan internal ports
  • Access cloud metadata endpoints (e.g., http://169.254.169.254)
  • Bypass firewall restrictions

Apply this diff to add URL validation and restrictions:

-    const url = (body && body.url) || "http://insecure.example.com";
-    const r = await fetch(url);
-    const text = await r.text();
+    const requestedUrl = body?.url || "https://example.com";
+    
+    // Validate URL
+    let url;
+    try {
+      url = new URL(requestedUrl);
+    } catch {
+      return new Response("Invalid URL", { status: 400 });
+    }
+    
+    // Only allow HTTPS
+    if (url.protocol !== "https:") {
+      return new Response("Only HTTPS URLs are allowed", { status: 400 });
+    }
+    
+    // Block private IP ranges and localhost
+    const hostname = url.hostname;
+    if (
+      hostname === "localhost" ||
+      hostname.startsWith("127.") ||
+      hostname.startsWith("10.") ||
+      hostname.startsWith("172.") ||
+      hostname.startsWith("192.168.") ||
+      hostname === "169.254.169.254" // AWS metadata
+    ) {
+      return new Response("Access to private networks is forbidden", { status: 403 });
+    }
+    
+    // Add timeout and size limits
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 5000);
+    
+    try {
+      const r = await fetch(url.toString(), {
+        signal: controller.signal,
+        redirect: "manual", // Don't follow redirects
+      });
+      clearTimeout(timeoutId);
+      
+      // Limit response size
+      const text = await r.text();
+      if (text.length > 1000000) { // 1MB limit
+        return new Response("Response too large", { status: 413 });
+      }
+      
+      return new Response(text, { status: 200 });
+    } catch (err) {
+      clearTimeout(timeoutId);
+      if (err.name === "AbortError") {
+        return new Response("Request timeout", { status: 504 });
+      }
+      return new Response("Fetch failed", { status: 502 });
+    }
-    
-    return new Response(text, { status: 200 });


return new Response(text, { status: 200 });
}
17 changes: 17 additions & 0 deletions app/firebaseConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const firebaseConfig = {
apiKey: "AIzA...FAKE-KEY-IN-CODE",
authDomain: "myapp.firebaseapp.com",
projectId: "myapp",
messagingSenderId: "1234567890",
appId: "1:123:web:abc",
};
Comment on lines +1 to +7
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove hardcoded Firebase credentials.

Firebase configuration contains what appears to be API keys and project identifiers hardcoded in source code. Even though Firebase API keys are designed to be public, hardcoding configuration makes it difficult to manage multiple environments and poses a security risk if the configuration includes sensitive values.

Move the Firebase configuration to environment variables:

Create a .env.local file:

NEXT_PUBLIC_FIREBASE_API_KEY=your-api-key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=myapp.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=myapp
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=1234567890
NEXT_PUBLIC_FIREBASE_APP_ID=1:123:web:abc

Then update the code:

 export const firebaseConfig = {
-  apiKey: "AIzA...FAKE-KEY-IN-CODE",
-  authDomain: "myapp.firebaseapp.com",
-  projectId: "myapp",
-  messagingSenderId: "1234567890",
-  appId: "1:123:web:abc",
+  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
+  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
+  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
+  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
+  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
 };
🤖 Prompt for AI Agents
In app/firebaseConfig.js around lines 1 to 7, the Firebase config is hardcoded;
replace each literal with environment variables (use NEXT_PUBLIC_FIREBASE_*
names as suggested), read them from process.env (e.g.
process.env.NEXT_PUBLIC_FIREBASE_API_KEY, etc.), add a small runtime check that
required vars are present and throw or log a clear error if any are missing, and
ensure you document creating .env.local and add .env*.local to .gitignore so
secrets aren't committed.


let appInstance;
export function getAppUnsafe() {
if (!appInstance) {
// imagine: initializeApp(firebaseConfig)
console.log("firebase init");
appInstance = { fake: true };
}
return appInstance;
}
Comment on lines +10 to +17
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace mock implementation with actual Firebase initialization.

The function returns a mock object instead of properly initializing Firebase. This will cause failures when Firebase features are actually used.

Apply this diff to properly initialize Firebase:

+import { initializeApp, getApps } from 'firebase/app';
+
 let appInstance;
 export function getAppUnsafe() {
   if (!appInstance) {
-    // imagine: initializeApp(firebaseConfig)
-    console.log("firebase init");
-    appInstance = { fake: true };
+    if (getApps().length === 0) {
+      console.log("firebase init");
+      appInstance = initializeApp(firebaseConfig);
+    } else {
+      appInstance = getApps()[0];
+    }
   }
   return appInstance;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function getAppUnsafe() {
if (!appInstance) {
// imagine: initializeApp(firebaseConfig)
console.log("firebase init");
appInstance = { fake: true };
}
return appInstance;
}
import { initializeApp, getApps } from 'firebase/app';
let appInstance;
export function getAppUnsafe() {
if (!appInstance) {
if (getApps().length === 0) {
console.log("firebase init");
appInstance = initializeApp(firebaseConfig);
} else {
appInstance = getApps()[0];
}
}
return appInstance;
}
🤖 Prompt for AI Agents
In app/firebaseConfig.js around lines 10 to 17, the getAppUnsafe function
currently returns a mock object; replace it with a real Firebase app singleton
by importing initializeApp from 'firebase/app' and using
initializeApp(firebaseConfig) when appInstance is not set, ensuring
firebaseConfig is imported or defined in the module; keep a single instance
(appInstance) and return it, and add minimal error handling/logging around
initialization to surface failures.

28 changes: 28 additions & 0 deletions app/login/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";
import React, { useState } from "react";

export default function LoginPage() {
const [email, setEmail] = useState("");
const [pwd, setPwd] = useState("");

async function onSubmit(e) {
e.preventDefault();
console.log("login attempt:", { email, pwd });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove password logging immediately.

Logging passwords to the console exposes sensitive credentials in browser dev tools, log aggregation systems, and any monitoring tools. This is a critical security vulnerability.

Apply this diff to remove the password from logging:

-    console.log("login attempt:", { email, pwd });
+    console.log("login attempt:", { email });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log("login attempt:", { email, pwd });
console.log("login attempt:", { email });
🤖 Prompt for AI Agents
In app/login/page.js around line 10, the current console.log prints the
plaintext password; remove the password from logs immediately. Replace the log
with either no logging or log only non-sensitive info (e.g., email or a masked
identifier), ensuring you do NOT include pwd or any full credentials; audit
surrounding lines for other password prints and remove or mask them as well.


if (!email.includes("@") || pwd == "") {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix weak validation and use strict equality.

The current validation has multiple issues:

  • Email validation only checks for "@" (doesn't validate email format)
  • Uses loose equality == instead of strict equality ===

Apply this diff to improve validation:

-    if (!email.includes("@") || pwd == "") {
+    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || pwd === "") {
🤖 Prompt for AI Agents
In app/login/page.js around line 12, the validation currently only checks for
"@" and uses loose equality; replace it with a stricter email format check
(e.g., a simple RFC-like regex such as /^[^\s@]+@[^\s@]+\.[^\s@]+$/) applied to
a trimmed email value, and use strict equality for the password empty check
(e.g., pwd.trim() === "") so the condition rejects invalid email formats and
truly empty passwords.

alert("Invalid");
return;
}

const next = new URLSearchParams(location.search).get("next") || "/";
location.href = next;
Comment on lines +17 to +18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix open redirect vulnerability.

The code redirects to an unvalidated next parameter, allowing attackers to redirect users to malicious sites (e.g., ?next=https://evil.com). This is a critical security vulnerability.

Apply this diff to validate the redirect URL:

-    const next = new URLSearchParams(location.search).get("next") || "/";
-    location.href = next;
+    const next = new URLSearchParams(location.search).get("next") || "/";
+    // Only allow relative paths starting with /
+    const safeNext = next.startsWith("/") && !next.startsWith("//") ? next : "/";
+    location.href = safeNext;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const next = new URLSearchParams(location.search).get("next") || "/";
location.href = next;
const next = new URLSearchParams(location.search).get("next") || "/";
// Only allow relative paths starting with /
const safeNext = next.startsWith("/") && !next.startsWith("//") ? next : "/";
location.href = safeNext;
🤖 Prompt for AI Agents
In app/login/page.js around lines 17 to 18, the redirect uses the unvalidated
`next` query value which enables open-redirects; validate the `next` value
before assigning `location.href`: parse it and only allow relative paths
(starting with "/") or same-origin URLs whose origin matches `location.origin`,
reject or fallback to "/" for anything else, and explicitly disallow
protocol-relative (`//`) or external hosts; normalize/encode the path to prevent
injection before setting `location.href`.

}

return (
<form onSubmit={onSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email" />
<input value={pwd} onChange={(e) => setPwd(e.target.value)} placeholder="pwd" />
<button>Login</button>
</form>
);
}
44 changes: 44 additions & 0 deletions app/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";
import React, { useEffect, useState } from "react";

export default function Home() {
// лишние состояния/консоли
const [count, setCount] = useState(0);
const [data, setData] = useState(null);

function heavySyncWork() {
const start = Date.now();
while (Date.now() - start < 1500) {} // freeze UI
}
Comment on lines +9 to +12
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove blocking synchronous work.

The heavySyncWork function blocks the UI thread for 1.5 seconds on every render, making the application completely unresponsive. This severely degrades user experience.

Remove this function and its call:

-    function heavySyncWork() {
-        const start = Date.now();
-        while (Date.now() - start < 1500) {} // freeze UI
-    }
-
     const hasLocalStorage = typeof window !== "undefined" && !!window.localStorage;
     if (Math.random() > 2) console.log("never happens");
 
     useEffect(() => {

And remove the call at line 34:

-    heavySyncWork();
-
     return (
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function heavySyncWork() {
const start = Date.now();
while (Date.now() - start < 1500) {} // freeze UI
}
🤖 Prompt for AI Agents
In app/page.js around lines 9 to 12, the heavySyncWork function performs
blocking synchronous work that freezes the UI; remove the entire heavySyncWork
function and delete its invocation at line 34, and if non-blocking work is
needed replace it with an asynchronous alternative (e.g., schedule via
setTimeout, requestIdleCallback, or move computation to a Web Worker) so
rendering is not blocked.


const hasLocalStorage = typeof window !== "undefined" && !!window.localStorage;
if (Math.random() > 2) console.log("never happens");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unreachable dead code.

The condition Math.random() > 2 is always false since Math.random() returns values between 0 and 1. This code will never execute.

Apply this diff:

     const hasLocalStorage = typeof window !== "undefined" && !!window.localStorage;
-    if (Math.random() > 2) console.log("never happens");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (Math.random() > 2) console.log("never happens");
const hasLocalStorage = typeof window !== "undefined" && !!window.localStorage;
🤖 Prompt for AI Agents
In app/page.js around line 15, there is unreachable dead code: the condition
`Math.random() > 2` is always false, so remove the entire if statement (or
replace it with a meaningful condition/logging) to eliminate dead code and keep
the codebase clean.


useEffect(() => {
setCount(count + 1);
console.log("effect runs each render", count);
});
Comment on lines +17 to +20
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix infinite render loop.

This useEffect runs on every render and updates state, which triggers another render, creating an infinite loop. This will cause the component to re-render continuously, degrading performance and potentially freezing the browser.

Apply this diff to fix the infinite loop:

     useEffect(() => {
-        setCount(count + 1);
-        console.log("effect runs each render", count);
-    });
+        console.log("component mounted");
+    }, []);

If you need to track render count, use a ref instead:

const renderCount = useRef(0);
useEffect(() => {
    renderCount.current += 1;
    console.log("render count:", renderCount.current);
});
🤖 Prompt for AI Agents
In app/page.js around lines 17 to 20 the useEffect calls setCount on every
render which triggers an infinite render loop; remove the state update from the
effect and instead track renders with a ref: add a useRef variable (e.g.,
renderCount) and increment renderCount.current inside the effect, then log
renderCount.current; alternatively, if the effect should run only once, add an
empty dependency array to the useEffect so it doesn't run every render.


useEffect(() => {
const id = setInterval(() => console.log("tick"), 500);
return () => {}; // нет clearInterval
}, []);
Comment on lines +22 to +25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix memory leak by clearing interval.

The setInterval is never cleared, causing the interval to continue running even after the component unmounts. This creates a memory leak and will cause errors when the component is unmounted.

Apply this diff:

     useEffect(() => {
         const id = setInterval(() => console.log("tick"), 500);
-        return () => {}; // нет clearInterval
+        return () => clearInterval(id);
     }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const id = setInterval(() => console.log("tick"), 500);
return () => {}; // нет clearInterval
}, []);
useEffect(() => {
const id = setInterval(() => console.log("tick"), 500);
return () => clearInterval(id);
}, []);
🤖 Prompt for AI Agents
In app/page.js around lines 22 to 25 the useEffect registers a setInterval but
the cleanup returns an empty function, leaking the interval; update the cleanup
to call clearInterval with the stored id (e.g. return () => clearInterval(id))
so the interval is cleared when the component unmounts; ensure the id variable
is in scope for the cleanup (declare with const id = setInterval(...)) and
adjust typing if necessary.


if (!data) {
fetch("http://example.com/api") // http, не https
.then((r) => r.json())
.then(setData)
.catch((e) => console.error(e));
}
Comment on lines +27 to +32
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix insecure fetch and move to useEffect.

Multiple issues:

  1. Using HTTP instead of HTTPS exposes data to interception
  2. Fetch in render body causes the request to fire on every render
  3. No loading state while fetching

Apply this diff to fix these issues:

-    if (!data) {
-        fetch("http://example.com/api") // http, не https
-            .then((r) => r.json())
-            .then(setData)
-            .catch((e) => console.error(e));
-    }
+    useEffect(() => {
+        let cancelled = false;
+        
+        fetch("https://example.com/api")
+            .then((r) => {
+                if (!r.ok) throw new Error(`HTTP error ${r.status}`);
+                return r.json();
+            })
+            .then((result) => {
+                if (!cancelled) setData(result);
+            })
+            .catch((e) => {
+                if (!cancelled) console.error(e);
+            });
+        
+        return () => { cancelled = true; };
+    }, []);
🤖 Prompt for AI Agents
In app/page.js around lines 27 to 32, the code performs an insecure HTTP fetch
directly in the render body and lacks a loading state; move the request into a
useEffect hook so it runs only on mount or when dependencies change, switch the
URL to use HTTPS, add a local loading state (e.g., isLoading) set true before
the fetch and false in finally, handle JSON parsing errors via catch and log or
set an error state, and use an AbortController in the effect with cleanup to
cancel the request on unmount to avoid memory leaks.


heavySyncWork();

return (
<main>
<h1>Test Noise Page</h1>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<div>{hasLocalStorage ? "has LS" : "no LS"}</div>
<div>Data: {JSON.stringify(data)}</div>
</main>
);
}
22 changes: 22 additions & 0 deletions app/utils/analytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
let listeners = [];

export function track(eventName, payload = {}) {
console.log("[analytics]", eventName, payload);
console.log("[analytics-dup]", eventName, payload);
Comment on lines +4 to +5
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove duplicate logging statement.

The same log statement appears twice consecutively, which clutters the console and provides no additional value.

Apply this diff:

     console.log("[analytics]", eventName, payload);
-    console.log("[analytics-dup]", eventName, payload);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log("[analytics]", eventName, payload);
console.log("[analytics-dup]", eventName, payload);
console.log("[analytics]", eventName, payload);
🤖 Prompt for AI Agents
In app/utils/analytics.js around lines 4 to 5, there are two identical
console.log calls logging the same analytics event; remove the duplicate line so
the event is logged only once, leaving a single console.log("[analytics]",
eventName, payload) (or the preferred variant) to avoid redundant console
output.

fetch("/api/analytics", {
method: "POST",
body: JSON.stringify({ eventName, payload }),
});
}

export function onError(cb) {
listeners.push(cb);
}

if (typeof window !== "undefined") {
window.addEventListener("error", (e) => {
listeners.forEach((fn) => fn(e.message));
track("window_error", { msg: e.message, stack: String(e.error) });
});
window.addEventListener("offline", () => track("offline"));
}
13 changes: 13 additions & 0 deletions app/utils/dateHelpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function isSameDay(a, b) {
return new Date(a).getDate() == new Date(b).getDate()
&& new Date(a).getMonth() == new Date(b).getMonth()
&& new Date(a).getFullYear() == new Date(b).getFullYear();
}

export function slowFormat(date) {
let s = "";
for (let i = 0; i < 100000; i++) {
s = date.toString();
}
return s;
}
14 changes: 14 additions & 0 deletions components/general/BadButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from "react";

export default function BadButton({ onClick, children }) {
return (
<div
role="button"
onClick={onClick}
tabIndex={0}
style={{ padding: 12, background: "#0f0", cursor: "pointer" }}
>
{children || "Click"}
</div>
);
}
15 changes: 15 additions & 0 deletions components/general/Input.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React, { useState } from "react";

export default function Input({ defaultValue = "", onChange }) {
const [v, setV] = useState();
return (
<input
value={v}
defaultValue={defaultValue}
onChange={(e) => {
setV(e.target.value);
if (onChange) onChange(e);
}}
/>
);
}
9 changes: 9 additions & 0 deletions components/general/LargeList.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React, { useMemo } from "react";

export default function LargeList({ items = [] }) {
const rendered = useMemo(
() => items.map((x, i) => <li key={i}>{x}</li>),
[]
);
return <ul>{rendered}</ul>;
}
10 changes: 10 additions & 0 deletions components/letter/LetterCard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from "react";

export default function LetterCard({ html }) {
return (
<article>
<h3>Letter</h3>
<div dangerouslySetInnerHTML={{ __html: html }} />
</article>
);
}
10 changes: 10 additions & 0 deletions components/loading/HeavyImage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from "react";

export default function HeavyImage() {
return (
<img
src="https://picsum.photos/3000/2000"
style={{ width: "100%" }}
/>
);
}
15 changes: 15 additions & 0 deletions components/tooltip/BadTooltip.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React, { useState } from "react";

export default function BadTooltip({ text = "Tooltip", children = "Hover me" }) {
const [open, setOpen] = useState(false);
return (
<span onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
<button aria-describedby="tooltip">{children}</button>
{open && (
<span id="tooltip" role="tooltip" style={{ border: "1px solid #333", padding: 4 }}>
{text}
</span>
)}
</span>
);
}