diff --git a/.gitignore b/.gitignore index eb53533..ec1eb57 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ ash_learning-*.tar app-*.tar /priv/static/assets/ +/priv/static/fonts/ /priv/ssr-js/ /priv/static/cache_manifest.json /priv/plts/ diff --git a/AGENTS.md b/AGENTS.md index f4ea57e..f47552d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ This is a web application written using the Phoenix web framework. - Use `mix precommit` alias when you are done with all changes and fix any pending issues - Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps +- **All Node.js/package.json commands must be run through `./bin/pnpm.sh COMMAND`** - This includes `lint`, `fmt`, `dev`, `build`, etc. Never run pnpm/npm commands directly in the assets directory ### Phoenix v1.8 guidelines @@ -416,7 +417,7 @@ mix usage_rules.docs Enum.zip/1 ## Searching Documentation -You should also consult the documentation of any tools you are using, early and often. The best +You should also consult the documentation of any tools you are using, early and often. The best way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have found what you are looking for, use the links in the search results to get more detail. For example: @@ -996,7 +997,7 @@ mix usage_rules.docs Enum.zip/1 ## Searching Documentation -You should also consult the documentation of any tools you are using, early and often. The best +You should also consult the documentation of any tools you are using, early and often. The best way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have found what you are looking for, use the links in the search results to get more detail. For example: @@ -1570,7 +1571,7 @@ mix usage_rules.docs Enum.zip/1 ## Searching Documentation -You should also consult the documentation of any tools you are using, early and often. The best +You should also consult the documentation of any tools you are using, early and often. The best way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have found what you are looking for, use the links in the search results to get more detail. For example: diff --git a/assets/.oxfmtrc.json b/assets/.oxfmtrc.json index 6201ef8..a432300 100644 --- a/assets/.oxfmtrc.json +++ b/assets/.oxfmtrc.json @@ -5,6 +5,7 @@ "useTabs": false, "semi": false, "singleQuote": true, + "jsxSingleQuote": true, "trailingComma": "all", "experimentalSortImports": { "groups": [ diff --git a/assets/.oxlintrc.json b/assets/.oxlintrc.json index ff9e35d..76d4528 100644 --- a/assets/.oxlintrc.json +++ b/assets/.oxlintrc.json @@ -24,7 +24,7 @@ "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "react-refresh/only-export-components": [ - "warn", + "error", { "allowConstantExport": true } diff --git a/assets/components.json b/assets/components.json new file mode 100644 index 0000000..afcd14f --- /dev/null +++ b/assets/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "css/app.css", + "baseColor": "gray", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "inverted", + "menuAccent": "subtle", + "registries": {} +} diff --git a/assets/css/app.css b/assets/css/app.css index d4b5078..72b9d7d 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1 +1,150 @@ @import 'tailwindcss'; +@import 'tw-animate-css'; +@import 'shadcn/tailwind.css'; +@import './fonts.css'; + +@source "../../lib/ash_learning_web/components/layouts/root.html.heex"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.13 0.028 261.692); + --card: oklch(1 0 0); + --card-foreground: oklch(0.13 0.028 261.692); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.13 0.028 261.692); + --primary: oklch(0.21 0.034 264.665); + --primary-foreground: oklch(0.985 0.002 247.839); + --secondary: oklch(0.967 0.003 264.542); + --secondary-foreground: oklch(0.21 0.034 264.665); + --muted: oklch(0.967 0.003 264.542); + --muted-foreground: oklch(0.551 0.027 264.364); + --accent: oklch(0.967 0.003 264.542); + --accent-foreground: oklch(0.21 0.034 264.665); + + --destructive: oklch(93.6% 0.032 17.717); /* Red 100 */ + --destructive-muted: oklch(97.1% 0.013 17.38); /* Red 50 */ + --destructive-border: oklch(70.4% 0.191 22.216); /* Red 400 */ + --destructive-ring: oklch(93.6% 0.032 17.717); /* Red 100 */ + --destructive-foreground: oklch(50.5% 0.213 27.518); /* Red 700 */ + --destructive-foreground-muted: oklch(57.7% 0.245 27.325); /* Red 600 */ + + --border: oklch(0.928 0.006 264.531); + --input: oklch(0.928 0.006 264.531); + --ring: oklch(0.707 0.022 261.325); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.45rem; + --sidebar: oklch(0.985 0.002 247.839); + --sidebar-foreground: oklch(0.13 0.028 261.692); + --sidebar-primary: oklch(0.21 0.034 264.665); + --sidebar-primary-foreground: oklch(0.985 0.002 247.839); + --sidebar-accent: oklch(0.967 0.003 264.542); + --sidebar-accent-foreground: oklch(0.21 0.034 264.665); + --sidebar-border: oklch(0.928 0.006 264.531); + --sidebar-ring: oklch(0.707 0.022 261.325); +} + +.dark { + --background: oklch(0.13 0.028 261.692); + --foreground: oklch(0.985 0.002 247.839); + --card: oklch(0.21 0.034 264.665); + --card-foreground: oklch(0.985 0.002 247.839); + --popover: oklch(0.21 0.034 264.665); + --popover-foreground: oklch(0.985 0.002 247.839); + --primary: oklch(0.928 0.006 264.531); + --primary-foreground: oklch(0.21 0.034 264.665); + --secondary: oklch(0.278 0.033 256.848); + --secondary-foreground: oklch(0.985 0.002 247.839); + --muted: oklch(0.278 0.033 256.848); + --muted-foreground: oklch(0.707 0.022 261.325); + --accent: oklch(0.278 0.033 256.848); + --accent-foreground: oklch(0.985 0.002 247.839); + + --destructive: oklch(57.7% 0.245 27.325); /* Red 600 */ + --destructive-muted: oklch(50.5% 0.213 27.518); /* Red 700 */ + --destructive-border: oklch(70.4% 0.191 22.216); /* Red 400 */ + --destructive-ring: oklch(97.1% 0.013 17.38 / 30%); /* Red 50 */ + --destructive-foreground: oklch(93.6% 0.032 17.717); /* Red 100 */ + --destructive-foreground-muted: oklch(88.5% 0.062 18.334); /* Red 200 */ + + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.034 264.665); + --sidebar-foreground: oklch(0.985 0.002 247.839); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0.002 247.839); + --sidebar-accent: oklch(0.278 0.033 256.848); + --sidebar-accent-foreground: oklch(0.985 0.002 247.839); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.551 0.027 264.364); +} + +@theme inline { + --font-sans: 'Geist Variable', sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-destructive-muted: var(--destructive-muted); + --color-destructive-border: var(--destructive-border); + --color-destructive-ring: var(--destructive-ring); + --color-destructive-foreground: var(--destructive-foreground); + --color-destructive-foreground-muted: var(--destructive-foreground-muted); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply font-sans bg-background text-foreground; + } + html { + @apply font-sans; + } +} diff --git a/assets/css/fonts.css b/assets/css/fonts.css new file mode 100644 index 0000000..ca78b66 --- /dev/null +++ b/assets/css/fonts.css @@ -0,0 +1,64 @@ +/* + * Geist Variable Font - Manual Installation + * + * Source: https://fontsource.org/fonts/geist/install + * + * This is a manual installation of the Geist Variable font. + * + * The recommended approach by Fontsource and Shadcn is to use: + * npm install @fontsource-variable/geist + * @import '@fontsource-variable/geist'; + * + * However, due to development server complexities in our Phoenix + Docker setup, + * we're using manual font files copied from the npm package. + * + * Font files copied from: node_modules/@fontsource-variable/geist/files/ + * CSS definitions copied from: node_modules/@fontsource-variable/geist/index.css + * + * To update fonts: + * 1. Update the @fontsource-variable/geist package + * 2. Copy new font files from node_modules/@fontsource-variable/geist/files/ + * to assets/static/fonts/ and priv/static/fonts/ + * 3. Update the CSS below from node_modules/@fontsource-variable/geist/index.css + * + * Note: priv/static/fonts/ is gitignored as it's a build artifact. + * The source fonts in assets/static/fonts/ are committed to git. + * + * Usage in CSS: + * font-family: "Geist Variable", sans-serif; + */ + +/* geist-cyrillic-wght-normal */ +@font-face { + font-family: 'Geist Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('/fonts/geist-cyrillic-wght-normal.woff2') format('woff2-variations'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* geist-latin-ext-wght-normal */ +@font-face { + font-family: 'Geist Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('/fonts/geist-latin-ext-wght-normal.woff2') format('woff2-variations'); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, + U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, + U+A720-A7FF; +} + +/* geist-latin-wght-normal */ +@font-face { + font-family: 'Geist Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('/fonts/geist-latin-wght-normal.woff2') format('woff2-variations'); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/assets/js/Layouts/AppLayout/index.tsx b/assets/js/Layouts/AppLayout/index.tsx index d3781ef..621c0a2 100644 --- a/assets/js/Layouts/AppLayout/index.tsx +++ b/assets/js/Layouts/AppLayout/index.tsx @@ -28,7 +28,7 @@ export default function AppLayout({ * [Read more](https://inertiajs.com/pages#scroll-regions) */}
{children} diff --git a/assets/js/Layouts/AuthLayout/index.tsx b/assets/js/Layouts/AuthLayout/index.tsx index 01d9cbf..5330fda 100644 --- a/assets/js/Layouts/AuthLayout/index.tsx +++ b/assets/js/Layouts/AuthLayout/index.tsx @@ -3,78 +3,62 @@ import { ReactNode } from 'react' import { index as signup } from '@/actions/Auth/RegisterController' import { login } from '@/actions/Auth/SessionsController' -import { useHost } from '@/hooks/useHost' +import { LogoLink } from '@/components/LogoLink' import MainLayout from '@/Layouts/MainLayout' - -function AuthLink({ text, href }: { text: string; href: string }) { - // return ( - // - // {text} - // - // ) - return {text} -} +import { Text } from '@/ui/atoms/Text' +import { FieldDescription } from '@/ui/molecules/Form/Field' function FooterLink({ children }: { children: ReactNode }) { - // return ( - // - // {children} - // - // ) - return children + return {children} } -function AuthLayout({ - title, - card, - showToS = false, - children, -}: { - title?: string - card?: { - title: string - description: string - footer?: ReactNode - // variant?: ComponentProps['variant'] - } - showToS?: boolean - children: ReactNode -}) { - const { buildSiteUrl } = useHost() +function AuthLayout({ title, children }: { title?: string; children: ReactNode }) { return ( -
-
- -
- {/* LOGO Here. Ignore do not change for now */} -
-
-
- {card ?
{children}
: children} - {showToS ? ( - <> - By clicking continue, you agree to our Terms of Service - and Privacy Policy.{' '} - - ) : null} +
+
+
+
+
+
{children}
+
+
+
+
) } -AuthLayout.goSignup = () => ( +AuthLayout.SignupLink = () => ( - Don't have an account? + Don't have an account? Sign up ) -AuthLayout.goLogin = () => ( +AuthLayout.LoginLink = () => ( - Already have an account? + Already have an account? Sign in ) +AuthLayout.FooterLink = FooterLink + +AuthLayout.Header = ({ title, description }: { title: string; description: string }) => ( +
+ +

{title}

+
+ +

{description}

+
+
+) + export default AuthLayout diff --git a/assets/js/Layouts/MainLayout/index.tsx b/assets/js/Layouts/MainLayout/index.tsx index 477d597..10f2bc4 100644 --- a/assets/js/Layouts/MainLayout/index.tsx +++ b/assets/js/Layouts/MainLayout/index.tsx @@ -1,11 +1,18 @@ import { Head } from '@inertiajs/react' +import { ThemeProvider } from 'next-themes' import { ReactNode } from 'react' +import { FlashMessage } from '@/ui/molecules/FlashMessage' +import { Toaster } from '@/ui/molecules/Toast/Primitives' + +const TOAST_DURATION = 4000 export default function MainLayout({ title, children }: { title?: string; children: ReactNode }) { return ( -
+ {children} -
+ + + ) } diff --git a/assets/js/Pages/Auth/ConfirmationTokenPage/index.tsx b/assets/js/Pages/Auth/ConfirmationTokenPage/index.tsx index 4846f71..5f6b542 100644 --- a/assets/js/Pages/Auth/ConfirmationTokenPage/index.tsx +++ b/assets/js/Pages/Auth/ConfirmationTokenPage/index.tsx @@ -5,6 +5,8 @@ import { update as magicLinkUpdate } from '@/actions/Auth/MagicLinkController' import { update as registerUpdate } from '@/actions/Auth/RegisterController' import { login } from '@/actions/Auth/SessionsController' import AuthLayout from '@/Layouts/AuthLayout' +import { Button } from '@/ui/atoms/Button' +import { FieldGroup } from '@/ui/molecules/Form/Field' type Props = { token: string @@ -15,27 +17,27 @@ function ConfirmationTokenPage({ token, action_type }: Props) { const action = action_type === 'register' ? registerUpdate(token) : magicLinkUpdate(token) return ( - <> -
-

Complete Sign In

-

Click the button below to sign in to your account.

-
- -
- {({ processing }) => ( - - )} -
- - Back to Login - +
+ {({ processing }) => ( + + + + + Back to Login + + + )} +
) } ConfirmationTokenPage.layout = (children: ReactNode) => ( - {children} + {children} ) export default ConfirmationTokenPage diff --git a/assets/js/Pages/Auth/LoginPage/index.tsx b/assets/js/Pages/Auth/LoginPage/index.tsx index 9c02c45..a8edada 100644 --- a/assets/js/Pages/Auth/LoginPage/index.tsx +++ b/assets/js/Pages/Auth/LoginPage/index.tsx @@ -1,93 +1,66 @@ -import { Form, Link } from '@inertiajs/react' -import { ReactNode, useCallback } from 'react' +import { Form } from '@inertiajs/react' +import { ReactNode } from 'react' -import { index as register } from '@/actions/Auth/RegisterController' -import { index as resetPassword } from '@/actions/Auth/ResetPasswordsController' import { create as login } from '@/actions/Auth/SessionsController' -import { MagicLinkForm } from '@/components/Auth/MagicLinkForm' +import { OuathConnectButtons } from '@/components/Auth/OuathConnectButtons' +import { PasswordField } from '@/components/Auth/PasswordField' +import { usePassword } from '@/components/Auth/PasswordField/usePassword' import AuthLayout from '@/Layouts/AuthLayout' -import { OAuthProvider } from '@/types' +import { OAuthProvider, OauthStrategy } from '@/types' +import { Button } from '@/ui/atoms/Button' +import { Input } from '@/ui/atoms/Input' +import { + Field, + FieldError, + FieldLabel, + FieldGroup, + FieldSeparator, +} from '@/ui/molecules/Form/Field' type Props = { return_to: string - oauth_providers: OAuthProvider[] + oauth_providers: OAuthProvider[] } function LoginPage({ return_to, oauth_providers }: Props) { - const resetPasswordUrl = useCallback((formData: object) => { - const resetPath = resetPassword.url().path - const email = 'email' in formData ? formData.email : null - return email ? `${resetPath}?email=${email}` : resetPath - }, []) + const { action, showPassword, toggle } = usePassword({ route: login() }) return ( - <> -
-

Welcome Back

-

Login to your account to continue learning

-
- -
- {({ errors, getData, processing }) => ( - <> - - -
- - - {errors.email &&

{errors.email}

} -
- -
- - - {errors.password &&

{errors.password}

} -
- - Forgot your password? - -
- - -
- - - - )} -
- -
-

- Need an account? Create one -

-
- -
-

Use other methods

- -
- {oauth_providers.map((provider) => ( - - Continue with {provider.display_name} - - ))} -
- -
-

Or continue with email

- - - +
+ {({ errors, getData, processing }) => ( + + + + + Email + + + + {showPassword && ( + + )} + + + + + + Or continue with + + + + + )} +
) } -LoginPage.layout = (children: ReactNode) => {children} +LoginPage.layout = (children: ReactNode) => {children} export default LoginPage diff --git a/assets/js/Pages/Auth/RegisterPage/index.tsx b/assets/js/Pages/Auth/RegisterPage/index.tsx index e400ff6..7ae9a9a 100644 --- a/assets/js/Pages/Auth/RegisterPage/index.tsx +++ b/assets/js/Pages/Auth/RegisterPage/index.tsx @@ -1,102 +1,64 @@ -import { Form, Link } from '@inertiajs/react' +import { Form } from '@inertiajs/react' import { ReactNode } from 'react' import { create as register } from '@/actions/Auth/RegisterController' -import { login } from '@/actions/Auth/SessionsController' -import { MagicLinkForm } from '@/components/Auth/MagicLinkForm' +import { OuathConnectButtons } from '@/components/Auth/OuathConnectButtons' +import { PasswordField } from '@/components/Auth/PasswordField' +import { usePassword } from '@/components/Auth/PasswordField/usePassword' import AuthLayout from '@/Layouts/AuthLayout' -import { OAuthProvider } from '@/types' +import { OAuthProvider, OauthStrategy } from '@/types' +import { Button } from '@/ui/atoms/Button' +import { Input } from '@/ui/atoms/Input' +import { + Field, + FieldError, + FieldLabel, + FieldGroup, + FieldSeparator, +} from '@/ui/molecules/Form/Field' -/** - * TODO: Use Inertia.js `
` - */ function RegisterPage({ return_to, oauth_providers, }: { return_to: string - oauth_providers: OAuthProvider[] + oauth_providers: OAuthProvider[] }) { + const { action, showPassword, toggle } = usePassword({ route: register() }) return ( - <> -
-

Create Account

-

Join us and start your journey

-
+ + {({ errors, processing }) => ( + + + - - {({ errors, processing }) => ( - <> - + + Email + + + -
- - - {errors.email &&

{errors.email}

} -
- -
- - - {errors.password &&

{errors.password}

} -
- -
- - - {errors.password_confirmation &&

{errors.password_confirmation}

} -
- - - - )} - - -
-

- Already have an account? Sign in -

-
- -
-

Use other methods

- -
- {oauth_providers.map((provider) => ( - - Continue with {provider.display_name} - - ))} -
- -
-

Or continue with email

- - - + {showPassword && } + + + + + Or continue with + + +
+ )} + ) } -RegisterPage.layout = (children: ReactNode) => {children} +RegisterPage.layout = (children: ReactNode) => {children} export default RegisterPage diff --git a/assets/js/Pages/Auth/ResetPasswordEditPage/index.tsx b/assets/js/Pages/Auth/ResetPasswordEditPage/index.tsx index 80c07e9..5759e91 100644 --- a/assets/js/Pages/Auth/ResetPasswordEditPage/index.tsx +++ b/assets/js/Pages/Auth/ResetPasswordEditPage/index.tsx @@ -2,54 +2,34 @@ import { Form } from '@inertiajs/react' import { ReactNode } from 'react' import { update } from '@/actions/Auth/ResetPasswordsController' +import { PasswordField } from '@/components/Auth/PasswordField' import AuthLayout from '@/Layouts/AuthLayout' +import { Button } from '@/ui/atoms/Button' +import { FieldGroup } from '@/ui/molecules/Form/Field' function ResetPasswordEditPage({ reset_token }: { reset_token: string }) { return ( - <> -
-

Reset Password

-

Please enter your new password below.

-
+
+ {({ errors, processing }) => ( + + + + - - {({ errors, processing }) => ( - <> - -
- - - {errors.password &&

{errors.password}

} -
- -
- - - {errors.password_confirmation &&

{errors.password_confirmation}

} -
- - - - )} - - + +
+ )} + ) } ResetPasswordEditPage.layout = (children: ReactNode) => ( - {children} + {children} ) export default ResetPasswordEditPage diff --git a/assets/js/Pages/Auth/ResetPasswordPage/index.tsx b/assets/js/Pages/Auth/ResetPasswordPage/index.tsx index 70baac1..cf6b423 100644 --- a/assets/js/Pages/Auth/ResetPasswordPage/index.tsx +++ b/assets/js/Pages/Auth/ResetPasswordPage/index.tsx @@ -1,54 +1,47 @@ -import { Link, Form } from '@inertiajs/react' +import { Form } from '@inertiajs/react' import { ReactNode } from 'react' -import { index as register } from '@/actions/Auth/RegisterController' import { create } from '@/actions/Auth/ResetPasswordsController' import AuthLayout from '@/Layouts/AuthLayout' +import { Button } from '@/ui/atoms/Button' +import { Input } from '@/ui/atoms/Input' +import { Field, FieldError, FieldLabel, FieldGroup } from '@/ui/molecules/Form/Field' function ResetPasswordPage({ email }: { email?: string }) { return ( - <> -
-

Reset Password

-

- Forgot your password? No problem. Just let us know your email address and we will email - you a password reset link that will allow you to choose a new one. -

-
+
+ {({ errors, processing }) => ( + + + + Email + + + + - - {({ errors, processing }) => ( - <> -
- - - {errors.email &&

{errors.email}

} -
- - - - )} - - -
-

- Need an account? Create one -

-
- + +
+ )} + ) } ResetPasswordPage.layout = (children: ReactNode) => ( - {children} + {children} ) export default ResetPasswordPage diff --git a/assets/js/Pages/Dashboard/DashboardPage/index.tsx b/assets/js/Pages/Dashboard/DashboardPage/index.tsx index d86ad05..9a85e5a 100644 --- a/assets/js/Pages/Dashboard/DashboardPage/index.tsx +++ b/assets/js/Pages/Dashboard/DashboardPage/index.tsx @@ -4,10 +4,10 @@ import { ReactNode } from 'react' import { deleteMethod as logout } from '@/actions/Auth/SessionsController' import { ProvidersList } from '@/components/Dashboard/ProvidersList' import AppLayout from '@/Layouts/AppLayout' -import type { OAuthProvider, UserIdentity } from '@/types' +import type { OAuthProvider, OauthStrategy, UserIdentity } from '@/types' type Props = { - oauth_providers: OAuthProvider[] + oauth_providers: OAuthProvider[] identities: UserIdentity[] user_email: string } @@ -15,27 +15,27 @@ type Props = { function DashboardPage({ oauth_providers, identities, user_email }: Props) { return ( <> -
+
{/* Placeholder for other dashboard content */} -
-

Welcome to your Dashboard

-

+

+

Welcome to your Dashboard

+

Manage your account settings and connected services.

-
{user_email}
+
{user_email}
Logout
{/* Provider management section - 2/3 width on the right */} -
+
@@ -49,7 +49,7 @@ function DashboardPage({ oauth_providers, identities, user_email }: Props) { * [Learn more](https://inertiajs.com/pages#persistent-layouts) */ DashboardPage.layout = (children: ReactNode) => ( - + {children} ) diff --git a/assets/js/Pages/ErrorPage/index.tsx b/assets/js/Pages/ErrorPage/index.tsx index bf45714..1172369 100644 --- a/assets/js/Pages/ErrorPage/index.tsx +++ b/assets/js/Pages/ErrorPage/index.tsx @@ -17,14 +17,14 @@ type ErrorPageProps = PageProps<{ */ export default function ErrorPage({ title, description, status: _s }: ErrorPageProps) { return ( -
+
- - + + -
-

{title}

-

{description}

+
+

{title}

+

{description}

) diff --git a/assets/js/Pages/HomePage/index.tsx b/assets/js/Pages/HomePage/index.tsx index 61c8478..99474bd 100644 --- a/assets/js/Pages/HomePage/index.tsx +++ b/assets/js/Pages/HomePage/index.tsx @@ -5,8 +5,8 @@ function HomePage() { const { buildAppUrl } = useHost() const loginUrl = buildAppUrl({ path: login.url().path }) return ( -
-
Welcome to the Home Page Ugly as fuck page
+
+
Welcome to the Home Page Ugly as fuck page
Login
) diff --git a/assets/js/app.tsx b/assets/js/app.tsx index 83777d6..8395266 100644 --- a/assets/js/app.tsx +++ b/assets/js/app.tsx @@ -10,10 +10,16 @@ import { PageProps } from '@/types' axios.defaults.xsrfHeaderName = 'x-csrf-token' -createInertiaApp({ +void createInertiaApp({ resolve: resolvePage, setup({ App, el, props }) { - const { ssr } = props.initialPage.props as PageProps + // Type guard for PageProps + const isPageProps = (obj: unknown): obj is PageProps => { + return obj !== null && typeof obj === 'object' && ('ssr' in obj || 'currentPath' in obj) + } + + const initialPageProps = props.initialPage.props + const ssr = initialPageProps && isPageProps(initialPageProps) ? initialPageProps.ssr : false const app = ( diff --git a/assets/js/components/Auth/MagicLinkForm/index.tsx b/assets/js/components/Auth/MagicLinkForm/index.tsx deleted file mode 100644 index 36f79f6..0000000 --- a/assets/js/components/Auth/MagicLinkForm/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Form } from '@inertiajs/react' - -import { create as magicLink } from '@/actions/Auth/MagicLinkController' - -export function MagicLinkForm({ returnTo }: { returnTo: string }) { - return ( -
- {({ errors, processing }) => ( - <> - -
- - - {errors.email &&

{errors.email}

} -
- - - )} -
- ) -} diff --git a/assets/js/components/Auth/OuathConnectButtons/index.tsx b/assets/js/components/Auth/OuathConnectButtons/index.tsx new file mode 100644 index 0000000..a59f8d9 --- /dev/null +++ b/assets/js/components/Auth/OuathConnectButtons/index.tsx @@ -0,0 +1,25 @@ +import { Link } from '@inertiajs/react' + +import { OAuthProvider, OauthStrategy } from '@/types' +import { Button } from '@/ui/atoms/Button' + +import { ProviderIcon } from '../ProviderIcon' + +export function OuathConnectButtons({ providers }: { providers: OAuthProvider[] }) { + return ( +
+ {providers.map((provider) => ( + + ))} +
+ ) +} diff --git a/assets/js/components/Auth/PasswordField/index.tsx b/assets/js/components/Auth/PasswordField/index.tsx new file mode 100644 index 0000000..221da55 --- /dev/null +++ b/assets/js/components/Auth/PasswordField/index.tsx @@ -0,0 +1,54 @@ +import { FormDataConvertible } from '@inertiajs/core' +import { Link } from '@inertiajs/react' +import { useCallback } from 'react' + +import { index as resetPassword } from '@/actions/Auth/ResetPasswordsController' +import { Input } from '@/ui/atoms/Input' +import { Text } from '@/ui/atoms/Text' +import { Field, FieldLabel, FieldError } from '@/ui/molecules/Form/Field' + +export function PasswordField({ + errors, + getData, + showPasswordConfirmation, +}: { + errors: Record + showPasswordConfirmation?: boolean + getData?: () => Record +}) { + const resetPasswordUrl = useCallback((formData: Record) => { + const resetPath = resetPassword.url().path + const email = typeof formData.email === 'string' ? formData.email : '' + return email ? `${resetPath}?email=${encodeURIComponent(email)}` : resetPath + }, []) + const resetUrl = getData ? resetPasswordUrl(getData()) : null + return ( + <> + +
+ Password + {resetUrl ? ( + + Forgot your password? + + ) : null} +
+ + +
+ {showPasswordConfirmation ? ( + + Confirm Password + + + + ) : null} + + ) +} diff --git a/assets/js/components/Auth/PasswordField/usePassword.ts b/assets/js/components/Auth/PasswordField/usePassword.ts new file mode 100644 index 0000000..c504302 --- /dev/null +++ b/assets/js/components/Auth/PasswordField/usePassword.ts @@ -0,0 +1,16 @@ +import { useState, useCallback } from 'react' + +import { create as magicLink } from '@/actions/Auth/MagicLinkController' +import { RouteDefinition } from '@/wayfinder' + +export function usePassword({ route }: { route: RouteDefinition<'post'> }) { + const [showPassword, setUsePassword] = useState(true) + const handleToggleMethod = useCallback(() => { + setUsePassword((prev) => !prev) + }, []) + return { + showPassword, + toggle: handleToggleMethod, + action: showPassword ? route : magicLink(), + } +} diff --git a/assets/js/components/Auth/ProviderIcon/index.tsx b/assets/js/components/Auth/ProviderIcon/index.tsx new file mode 100644 index 0000000..c8ef940 --- /dev/null +++ b/assets/js/components/Auth/ProviderIcon/index.tsx @@ -0,0 +1,54 @@ +import { OAuthProvider, OauthStrategy } from '@/types' + +export function ProviderIcon({ + name, + className = 'w-6 h-6', +}: { + name: OAuthProvider['icon'] + className?: string +}) { + if (name === 'github') { + return ( + + + + ) + } + + if (name === 'google') { + return ( + + + + + + + ) + } + + // Default globe icon + return ( + + + + + ) +} diff --git a/assets/js/components/Dashboard/ProvidersList/index.tsx b/assets/js/components/Dashboard/ProvidersList/index.tsx index 8041f32..992a7dd 100644 --- a/assets/js/components/Dashboard/ProvidersList/index.tsx +++ b/assets/js/components/Dashboard/ProvidersList/index.tsx @@ -1,9 +1,10 @@ import { Link } from '@inertiajs/react' -import type { OAuthProvider, UserIdentity } from '@/types' +import { ProviderIcon } from '@/components/Auth/ProviderIcon' +import type { OAuthProvider, OauthStrategy, UserIdentity } from '@/types' type Props = { - providers: OAuthProvider[] + providers: OAuthProvider[] identities: UserIdentity[] } @@ -22,78 +23,37 @@ function groupIdentitiesByProvider(identities: UserIdentity[]) { ) } -function ProviderIcon({ name, className = 'w-6 h-6' }: { name: string; className?: string }) { - if (name === 'github') { - return ( - - - - ) - } - - if (name === 'google') { - return ( - - - - - - - ) - } - - // Default globe icon - return ( - - - - - ) -} - -function IdentityRow({ identity, provider }: { identity: UserIdentity; provider: OAuthProvider }) { +function IdentityRow({ + identity, + provider, +}: { + identity: UserIdentity + provider: OAuthProvider +}) { const disconnectUrl = `/providers/${provider.name}/${encodeURIComponent(identity.uid)}` return ( -
-
+
+
{identity.avatar_url && ( {`${provider.display_name} )}
- {identity.full_name && {identity.full_name}} - {identity.email &&

{identity.email}

} + {identity.full_name && {identity.full_name}} + {identity.email &&

{identity.email}

}
confirm(`Are you sure you want to disconnect this ${provider.display_name} account?`) } @@ -104,49 +64,48 @@ function IdentityRow({ identity, provider }: { identity: UserIdentity; provider: ) } -function ProviderSection({ +function ProviderSection({ provider, identities, }: { - provider: OAuthProvider + provider: OAuthProvider identities: UserIdentity[] }) { const isConnected = identities.length > 0 return ( -
- {/* Provider header with connect button */} -
-
+
+
+
-

{provider.display_name}

+

{provider.display_name}

{isConnected ? ( -

- +

+ {identities.length} account(s) connected

) : ( -

Not connected

+

Not connected

)}
- + {isConnected ? 'Add another' : 'Connect'} @@ -155,7 +114,7 @@ function ProviderSection({ {/* Connected identities list */} {isConnected && ( -
+
{identities.map((identity) => ( ))} @@ -169,15 +128,15 @@ export function ProvidersList({ providers, identities }: Props) { const identitiesByProvider = groupIdentitiesByProvider(identities) return ( -
+
-

Connect your accounts

-

+

Connect your accounts

+

Link your social accounts to enable sign-in with multiple providers.

-
+
{providers.map((provider) => ( + + + + + + + + + ) +} diff --git a/assets/js/components/LogoLink/index.tsx b/assets/js/components/LogoLink/index.tsx new file mode 100644 index 0000000..c818327 --- /dev/null +++ b/assets/js/components/LogoLink/index.tsx @@ -0,0 +1,17 @@ +import { Link } from '@inertiajs/react' + +import { Logo } from '@/components/Logo' +import { useHost } from '@/hooks/useHost' + +export function LogoLink({ url }: { url?: string }) { + const { buildSiteUrl } = useHost() + const logoLinkUrl = url || buildSiteUrl() + return ( + +
+ +
+ Ash Learning Inc. + + ) +} diff --git a/assets/js/lib/utils.ts b/assets/js/lib/utils.ts new file mode 100644 index 0000000..fed2fe9 --- /dev/null +++ b/assets/js/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/assets/js/types.ts b/assets/js/types.ts index cafd58a..db4406f 100644 --- a/assets/js/types.ts +++ b/assets/js/types.ts @@ -84,10 +84,11 @@ export type PageProps = T & { } } -export type OAuthProvider = { - name: string +export type OauthStrategy = 'google' | 'github' +export type OAuthProvider = { + name: T + icon: T display_name: string - icon: string auth_url: string } diff --git a/assets/js/ui/atoms/Alert/index.tsx b/assets/js/ui/atoms/Alert/index.tsx new file mode 100644 index 0000000..70f1c1c --- /dev/null +++ b/assets/js/ui/atoms/Alert/index.tsx @@ -0,0 +1,159 @@ +import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog' +import { ComponentProps } from 'react' + +import { cn } from '@/lib/utils' +import { Button } from '@/ui/atoms/Button' + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return +} + +function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = 'default', + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: 'default' | 'sm' +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ className, ...props }: ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogFooter({ className, ...props }: ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogMedia({ className, ...props }: ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ className, ...props }: ComponentProps) { + return