mirror of
				https://github.com/steveiliop56/tinyauth.git
				synced 2025-11-03 23:55:44 +00:00 
			
		
		
		
	Feat/new UI (#153)
* wip * feat: make forms functional * feat: finalize pages * chore: remove unused translations * feat: app context * feat: user context * feat: finalize username login * fix: use correct tab order in login form * feat: add oauth logic * chore: update readme and assets * chore: rename docs back to assets * feat: favicons * feat: custom background image config option * chore: add acknowledgements for background image * feat: sanitize redirect URL * feat: sanitize redirect URL on check * chore: fix dependabot config * refactor: bot suggestions * fix: correctly redirect to app and check for untrusted redirects * fix: run oauth auto redirect only when there is a redirect URI * refactor: change select color * fix: fix dockerfiles * fix: fix hook rendering * chore: remove translations cdn * chore: formatting * feat: validate api response against zod schema * fix: use axios error instead of generic error in login page
This commit is contained in:
		
							
								
								
									
										81
									
								
								frontend/src/components/auth/login-form.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								frontend/src/components/auth/login-form.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { Input } from "../ui/input";
 | 
			
		||||
import { useForm } from "react-hook-form";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
import {
 | 
			
		||||
  Form,
 | 
			
		||||
  FormControl,
 | 
			
		||||
  FormField,
 | 
			
		||||
  FormItem,
 | 
			
		||||
  FormLabel,
 | 
			
		||||
  FormMessage,
 | 
			
		||||
} from "../ui/form";
 | 
			
		||||
import { Button } from "../ui/button";
 | 
			
		||||
import { loginSchema, LoginSchema } from "@/schemas/login-schema";
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  onSubmit: (data: LoginSchema) => void;
 | 
			
		||||
  loading?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const LoginForm = (props: Props) => {
 | 
			
		||||
  const { onSubmit, loading } = props;
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
 | 
			
		||||
  const form = useForm<LoginSchema>({
 | 
			
		||||
    resolver: zodResolver(loginSchema),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Form {...form}>
 | 
			
		||||
      <form onSubmit={form.handleSubmit(onSubmit)}>
 | 
			
		||||
        <FormField
 | 
			
		||||
          control={form.control}
 | 
			
		||||
          name="username"
 | 
			
		||||
          render={({ field }) => (
 | 
			
		||||
            <FormItem className="mb-4">
 | 
			
		||||
              <FormLabel>{t("loginUsername")}</FormLabel>
 | 
			
		||||
              <FormControl>
 | 
			
		||||
                <Input
 | 
			
		||||
                  placeholder={t("loginUsername")}
 | 
			
		||||
                  disabled={loading}
 | 
			
		||||
                  {...field}
 | 
			
		||||
                />
 | 
			
		||||
              </FormControl>
 | 
			
		||||
              <FormMessage />
 | 
			
		||||
            </FormItem>
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
        <FormField
 | 
			
		||||
          control={form.control}
 | 
			
		||||
          name="password"
 | 
			
		||||
          render={({ field }) => (
 | 
			
		||||
            <FormItem className="mb-4">
 | 
			
		||||
              <div className="relative">
 | 
			
		||||
                <FormLabel className="mb-2">{t("loginPassword")}</FormLabel>
 | 
			
		||||
                <FormControl>
 | 
			
		||||
                  <Input
 | 
			
		||||
                    placeholder={t("loginPassword")}
 | 
			
		||||
                    type="password"
 | 
			
		||||
                    disabled={loading}
 | 
			
		||||
                    {...field}
 | 
			
		||||
                  />
 | 
			
		||||
                </FormControl>
 | 
			
		||||
                <FormMessage />
 | 
			
		||||
                <a
 | 
			
		||||
                  href="/forgot-password"
 | 
			
		||||
                  className="text-muted-foreground text-sm absolute right-0 bottom-10"
 | 
			
		||||
                >
 | 
			
		||||
                  {t("forgotPasswordTitle")}
 | 
			
		||||
                </a>
 | 
			
		||||
              </div>
 | 
			
		||||
            </FormItem>
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
        <Button className="w-full" type="submit" loading={loading}>
 | 
			
		||||
          {t("loginSubmit")}
 | 
			
		||||
        </Button>
 | 
			
		||||
      </form>
 | 
			
		||||
    </Form>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,57 +0,0 @@
 | 
			
		||||
import { TextInput, PasswordInput, Button, Anchor, Group, Text } from "@mantine/core";
 | 
			
		||||
import { useForm, zodResolver } from "@mantine/form";
 | 
			
		||||
import { LoginFormValues, loginSchema } from "../../schemas/login-schema";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
interface LoginFormProps {
 | 
			
		||||
  isPending: boolean;
 | 
			
		||||
  onSubmit: (values: LoginFormValues) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const LoginForm = (props: LoginFormProps) => {
 | 
			
		||||
  const { isPending, onSubmit } = props;
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
 | 
			
		||||
  const form = useForm({
 | 
			
		||||
    mode: "uncontrolled",
 | 
			
		||||
    initialValues: {
 | 
			
		||||
      username: "",
 | 
			
		||||
      password: "",
 | 
			
		||||
    },
 | 
			
		||||
    validate: zodResolver(loginSchema),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <form onSubmit={form.onSubmit(onSubmit)}>
 | 
			
		||||
      <TextInput
 | 
			
		||||
        label={t("loginUsername")}
 | 
			
		||||
        placeholder="Username"
 | 
			
		||||
        disabled={isPending}
 | 
			
		||||
        required
 | 
			
		||||
        withAsterisk={false}
 | 
			
		||||
        key={form.key("username")}
 | 
			
		||||
        {...form.getInputProps("username")}
 | 
			
		||||
      />
 | 
			
		||||
      <Group justify="space-between" mb={5} mt="md">
 | 
			
		||||
        <Text component="label" htmlFor=".password-input" size="sm" fw={500}>
 | 
			
		||||
        {t("loginPassword")}
 | 
			
		||||
        </Text>
 | 
			
		||||
 | 
			
		||||
        <Anchor href="#" onClick={() => window.location.replace("/forgot-password")} pt={2} fw={500} fz="xs">
 | 
			
		||||
          {t('forgotPasswordTitle')}
 | 
			
		||||
        </Anchor>
 | 
			
		||||
      </Group>
 | 
			
		||||
      <PasswordInput
 | 
			
		||||
        className="password-input"
 | 
			
		||||
        placeholder="Password"
 | 
			
		||||
        required
 | 
			
		||||
        disabled={isPending}
 | 
			
		||||
        key={form.key("password")}
 | 
			
		||||
        {...form.getInputProps("password")}
 | 
			
		||||
      />
 | 
			
		||||
      <Button fullWidth mt="xl" type="submit" loading={isPending}>
 | 
			
		||||
        {t("loginSubmit")}
 | 
			
		||||
      </Button>
 | 
			
		||||
    </form>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,58 +0,0 @@
 | 
			
		||||
import { Grid, Button } from "@mantine/core";
 | 
			
		||||
import { GithubIcon } from "../../icons/github";
 | 
			
		||||
import { GoogleIcon } from "../../icons/google";
 | 
			
		||||
import { OAuthIcon } from "../../icons/oauth";
 | 
			
		||||
 | 
			
		||||
interface OAuthButtonsProps {
 | 
			
		||||
  oauthProviders: string[];
 | 
			
		||||
  isPending: boolean;
 | 
			
		||||
  mutate: (provider: string) => void;
 | 
			
		||||
  genericName: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const OAuthButtons = (props: OAuthButtonsProps) => {
 | 
			
		||||
  const { oauthProviders, isPending, genericName, mutate } = props;
 | 
			
		||||
  return (
 | 
			
		||||
    <Grid mb="md" mt="md" align="center" justify="center">
 | 
			
		||||
      {oauthProviders.includes("google") && (
 | 
			
		||||
        <Grid.Col span="content">
 | 
			
		||||
          <Button
 | 
			
		||||
            radius="xl"
 | 
			
		||||
            leftSection={<GoogleIcon style={{ width: 14, height: 14 }} />}
 | 
			
		||||
            variant="default"
 | 
			
		||||
            onClick={() => mutate("google")}
 | 
			
		||||
            loading={isPending}
 | 
			
		||||
          >
 | 
			
		||||
            Google
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Grid.Col>
 | 
			
		||||
      )}
 | 
			
		||||
      {oauthProviders.includes("github") && (
 | 
			
		||||
        <Grid.Col span="content">
 | 
			
		||||
          <Button
 | 
			
		||||
            radius="xl"
 | 
			
		||||
            leftSection={<GithubIcon style={{ width: 14, height: 14 }} />}
 | 
			
		||||
            variant="default"
 | 
			
		||||
            onClick={() => mutate("github")}
 | 
			
		||||
            loading={isPending}
 | 
			
		||||
          >
 | 
			
		||||
            Github
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Grid.Col>
 | 
			
		||||
      )}
 | 
			
		||||
      {oauthProviders.includes("generic") && (
 | 
			
		||||
        <Grid.Col span="content">
 | 
			
		||||
          <Button
 | 
			
		||||
            radius="xl"
 | 
			
		||||
            leftSection={<OAuthIcon style={{ width: 14, height: 14 }} />}
 | 
			
		||||
            variant="default"
 | 
			
		||||
            onClick={() => mutate("generic")}
 | 
			
		||||
            loading={isPending}
 | 
			
		||||
          >
 | 
			
		||||
            {genericName}
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Grid.Col>
 | 
			
		||||
      )}
 | 
			
		||||
    </Grid>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,40 +1,54 @@
 | 
			
		||||
import { Button, PinInput } from "@mantine/core";
 | 
			
		||||
import { useForm, zodResolver } from "@mantine/form";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { Form, FormControl, FormField, FormItem } from "../ui/form";
 | 
			
		||||
import {
 | 
			
		||||
  InputOTP,
 | 
			
		||||
  InputOTPGroup,
 | 
			
		||||
  InputOTPSeparator,
 | 
			
		||||
  InputOTPSlot,
 | 
			
		||||
} from "../ui/input-otp";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
import { useForm } from "react-hook-form";
 | 
			
		||||
import { totpSchema, TotpSchema } from "@/schemas/totp-schema";
 | 
			
		||||
 | 
			
		||||
const schema = z.object({
 | 
			
		||||
  code: z.string(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
type FormValues = z.infer<typeof schema>;
 | 
			
		||||
 | 
			
		||||
interface TotpFormProps {
 | 
			
		||||
  onSubmit: (values: FormValues) => void;
 | 
			
		||||
  isPending: boolean;
 | 
			
		||||
interface Props {
 | 
			
		||||
  formId: string;
 | 
			
		||||
  onSubmit: (code: TotpSchema) => void;
 | 
			
		||||
  loading?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const TotpForm = (props: TotpFormProps) => {
 | 
			
		||||
  const { onSubmit, isPending } = props;
 | 
			
		||||
export const TotpForm = (props: Props) => {
 | 
			
		||||
  const { formId, onSubmit, loading } = props;
 | 
			
		||||
 | 
			
		||||
  const form = useForm({
 | 
			
		||||
    mode: "uncontrolled",
 | 
			
		||||
    initialValues: {
 | 
			
		||||
      code: "",
 | 
			
		||||
    },
 | 
			
		||||
    validate: zodResolver(schema),
 | 
			
		||||
  const form = useForm<TotpSchema>({
 | 
			
		||||
    resolver: zodResolver(totpSchema),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <form onSubmit={form.onSubmit(onSubmit)}>
 | 
			
		||||
      <PinInput
 | 
			
		||||
        length={6}
 | 
			
		||||
        type={"number"}
 | 
			
		||||
        placeholder=""
 | 
			
		||||
        {...form.getInputProps("code")}
 | 
			
		||||
      />
 | 
			
		||||
      <Button type="submit" mt="xl" loading={isPending} fullWidth>
 | 
			
		||||
        Verify
 | 
			
		||||
      </Button>
 | 
			
		||||
    </form>
 | 
			
		||||
    <Form {...form}>
 | 
			
		||||
      <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
 | 
			
		||||
        <FormField
 | 
			
		||||
          control={form.control}
 | 
			
		||||
          name="code"
 | 
			
		||||
          render={({ field }) => (
 | 
			
		||||
            <FormItem>
 | 
			
		||||
              <FormControl>
 | 
			
		||||
                <InputOTP maxLength={6} disabled={loading} {...field}>
 | 
			
		||||
                  <InputOTPGroup>
 | 
			
		||||
                    <InputOTPSlot index={0} />
 | 
			
		||||
                    <InputOTPSlot index={1} />
 | 
			
		||||
                    <InputOTPSlot index={2} />
 | 
			
		||||
                  </InputOTPGroup>
 | 
			
		||||
                  <InputOTPSeparator />
 | 
			
		||||
                  <InputOTPGroup>
 | 
			
		||||
                    <InputOTPSlot index={3} />
 | 
			
		||||
                    <InputOTPSlot index={4} />
 | 
			
		||||
                    <InputOTPSlot index={5} />
 | 
			
		||||
                  </InputOTPGroup>
 | 
			
		||||
                </InputOTP>
 | 
			
		||||
              </FormControl>
 | 
			
		||||
            </FormItem>
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
      </form>
 | 
			
		||||
    </Form>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										24
									
								
								frontend/src/components/icons/generic.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								frontend/src/components/icons/generic.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
import type { SVGProps } from "react";
 | 
			
		||||
 | 
			
		||||
export function GenericIcon(props: SVGProps<SVGSVGElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <svg
 | 
			
		||||
      xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
      width={24}
 | 
			
		||||
      height={24}
 | 
			
		||||
      viewBox="0 0 24 24"
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <g
 | 
			
		||||
        fill="none"
 | 
			
		||||
        stroke="currentColor"
 | 
			
		||||
        strokeLinecap="round"
 | 
			
		||||
        strokeLinejoin="round"
 | 
			
		||||
        strokeWidth={2}
 | 
			
		||||
      >
 | 
			
		||||
        <path d="M2 12a10 10 0 1 0 20 0a10 10 0 1 0-20 0"></path>
 | 
			
		||||
        <path d="M12.556 6c.65 0 1.235.373 1.508.947l2.839 7.848a1.646 1.646 0 0 1-1.01 2.108a1.673 1.673 0 0 1-2.068-.851L13.365 15h-2.73l-.398.905A1.67 1.67 0 0 1 8.26 16.95l-.153-.047a1.647 1.647 0 0 1-1.056-1.956l2.824-7.852a1.66 1.66 0 0 1 1.409-1.087z"></path>
 | 
			
		||||
      </g>
 | 
			
		||||
    </svg>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								frontend/src/components/icons/github.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								frontend/src/components/icons/github.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import type { SVGProps } from "react";
 | 
			
		||||
 | 
			
		||||
export function GithubIcon(props: SVGProps<SVGSVGElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <svg
 | 
			
		||||
      xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
      width={24}
 | 
			
		||||
      height={24}
 | 
			
		||||
      viewBox="0 0 24 24"
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <path
 | 
			
		||||
        fill="currentColor"
 | 
			
		||||
        d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
 | 
			
		||||
      ></path>
 | 
			
		||||
    </svg>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										30
									
								
								frontend/src/components/icons/google.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/src/components/icons/google.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
import type { SVGProps } from "react";
 | 
			
		||||
 | 
			
		||||
export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <svg
 | 
			
		||||
      xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
      width={256}
 | 
			
		||||
      height={262}
 | 
			
		||||
      viewBox="0 0 256 262"
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <path
 | 
			
		||||
        fill="#4285f4"
 | 
			
		||||
        d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
 | 
			
		||||
      ></path>
 | 
			
		||||
      <path
 | 
			
		||||
        fill="#34a853"
 | 
			
		||||
        d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
 | 
			
		||||
      ></path>
 | 
			
		||||
      <path
 | 
			
		||||
        fill="#fbbc05"
 | 
			
		||||
        d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"
 | 
			
		||||
      ></path>
 | 
			
		||||
      <path
 | 
			
		||||
        fill="#eb4335"
 | 
			
		||||
        d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
 | 
			
		||||
      ></path>
 | 
			
		||||
    </svg>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,40 +0,0 @@
 | 
			
		||||
import { ComboboxItem, Select } from "@mantine/core";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import i18n from "../../lib/i18n/i18n";
 | 
			
		||||
import {
 | 
			
		||||
  SupportedLanguage,
 | 
			
		||||
  getLanguageName,
 | 
			
		||||
  languages,
 | 
			
		||||
} from "../../lib/i18n/locales";
 | 
			
		||||
 | 
			
		||||
export const LanguageSelector = () => {
 | 
			
		||||
  const [language, setLanguage] = useState<ComboboxItem>({
 | 
			
		||||
    value: i18n.language,
 | 
			
		||||
    label: getLanguageName(i18n.language as SupportedLanguage),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const languageOptions = Object.entries(languages).map(([code, name]) => ({
 | 
			
		||||
    value: code,
 | 
			
		||||
    label: name,
 | 
			
		||||
  }));
 | 
			
		||||
 | 
			
		||||
  const handleLanguageChange = (option: string) => {
 | 
			
		||||
    i18n.changeLanguage(option as SupportedLanguage);
 | 
			
		||||
    setLanguage({
 | 
			
		||||
      value: option,
 | 
			
		||||
      label: getLanguageName(option as SupportedLanguage),
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Select
 | 
			
		||||
      data={languageOptions}
 | 
			
		||||
      value={language ? language.value : null}
 | 
			
		||||
      onChange={(_value, option) => handleLanguageChange(option.value)}
 | 
			
		||||
      allowDeselect={false}
 | 
			
		||||
      pos="absolute"
 | 
			
		||||
      right={10}
 | 
			
		||||
      top={10}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										35
									
								
								frontend/src/components/language/language.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								frontend/src/components/language/language.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
			
		||||
import { languages, SupportedLanguage } from "@/lib/i18n/locales";
 | 
			
		||||
import {
 | 
			
		||||
  Select,
 | 
			
		||||
  SelectContent,
 | 
			
		||||
  SelectItem,
 | 
			
		||||
  SelectTrigger,
 | 
			
		||||
  SelectValue,
 | 
			
		||||
} from "../ui/select";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import i18n from "@/lib/i18n/i18n";
 | 
			
		||||
 | 
			
		||||
export const LanguageSelector = () => {
 | 
			
		||||
  const [language, setLanguage] = useState<SupportedLanguage>(
 | 
			
		||||
    i18n.language as SupportedLanguage,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleSelect = (option: string) => {
 | 
			
		||||
    setLanguage(option as SupportedLanguage);
 | 
			
		||||
    i18n.changeLanguage(option as SupportedLanguage);
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <Select onValueChange={handleSelect} value={language}>
 | 
			
		||||
      <SelectTrigger className="absolute top-5 right-5">
 | 
			
		||||
        <SelectValue placeholder="Select language" />
 | 
			
		||||
      </SelectTrigger>
 | 
			
		||||
      <SelectContent>
 | 
			
		||||
        {Object.entries(languages).map(([key, value]) => (
 | 
			
		||||
          <SelectItem key={key} value={key}>
 | 
			
		||||
            {value}
 | 
			
		||||
          </SelectItem>
 | 
			
		||||
        ))}
 | 
			
		||||
      </SelectContent>
 | 
			
		||||
    </Select>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										20
									
								
								frontend/src/components/layout/layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								frontend/src/components/layout/layout.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
import { useAppContext } from "@/context/app-context";
 | 
			
		||||
import { LanguageSelector } from "../language/language";
 | 
			
		||||
 | 
			
		||||
export const Layout = ({ children }: { children: React.ReactNode }) => {
 | 
			
		||||
  const { backgroundImage } = useAppContext();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className="relative flex flex-col justify-center items-center min-h-svh"
 | 
			
		||||
      style={{
 | 
			
		||||
        backgroundImage: `url(${backgroundImage})`,
 | 
			
		||||
        backgroundSize: "cover",
 | 
			
		||||
        backgroundPosition: "center",
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <LanguageSelector />
 | 
			
		||||
      {children}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,16 +0,0 @@
 | 
			
		||||
import { Center, Flex } from "@mantine/core";
 | 
			
		||||
import { ReactNode } from "react";
 | 
			
		||||
import { LanguageSelector } from "../language-selector/language-selector";
 | 
			
		||||
 | 
			
		||||
export const Layout = ({ children }: { children: ReactNode }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <LanguageSelector />
 | 
			
		||||
      <Center style={{ minHeight: "100vh" }}>
 | 
			
		||||
        <Flex direction="column" flex="1" maw={340}>
 | 
			
		||||
          {children}
 | 
			
		||||
        </Flex>
 | 
			
		||||
      </Center>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										77
									
								
								frontend/src/components/ui/button.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								frontend/src/components/ui/button.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,77 @@
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
import { Slot } from "@radix-ui/react-slot";
 | 
			
		||||
import { cva, type VariantProps } from "class-variance-authority";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { Loader2 } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
const buttonVariants = cva(
 | 
			
		||||
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default:
 | 
			
		||||
          "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
 | 
			
		||||
        destructive:
 | 
			
		||||
          "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
 | 
			
		||||
        outline:
 | 
			
		||||
          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
 | 
			
		||||
        secondary:
 | 
			
		||||
          "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
 | 
			
		||||
        ghost:
 | 
			
		||||
          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
 | 
			
		||||
        link: "text-primary underline-offset-4 hover:underline",
 | 
			
		||||
        warning:
 | 
			
		||||
          "bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:ring-amber-200/20 dark:focus-visible:ring-amber-400/40 dark:bg-amber-600",
 | 
			
		||||
      },
 | 
			
		||||
      size: {
 | 
			
		||||
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
 | 
			
		||||
        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
 | 
			
		||||
        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
 | 
			
		||||
        icon: "size-9",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    defaultVariants: {
 | 
			
		||||
      variant: "default",
 | 
			
		||||
      size: "default",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function Button({
 | 
			
		||||
  className,
 | 
			
		||||
  variant,
 | 
			
		||||
  size,
 | 
			
		||||
  asChild = false,
 | 
			
		||||
  loading = false,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<"button"> &
 | 
			
		||||
  VariantProps<typeof buttonVariants> & {
 | 
			
		||||
    asChild?: boolean;
 | 
			
		||||
    loading?: boolean;
 | 
			
		||||
  }) {
 | 
			
		||||
  const Comp = asChild ? Slot : "button";
 | 
			
		||||
 | 
			
		||||
  if (loading) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Comp
 | 
			
		||||
        data-slot="button"
 | 
			
		||||
        className={cn(buttonVariants({ variant, size, className }))}
 | 
			
		||||
        disabled
 | 
			
		||||
        {...props}
 | 
			
		||||
      >
 | 
			
		||||
        <Loader2 className="animate-spin" />
 | 
			
		||||
      </Comp>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Comp
 | 
			
		||||
      data-slot="button"
 | 
			
		||||
      className={cn(buttonVariants({ variant, size, className }))}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Button, buttonVariants };
 | 
			
		||||
							
								
								
									
										92
									
								
								frontend/src/components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								frontend/src/components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,92 @@
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-header"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-title"
 | 
			
		||||
      className={cn("leading-none font-semibold", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-description"
 | 
			
		||||
      className={cn("text-muted-foreground text-sm", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-action"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-content"
 | 
			
		||||
      className={cn("px-6", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-footer"
 | 
			
		||||
      className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  Card,
 | 
			
		||||
  CardHeader,
 | 
			
		||||
  CardFooter,
 | 
			
		||||
  CardTitle,
 | 
			
		||||
  CardAction,
 | 
			
		||||
  CardDescription,
 | 
			
		||||
  CardContent,
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										166
									
								
								frontend/src/components/ui/form.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								frontend/src/components/ui/form.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,166 @@
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
import * as LabelPrimitive from "@radix-ui/react-label";
 | 
			
		||||
import { Slot } from "@radix-ui/react-slot";
 | 
			
		||||
import {
 | 
			
		||||
  Controller,
 | 
			
		||||
  FormProvider,
 | 
			
		||||
  useFormContext,
 | 
			
		||||
  useFormState,
 | 
			
		||||
  type ControllerProps,
 | 
			
		||||
  type FieldPath,
 | 
			
		||||
  type FieldValues,
 | 
			
		||||
} from "react-hook-form";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { Label } from "@/components/ui/label";
 | 
			
		||||
 | 
			
		||||
const Form = FormProvider;
 | 
			
		||||
 | 
			
		||||
type FormFieldContextValue<
 | 
			
		||||
  TFieldValues extends FieldValues = FieldValues,
 | 
			
		||||
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
 | 
			
		||||
> = {
 | 
			
		||||
  name: TName;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
 | 
			
		||||
  {} as FormFieldContextValue,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const FormField = <
 | 
			
		||||
  TFieldValues extends FieldValues = FieldValues,
 | 
			
		||||
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
 | 
			
		||||
>({
 | 
			
		||||
  ...props
 | 
			
		||||
}: ControllerProps<TFieldValues, TName>) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <FormFieldContext.Provider value={{ name: props.name }}>
 | 
			
		||||
      <Controller {...props} />
 | 
			
		||||
    </FormFieldContext.Provider>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const useFormField = () => {
 | 
			
		||||
  const fieldContext = React.useContext(FormFieldContext);
 | 
			
		||||
  const itemContext = React.useContext(FormItemContext);
 | 
			
		||||
  const { getFieldState } = useFormContext();
 | 
			
		||||
  const formState = useFormState({ name: fieldContext.name });
 | 
			
		||||
  const fieldState = getFieldState(fieldContext.name, formState);
 | 
			
		||||
 | 
			
		||||
  if (!fieldContext) {
 | 
			
		||||
    throw new Error("useFormField should be used within <FormField>");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { id } = itemContext;
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    id,
 | 
			
		||||
    name: fieldContext.name,
 | 
			
		||||
    formItemId: `${id}-form-item`,
 | 
			
		||||
    formDescriptionId: `${id}-form-item-description`,
 | 
			
		||||
    formMessageId: `${id}-form-item-message`,
 | 
			
		||||
    ...fieldState,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type FormItemContextValue = {
 | 
			
		||||
  id: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const FormItemContext = React.createContext<FormItemContextValue>(
 | 
			
		||||
  {} as FormItemContextValue,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  const id = React.useId();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <FormItemContext.Provider value={{ id }}>
 | 
			
		||||
      <div
 | 
			
		||||
        data-slot="form-item"
 | 
			
		||||
        className={cn("grid gap-2", className)}
 | 
			
		||||
        {...props}
 | 
			
		||||
      />
 | 
			
		||||
    </FormItemContext.Provider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function FormLabel({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
 | 
			
		||||
  const { error, formItemId } = useFormField();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Label
 | 
			
		||||
      data-slot="form-label"
 | 
			
		||||
      data-error={!!error}
 | 
			
		||||
      className={cn("data-[error=true]:text-destructive", className)}
 | 
			
		||||
      htmlFor={formItemId}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
 | 
			
		||||
  const { error, formItemId, formDescriptionId, formMessageId } =
 | 
			
		||||
    useFormField();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Slot
 | 
			
		||||
      data-slot="form-control"
 | 
			
		||||
      id={formItemId}
 | 
			
		||||
      aria-describedby={
 | 
			
		||||
        !error
 | 
			
		||||
          ? `${formDescriptionId}`
 | 
			
		||||
          : `${formDescriptionId} ${formMessageId}`
 | 
			
		||||
      }
 | 
			
		||||
      aria-invalid={!!error}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
 | 
			
		||||
  const { formDescriptionId } = useFormField();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <p
 | 
			
		||||
      data-slot="form-description"
 | 
			
		||||
      id={formDescriptionId}
 | 
			
		||||
      className={cn("text-muted-foreground text-sm", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
 | 
			
		||||
  const { error, formMessageId } = useFormField();
 | 
			
		||||
  const body = error ? String(error?.message ?? "") : props.children;
 | 
			
		||||
 | 
			
		||||
  if (!body) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <p
 | 
			
		||||
      data-slot="form-message"
 | 
			
		||||
      id={formMessageId}
 | 
			
		||||
      className={cn("text-destructive text-sm", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      {body}
 | 
			
		||||
    </p>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  useFormField,
 | 
			
		||||
  Form,
 | 
			
		||||
  FormItem,
 | 
			
		||||
  FormLabel,
 | 
			
		||||
  FormControl,
 | 
			
		||||
  FormDescription,
 | 
			
		||||
  FormMessage,
 | 
			
		||||
  FormField,
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										75
									
								
								frontend/src/components/ui/input-otp.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								frontend/src/components/ui/input-otp.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,75 @@
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
import { OTPInput, OTPInputContext } from "input-otp";
 | 
			
		||||
import { MinusIcon } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
function InputOTP({
 | 
			
		||||
  className,
 | 
			
		||||
  containerClassName,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof OTPInput> & {
 | 
			
		||||
  containerClassName?: string;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <OTPInput
 | 
			
		||||
      data-slot="input-otp"
 | 
			
		||||
      containerClassName={cn(
 | 
			
		||||
        "flex items-center gap-2 has-disabled:opacity-50",
 | 
			
		||||
        containerClassName,
 | 
			
		||||
      )}
 | 
			
		||||
      className={cn("disabled:cursor-not-allowed", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="input-otp-group"
 | 
			
		||||
      className={cn("flex items-center", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function InputOTPSlot({
 | 
			
		||||
  index,
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<"div"> & {
 | 
			
		||||
  index: number;
 | 
			
		||||
}) {
 | 
			
		||||
  const inputOTPContext = React.useContext(OTPInputContext);
 | 
			
		||||
  const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="input-otp-slot"
 | 
			
		||||
      data-active={isActive}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      {char}
 | 
			
		||||
      {hasFakeCaret && (
 | 
			
		||||
        <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
 | 
			
		||||
          <div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div data-slot="input-otp-separator" role="separator" {...props}>
 | 
			
		||||
      <MinusIcon />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
 | 
			
		||||
							
								
								
									
										21
									
								
								frontend/src/components/ui/input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								frontend/src/components/ui/input.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <input
 | 
			
		||||
      type={type}
 | 
			
		||||
      data-slot="input"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
 | 
			
		||||
        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
 | 
			
		||||
        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Input };
 | 
			
		||||
							
								
								
									
										22
									
								
								frontend/src/components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								frontend/src/components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
import * as LabelPrimitive from "@radix-ui/react-label";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
function Label({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <LabelPrimitive.Root
 | 
			
		||||
      data-slot="label"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Label };
 | 
			
		||||
							
								
								
									
										33
									
								
								frontend/src/components/ui/oauth-button.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								frontend/src/components/ui/oauth-button.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
import { Loader2 } from "lucide-react";
 | 
			
		||||
import { Button } from "./button";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { twMerge } from "tailwind-merge";
 | 
			
		||||
 | 
			
		||||
interface Props extends React.ComponentProps<typeof Button> {
 | 
			
		||||
  title: string;
 | 
			
		||||
  icon: React.ReactNode;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
  loading?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const OAuthButton = (props: Props) => {
 | 
			
		||||
  const { title, icon, onClick, loading, className, ...rest } = props;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Button
 | 
			
		||||
      onClick={onClick}
 | 
			
		||||
      className={twMerge("rounded-md", className)}
 | 
			
		||||
      variant="outline"
 | 
			
		||||
      {...rest}
 | 
			
		||||
    >
 | 
			
		||||
      {loading ? (
 | 
			
		||||
        <Loader2 className="animate-spin" />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <>
 | 
			
		||||
          {icon}
 | 
			
		||||
          {title}
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
    </Button>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										183
									
								
								frontend/src/components/ui/select.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								frontend/src/components/ui/select.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,183 @@
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
import * as SelectPrimitive from "@radix-ui/react-select";
 | 
			
		||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
function Select({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
 | 
			
		||||
  return <SelectPrimitive.Root data-slot="select" {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectGroup({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
 | 
			
		||||
  return <SelectPrimitive.Group data-slot="select-group" {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectValue({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
 | 
			
		||||
  return <SelectPrimitive.Value data-slot="select-value" {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectTrigger({
 | 
			
		||||
  className,
 | 
			
		||||
  size = "default",
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
 | 
			
		||||
  size?: "sm" | "default";
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Trigger
 | 
			
		||||
      data-slot="select-trigger"
 | 
			
		||||
      data-size={size}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-card dark:hover:bg-card/90 flex w-fit items-center justify-between gap-2 rounded-md border bg-card hover:bg-card/90 px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
      <SelectPrimitive.Icon asChild>
 | 
			
		||||
        <ChevronDownIcon className="size-4" />
 | 
			
		||||
      </SelectPrimitive.Icon>
 | 
			
		||||
    </SelectPrimitive.Trigger>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectContent({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  position = "popper",
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Portal>
 | 
			
		||||
      <SelectPrimitive.Content
 | 
			
		||||
        data-slot="select-content"
 | 
			
		||||
        className={cn(
 | 
			
		||||
          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
 | 
			
		||||
          position === "popper" &&
 | 
			
		||||
            "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
 | 
			
		||||
          className,
 | 
			
		||||
        )}
 | 
			
		||||
        position={position}
 | 
			
		||||
        {...props}
 | 
			
		||||
      >
 | 
			
		||||
        <SelectScrollUpButton />
 | 
			
		||||
        <SelectPrimitive.Viewport
 | 
			
		||||
          className={cn(
 | 
			
		||||
            "p-1",
 | 
			
		||||
            position === "popper" &&
 | 
			
		||||
              "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          {children}
 | 
			
		||||
        </SelectPrimitive.Viewport>
 | 
			
		||||
        <SelectScrollDownButton />
 | 
			
		||||
      </SelectPrimitive.Content>
 | 
			
		||||
    </SelectPrimitive.Portal>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectLabel({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Label
 | 
			
		||||
      data-slot="select-label"
 | 
			
		||||
      className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectItem({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Item
 | 
			
		||||
      data-slot="select-item"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <span className="absolute right-2 flex size-3.5 items-center justify-center">
 | 
			
		||||
        <SelectPrimitive.ItemIndicator>
 | 
			
		||||
          <CheckIcon className="size-4" />
 | 
			
		||||
        </SelectPrimitive.ItemIndicator>
 | 
			
		||||
      </span>
 | 
			
		||||
      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
 | 
			
		||||
    </SelectPrimitive.Item>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectSeparator({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Separator
 | 
			
		||||
      data-slot="select-separator"
 | 
			
		||||
      className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectScrollUpButton({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.ScrollUpButton
 | 
			
		||||
      data-slot="select-scroll-up-button"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex cursor-default items-center justify-center py-1",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <ChevronUpIcon className="size-4" />
 | 
			
		||||
    </SelectPrimitive.ScrollUpButton>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectScrollDownButton({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.ScrollDownButton
 | 
			
		||||
      data-slot="select-scroll-down-button"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex cursor-default items-center justify-center py-1",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <ChevronDownIcon className="size-4" />
 | 
			
		||||
    </SelectPrimitive.ScrollDownButton>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  Select,
 | 
			
		||||
  SelectContent,
 | 
			
		||||
  SelectGroup,
 | 
			
		||||
  SelectItem,
 | 
			
		||||
  SelectLabel,
 | 
			
		||||
  SelectScrollDownButton,
 | 
			
		||||
  SelectScrollUpButton,
 | 
			
		||||
  SelectSeparator,
 | 
			
		||||
  SelectTrigger,
 | 
			
		||||
  SelectValue,
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										38
									
								
								frontend/src/components/ui/separator.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								frontend/src/components/ui/separator.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
function Separator({
 | 
			
		||||
  className,
 | 
			
		||||
  orientation = "horizontal",
 | 
			
		||||
  decorative = true,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SeparatorPrimitive.Root
 | 
			
		||||
      data-slot="separator-root"
 | 
			
		||||
      decorative={decorative}
 | 
			
		||||
      orientation={orientation}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SeperatorWithChildren({ children }: { children: React.ReactNode }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex items-center gap-4">
 | 
			
		||||
      <Separator className="flex-1" />
 | 
			
		||||
      <span className="text-sm text-muted-foreground">{children}</span>
 | 
			
		||||
      <Separator className="flex-1" />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Separator, SeperatorWithChildren };
 | 
			
		||||
							
								
								
									
										23
									
								
								frontend/src/components/ui/sonner.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/src/components/ui/sonner.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
import { useTheme } from "next-themes";
 | 
			
		||||
import { Toaster as Sonner, ToasterProps } from "sonner";
 | 
			
		||||
 | 
			
		||||
const Toaster = ({ ...props }: ToasterProps) => {
 | 
			
		||||
  const { theme = "system" } = useTheme();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Sonner
 | 
			
		||||
      theme={theme as ToasterProps["theme"]}
 | 
			
		||||
      className="toaster group"
 | 
			
		||||
      style={
 | 
			
		||||
        {
 | 
			
		||||
          "--normal-bg": "var(--popover)",
 | 
			
		||||
          "--normal-text": "var(--popover-foreground)",
 | 
			
		||||
          "--normal-border": "var(--border)",
 | 
			
		||||
        } as React.CSSProperties
 | 
			
		||||
      }
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export { Toaster };
 | 
			
		||||
		Reference in New Issue
	
	Block a user