diff --git a/src/routes/auth.tsx b/src/routes/auth.tsx
new file mode 100644
index 0000000..470b0f1
--- /dev/null
+++ b/src/routes/auth.tsx
@@ -0,0 +1,22 @@
+import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
+import LogoWithText from "@/icons/logo-with-text.svg?react";
+
+export const Route = createFileRoute("/auth")({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/routes/auth/code.tsx b/src/routes/auth/code.tsx
new file mode 100644
index 0000000..b9a768b
--- /dev/null
+++ b/src/routes/auth/code.tsx
@@ -0,0 +1,171 @@
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { TextLink } from "@/components/ui/text-link";
+
+export const Route = createFileRoute("/auth/code")({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ const navigate = useNavigate();
+ const [code, setCode] = useState
(Array(6).fill(""));
+
+ const [resendTimeout, setResendTimeout] = useState(() => {
+ const savedTimeout = localStorage.getItem("resendTimeout");
+ const savedTimestamp = localStorage.getItem("resendTimeoutTimestamp");
+
+ if (savedTimeout && savedTimestamp) {
+ const secondsPassed = Math.floor(
+ (Date.now() - Number(savedTimestamp)) / 1000,
+ );
+ const remainingTime = Math.max(0, Number(savedTimeout) - secondsPassed);
+ return remainingTime > 0 ? Math.floor(remainingTime) : 0;
+ }
+ return 0;
+ });
+
+ useEffect(() => {
+ document.getElementById("code-input-0")?.focus();
+ }, []);
+
+ useEffect(() => {
+ if (resendTimeout <= 0) {
+ localStorage.removeItem("resendTimeout");
+ localStorage.removeItem("resendTimeoutTimestamp");
+ return;
+ }
+
+ localStorage.setItem("resendTimeout", resendTimeout.toString());
+ localStorage.setItem("resendTimeoutTimestamp", Date.now().toString());
+
+ const timer = setTimeout(() => {
+ setResendTimeout(resendTimeout - 1);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }, [resendTimeout]);
+
+ const handleChange = (index: number, value: string) => {
+ if (!/^\d?$/.test(value)) return;
+
+ const newCode = [...code];
+ newCode[index] = value;
+ setCode(newCode);
+
+ if (value && index < 5) {
+ const nextInput = document.getElementById(`code-input-${index + 1}`);
+ nextInput?.focus();
+ }
+ };
+
+ const handleKeyDown = (
+ index: number,
+ e: React.KeyboardEvent,
+ ) => {
+ if (e.key === "Backspace") {
+ const newCode = [...code];
+
+ if (code[index] === "") {
+ if (index > 0) {
+ newCode[index - 1] = "";
+ setCode(newCode);
+ document.getElementById(`code-input-${index - 1}`)?.focus();
+ }
+ } else {
+ newCode[index] = "";
+ setCode(newCode);
+ }
+ }
+ };
+
+ const handlePaste = (e: React.ClipboardEvent) => {
+ const pasted = e.clipboardData
+ .getData("Text")
+ .replace(/\D/g, "")
+ .slice(0, 6);
+ if (!pasted) return;
+
+ const newCode = [...code];
+ for (let i = 0; i < pasted.length; i++) {
+ newCode[i] = pasted[i];
+ }
+ setCode(newCode);
+
+ const nextIndex = pasted.length < 6 ? pasted.length : 5;
+ document.getElementById(`code-input-${nextIndex}`)?.focus();
+ };
+
+ const handleResendClick = () => {
+ setResendTimeout(60);
+ };
+
+ return (
+
+
+
+ Welcome to Cytonic!
+
+
Sign in
+
+ We’ve sent a code to{" "}
+ email@example.com
+
+
+
+
+
+ {code.map((digit, i) => (
+ handleChange(i, e.target.value)}
+ onKeyDown={(e) => handleKeyDown(i, e)}
+ autoComplete="one-time-code"
+ />
+ ))}
+
+
+
+
+
+
+
+ Didn't receive a code?
+
+ {resendTimeout > 0 ? (
+
+ Next resend in {resendTimeout}s
+
+ ) : (
+
+ [ Click to resend ]
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/routes/auth/mail.tsx b/src/routes/auth/mail.tsx
new file mode 100644
index 0000000..52b8746
--- /dev/null
+++ b/src/routes/auth/mail.tsx
@@ -0,0 +1,54 @@
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { useState } from "react";
+
+export const Route = createFileRoute("/auth/mail")({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ const [email, setEmail] = useState("");
+ const navigate = useNavigate();
+
+ const handleSubmit = () => {
+ if (email.trim() === "") return;
+ navigate({
+ to: "/auth/code",
+ viewTransition: { types: ["warp"] },
+ });
+ };
+
+ return (
+
+
+
+ Welcome to Cytonic!
+
+
Sign in
+
+
+
+ setEmail(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleSubmit();
+ }
+ }}
+ />
+
+
+
+
+ );
+}
diff --git a/src/routes/auth/username.tsx b/src/routes/auth/username.tsx
new file mode 100644
index 0000000..4252995
--- /dev/null
+++ b/src/routes/auth/username.tsx
@@ -0,0 +1,66 @@
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { useState } from "react";
+
+export const Route = createFileRoute("/auth/username")({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ const [username, setUsername] = useState("");
+ const navigate = useNavigate();
+
+ const handleSubmit = () => {
+ if (username.trim() === "") return;
+ navigate({
+ to: "/",
+ viewTransition: { types: ["warp"] },
+ });
+ };
+
+ return (
+
+
+
+ Welcome to Cytonic!
+
+
Sign in
+
+ Create a username
+
+
+
+
+
setUsername(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleSubmit();
+ }
+ }}
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index af885e7..e9bdda5 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -1,5 +1,7 @@
-import { TextLink } from "@/components/ui/text-link";
-import { createFileRoute } from "@tanstack/react-router";
+import { Chat } from "@/components/interaction-panel/chat";
+import { createFileRoute, Link } from "@tanstack/react-router";
+import LogoWithText from "@/icons/logo-with-text.svg?react";
+import { Button } from "@/components/ui/button";
export const Route = createFileRoute("/")({
component: App,
@@ -7,8 +9,16 @@ export const Route = createFileRoute("/")({
function App() {
return (
-
-
Lable
+
+
+
);
}
diff --git a/src/routes/mail-code/$mail-code.tsx b/src/routes/mail-code/$mail-code.tsx
new file mode 100644
index 0000000..c5ed9f0
--- /dev/null
+++ b/src/routes/mail-code/$mail-code.tsx
@@ -0,0 +1,193 @@
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
+import { TextLink } from "@/components/ui/text-link";
+import LogoWithText from "@/icons/logo-with-text.svg?react";
+
+export const Route = createFileRoute("/mail-code/$mail-code")({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ const navigate = useNavigate();
+ const { "mail-code": codeFromUrl } = Route.useParams();
+
+ const [code, setCode] = useState
(Array(6).fill(""));
+ const [isAutoSubmitted, setIsAutoSubmitted] = useState(false);
+
+ const [resendTimeout, setResendTimeout] = useState(() => {
+ const savedTimeout = localStorage.getItem("resendTimeout");
+ const savedTimestamp = localStorage.getItem("resendTimeoutTimestamp");
+
+ if (savedTimeout && savedTimestamp) {
+ const secondsPassed = Math.floor(
+ (Date.now() - Number(savedTimestamp)) / 1000,
+ );
+ const remainingTime = Math.max(0, Number(savedTimeout) - secondsPassed);
+ return remainingTime > 0 ? Math.floor(remainingTime) : 0;
+ }
+ return 0;
+ });
+
+ useEffect(() => {
+ if (codeFromUrl && /^\d{6}$/.test(codeFromUrl)) {
+ const codeArray = codeFromUrl.split("").slice(0, 6);
+ setCode(codeArray);
+
+ const timer = setTimeout(() => {
+ handleConfirm(codeArray.join(""));
+ }, 800);
+
+ return () => clearTimeout(timer);
+ }
+ }, [codeFromUrl]);
+
+ useEffect(() => {
+ if (resendTimeout <= 0) {
+ localStorage.removeItem("resendTimeout");
+ localStorage.removeItem("resendTimeoutTimestamp");
+ return;
+ }
+
+ localStorage.setItem("resendTimeout", resendTimeout.toString());
+ localStorage.setItem("resendTimeoutTimestamp", Date.now().toString());
+
+ const timer = setTimeout(() => {
+ setResendTimeout(resendTimeout - 1);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }, [resendTimeout]);
+
+ const handleChange = (index: number, value: string) => {
+ if (!/^\d?$/.test(value)) return;
+
+ const newCode = [...code];
+ newCode[index] = value;
+ setCode(newCode);
+
+ if (value && index < 5) {
+ const nextInput = document.getElementById(`code-input-${index + 1}`);
+ nextInput?.focus();
+ }
+ };
+
+ const handleKeyDown = (
+ index: number,
+ e: React.KeyboardEvent,
+ ) => {
+ if (e.key === "Backspace") {
+ const newCode = [...code];
+
+ if (code[index] === "") {
+ if (index > 0) {
+ newCode[index - 1] = "";
+ setCode(newCode);
+ document.getElementById(`code-input-${index - 1}`)?.focus();
+ }
+ } else {
+ newCode[index] = "";
+ setCode(newCode);
+ }
+ }
+ };
+
+ const handlePaste = (e: React.ClipboardEvent) => {
+ const pasted = e.clipboardData
+ .getData("Text")
+ .replace(/\D/g, "")
+ .slice(0, 6);
+ if (!pasted) return;
+
+ const newCode = [...code];
+ for (let i = 0; i < pasted.length; i++) {
+ newCode[i] = pasted[i];
+ }
+ setCode(newCode);
+
+ const nextIndex = pasted.length < 6 ? pasted.length : 5;
+ document.getElementById(`code-input-${nextIndex}`)?.focus();
+ };
+
+ const handleResendClick = () => {
+ setResendTimeout(60);
+ };
+
+ const handleConfirm = (fullCode: string) => {
+ navigate({
+ to: "/auth/username",
+ viewTransition: { types: ["warp"] },
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+ Welcome to Cytonic!
+
+
Sign in
+
+
+
+
+ {code.map((digit, i) => (
+ handleChange(i, e.target.value)}
+ onKeyDown={(e) => handleKeyDown(i, e)}
+ autoComplete="one-time-code"
+ readOnly={isAutoSubmitted}
+ />
+ ))}
+
+
+
+
+
+
+
+ Didn't receive a code?
+
+ {resendTimeout > 0 ? (
+
+ Next resend in {resendTimeout}s
+
+ ) : (
+
+ [ Click to resend ]
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/store/account.ts b/src/store/account.ts
index 2f3c96c..4aa998f 100644
--- a/src/store/account.ts
+++ b/src/store/account.ts
@@ -2,6 +2,8 @@ import { LS_TOKENS } from "@/lib/constants";
import { makeAutoObservable } from "mobx";
export class AccountStore {
+ userId?: string;
+
isMainMenuOpen: boolean;
isSandboxOpen: boolean;
@@ -12,6 +14,9 @@ export class AccountStore {
this.isMainMenuOpen = savedState ? savedState === "true" : false;
this.isSandboxOpen = false;
+
+ const savedUserId = localStorage.getItem(LS_TOKENS.userId);
+ this.userId = savedUserId ? savedUserId : undefined;
}
setIsMainMenuOpen(value: boolean) {
@@ -22,4 +27,9 @@ export class AccountStore {
setIsSandboxOpen(value: boolean) {
this.isSandboxOpen = value;
}
+
+ setUserId(value: string) {
+ this.userId = value;
+ localStorage.setItem(LS_TOKENS.userId, value);
+ }
}
diff --git a/src/styles.css b/src/styles.css
index 3a4a559..8eccddc 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -2,9 +2,16 @@
@theme {
--font-work: "work-sans", "sans-serif";
}
+
+textarea:focus,
+input:focus {
+ outline: none;
+}
+
@theme inline {
--color-bg-dark:#212121;
--color-bg-light:#FFFFFF;
+ --color-brand-gray:#201E1E;
--color-text-light-900:#0A0A0A;
--color-text-light-500:#939393;
@@ -20,6 +27,7 @@
--color-fill-700:#363636;
--color-fill-400:#939393;
--color-fill-300:#C0C0C0;
+ --color-fill-200:#D6D6D6;
--color-fill-150:#E8E8E8;
--color-fill-100:#F5F5F5;