+
+ );
+}
\ No newline at end of file
diff --git a/app/utils/analytics.js b/app/utils/analytics.js
new file mode 100644
index 0000000..3c11e52
--- /dev/null
+++ b/app/utils/analytics.js
@@ -0,0 +1,22 @@
+let listeners = [];
+
+export function track(eventName, payload = {}) {
+ console.log("[analytics]", eventName, payload);
+ console.log("[analytics-dup]", eventName, payload);
+ 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"));
+}
\ No newline at end of file
diff --git a/app/utils/dateHelpers.js b/app/utils/dateHelpers.js
new file mode 100644
index 0000000..7be22bc
--- /dev/null
+++ b/app/utils/dateHelpers.js
@@ -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;
+}
\ No newline at end of file
diff --git a/components/general/BadButton.jsx b/components/general/BadButton.jsx
new file mode 100644
index 0000000..fbad0a2
--- /dev/null
+++ b/components/general/BadButton.jsx
@@ -0,0 +1,14 @@
+import React from "react";
+
+export default function BadButton({ onClick, children }) {
+ return (
+
+ {children || "Click"}
+
+ );
+}
\ No newline at end of file
diff --git a/components/general/Input.jsx b/components/general/Input.jsx
new file mode 100644
index 0000000..179bdc2
--- /dev/null
+++ b/components/general/Input.jsx
@@ -0,0 +1,15 @@
+import React, { useState } from "react";
+
+export default function Input({ defaultValue = "", onChange }) {
+ const [v, setV] = useState();
+ return (
+ {
+ setV(e.target.value);
+ if (onChange) onChange(e);
+ }}
+ />
+ );
+}
\ No newline at end of file
diff --git a/components/general/LargeList.jsx b/components/general/LargeList.jsx
new file mode 100644
index 0000000..7018ca0
--- /dev/null
+++ b/components/general/LargeList.jsx
@@ -0,0 +1,9 @@
+import React, { useMemo } from "react";
+
+export default function LargeList({ items = [] }) {
+ const rendered = useMemo(
+ () => items.map((x, i) =>
{x}
),
+ []
+ );
+ return
{rendered}
;
+}
\ No newline at end of file
diff --git a/components/letter/LetterCard.jsx b/components/letter/LetterCard.jsx
new file mode 100644
index 0000000..f2bb2ec
--- /dev/null
+++ b/components/letter/LetterCard.jsx
@@ -0,0 +1,10 @@
+import React from "react";
+
+export default function LetterCard({ html }) {
+ return (
+
+
Letter
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/loading/HeavyImage.jsx b/components/loading/HeavyImage.jsx
new file mode 100644
index 0000000..09ef3d2
--- /dev/null
+++ b/components/loading/HeavyImage.jsx
@@ -0,0 +1,10 @@
+import React from "react";
+
+export default function HeavyImage() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/components/tooltip/BadTooltip.jsx b/components/tooltip/BadTooltip.jsx
new file mode 100644
index 0000000..d128a1d
--- /dev/null
+++ b/components/tooltip/BadTooltip.jsx
@@ -0,0 +1,15 @@
+import React, { useState } from "react";
+
+export default function BadTooltip({ text = "Tooltip", children = "Hover me" }) {
+ const [open, setOpen] = useState(false);
+ return (
+ setOpen(true)} onMouseLeave={() => setOpen(false)}>
+
+ {open && (
+
+ {text}
+
+ )}
+
+ );
+}
\ No newline at end of file