first proto of nice about me page :3
This commit is contained in:
parent
fd79df9ec1
commit
967bb2a2d2
|
@ -67,24 +67,41 @@ body {
|
||||||
sans-serif;
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-stretch: expanded;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
input,
|
input,
|
||||||
|
button,
|
||||||
label {
|
label {
|
||||||
transition: background-color var(--theme-transition),
|
transition: background-color var(--theme-transition),
|
||||||
color var(--theme-transition);
|
color var(--theme-transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:disabled,
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
input,
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
background-color: var(--bg-dark);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled,
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder {
|
input::placeholder {
|
||||||
|
|
|
@ -1,28 +1,36 @@
|
||||||
.content {
|
.content {
|
||||||
max-width: 700px;
|
width: min-content;
|
||||||
margin: 0 auto;
|
margin: 2rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profileGrid {
|
.profileGrid {
|
||||||
/* todo */
|
display: grid;
|
||||||
|
grid-template-columns: 300px 1fr;
|
||||||
|
column-gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.profileTower *:first-child {
|
||||||
width: 400px;
|
margin-top: 0 !important;
|
||||||
|
|
||||||
background-color: var(--fg-darker);
|
|
||||||
height: 1px;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
margin: 1rem auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout {
|
.connections {
|
||||||
background: var(--bg-dark);
|
margin-top: 1rem;
|
||||||
border: 0;
|
}
|
||||||
border-radius: 0.15rem;
|
|
||||||
cursor: pointer;
|
.connections > *:nth-child(2) {
|
||||||
padding: 0.5em 1em;
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
column-gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
font-size: 3rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* stolen from prettyform */
|
/* stolen from prettyform */
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { UserInfo } from "@/ldap";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import styles from "./AboutMe.module.css";
|
import styles from "./AboutMe.module.css";
|
||||||
import AvatarChanger from "@/components/AvatarChanger";
|
import AvatarChanger from "@/components/AvatarChanger";
|
||||||
import Input from "@/components/Input";
|
import Input, { Label } from "@/components/Input";
|
||||||
import { Form, Formik, FormikHelpers } from "formik";
|
import { Form, Formik, FormikHelpers } from "formik";
|
||||||
import {
|
import {
|
||||||
AboutMeFormValues,
|
AboutMeFormValues,
|
||||||
|
@ -16,84 +16,22 @@ import {
|
||||||
import PrettyForm from "@/components/PrettyForm";
|
import PrettyForm from "@/components/PrettyForm";
|
||||||
import Toast from "@/components/Toast";
|
import Toast from "@/components/Toast";
|
||||||
import { AuthProviderState } from "@/auth/AuthProvider";
|
import { AuthProviderState } from "@/auth/AuthProvider";
|
||||||
import { exec } from "child_process";
|
import inputStyles from "@/components/Input.module.css";
|
||||||
|
import Connection from "@/components/Connection";
|
||||||
|
|
||||||
type UpdateResponse = {
|
type UpdateResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fileAsBase64(f: File) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsArrayBuffer(f);
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
reader.onload = () => {
|
|
||||||
const result = reader.result as ArrayBuffer;
|
|
||||||
const buffer = Buffer.from(result);
|
|
||||||
resolve(buffer.toString("base64"));
|
|
||||||
};
|
|
||||||
reader.onerror = () => reject(reader.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthProviderButton({ provider }: { provider: AuthProviderState }) {
|
|
||||||
// bullshit hack
|
|
||||||
const holdTime = provider.connected ? 3000 : 0;
|
|
||||||
const interval = React.useRef<NodeJS.Timeout | null>();
|
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const execute = async () => {
|
|
||||||
const name = provider.name.toLowerCase();
|
|
||||||
if (!provider.connected) {
|
|
||||||
window.location.href = `/oauth/${name}/login`;
|
|
||||||
} else {
|
|
||||||
await fetch(`/api/unlink?provider=${name}`, { method: "POST" });
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mouseDown = () => {
|
|
||||||
interval.current = setTimeout(execute, holdTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mouseUp = () => {
|
|
||||||
if (interval.current) clearTimeout(interval.current);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="submit"
|
|
||||||
className={
|
|
||||||
styles.fancyInput + " " + (provider.connected ? styles.progress : "")
|
|
||||||
}
|
|
||||||
onMouseDown={mouseDown}
|
|
||||||
onMouseUp={mouseUp}
|
|
||||||
value={provider.connected ? "Disconnect" : "Connect"}
|
|
||||||
ref={inputRef}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthProviderEntry({ provider }: { provider: AuthProviderState }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
{provider.name}:{" "}
|
|
||||||
{provider.connected ? provider.username : "Not connected"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<AuthProviderButton provider={provider} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AboutMe({
|
export default function AboutMe({
|
||||||
info,
|
info,
|
||||||
providers
|
providers: [discordState, githubState]
|
||||||
}: {
|
}: {
|
||||||
info: UserInfo;
|
info: UserInfo;
|
||||||
providers: AuthProviderState[];
|
providers: AuthProviderState[];
|
||||||
}) {
|
}) {
|
||||||
|
// TODO: Reimplement password changing.
|
||||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||||
const [madeProfileChanges, setMadeChanges] = React.useState(false);
|
const [madeProfileChanges, setMadeChanges] = React.useState(false);
|
||||||
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
|
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
|
||||||
|
@ -182,7 +120,6 @@ export default function AboutMe({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<h2 className={styles.userName}>{info.username}</h2>
|
|
||||||
<PrettyForm globalError={globalError}>
|
<PrettyForm globalError={globalError}>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
|
@ -191,14 +128,36 @@ export default function AboutMe({
|
||||||
>
|
>
|
||||||
{({ isSubmitting }) => (
|
{({ isSubmitting }) => (
|
||||||
<Form className={styles.profileGrid}>
|
<Form className={styles.profileGrid}>
|
||||||
{madeProfileChanges ? <Toast>Saved your changes.</Toast> : null}
|
<div className={styles.profileTower}>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
name="avatar"
|
||||||
|
customRender={(fieldProps) => (
|
||||||
|
<AvatarChanger
|
||||||
|
currentAvatarBlob={fieldProps.field.value}
|
||||||
|
onChange={(newBlob) =>
|
||||||
|
fieldProps.form.setFieldValue("avatar", newBlob)
|
||||||
|
}
|
||||||
|
vertical
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className={styles.userName}>{info.username}</h2>
|
||||||
|
<div className={styles.rightGrid}>
|
||||||
|
<div>
|
||||||
|
{madeProfileChanges ? (
|
||||||
|
<Toast>Saved your changes.</Toast>
|
||||||
|
) : null}
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
name="username"
|
name="username"
|
||||||
label="Username"
|
label="Username"
|
||||||
defaultValue={info.username}
|
defaultValue={info.username}
|
||||||
disabled
|
disabled
|
||||||
title="You can't change your username."
|
hint="This can’t be changed."
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -206,6 +165,7 @@ export default function AboutMe({
|
||||||
label="Display name"
|
label="Display name"
|
||||||
defaultValue={info.displayName}
|
defaultValue={info.displayName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
|
@ -213,20 +173,24 @@ export default function AboutMe({
|
||||||
defaultValue={info.email}
|
defaultValue={info.email}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<div className={inputStyles.formRow}>
|
||||||
type="file"
|
<button type="button">Change Password</button>
|
||||||
name="avatar"
|
</div>
|
||||||
label="Avatar"
|
|
||||||
accept="image/png, image/jpeg"
|
<div className={inputStyles.formRow}>
|
||||||
customRender={(fieldProps) => (
|
<button
|
||||||
<AvatarChanger
|
type="button"
|
||||||
currentAvatarBlob={fieldProps.field.value}
|
onClick={async () => {
|
||||||
onChange={(newBlob) =>
|
document.cookie =
|
||||||
fieldProps.form.setFieldValue("avatar", newBlob)
|
"ticket=; expires=" +
|
||||||
}
|
new Date().toUTCString() +
|
||||||
/>
|
"; path=/";
|
||||||
)}
|
window.location.href = "/";
|
||||||
/>
|
}}
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="submit"
|
type="submit"
|
||||||
|
@ -234,13 +198,22 @@ export default function AboutMe({
|
||||||
className={styles.fancyInput}
|
className={styles.fancyInput}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.connections}>
|
||||||
|
<Label>Connections</Label>
|
||||||
|
<Connection service="Discord" authState={discordState} />
|
||||||
|
<Connection service="GitHub" authState={githubState} />
|
||||||
|
<Connection service="Tailscale" unavailable />
|
||||||
|
<Connection service="Migadu" unavailable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
</PrettyForm>
|
</PrettyForm>
|
||||||
<hr className={styles.divider} />
|
{/*<PrettyForm globalError={passwordError}>
|
||||||
<h2 className={styles.header}>Change password</h2>
|
|
||||||
<PrettyForm globalError={passwordError}>
|
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialPasswordValues}
|
initialValues={initialPasswordValues}
|
||||||
onSubmit={handlePasswordSubmit}
|
onSubmit={handlePasswordSubmit}
|
||||||
|
@ -286,7 +259,6 @@ export default function AboutMe({
|
||||||
</Formik>
|
</Formik>
|
||||||
</PrettyForm>
|
</PrettyForm>
|
||||||
|
|
||||||
<hr className={styles.divider} />
|
|
||||||
<h2 className={styles.header}>Connections</h2>
|
<h2 className={styles.header}>Connections</h2>
|
||||||
<div className={styles.authProviderList}>
|
<div className={styles.authProviderList}>
|
||||||
{providers.map((provider) => (
|
{providers.map((provider) => (
|
||||||
|
@ -294,7 +266,6 @@ export default function AboutMe({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className={styles.divider} />
|
|
||||||
<input
|
<input
|
||||||
type="button"
|
type="button"
|
||||||
value="Log out"
|
value="Log out"
|
||||||
|
@ -304,7 +275,7 @@ export default function AboutMe({
|
||||||
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
|
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}}
|
}}
|
||||||
/>
|
/>*/}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,32 +2,44 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row nowrap;
|
flex-flow: row nowrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatarChanger :is(img, svg) {
|
.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarChanger .current-avatar,
|
||||||
|
.avatarChanger svg {
|
||||||
width: 3em;
|
width: 3em;
|
||||||
height: 3em;
|
height: 3em;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vertical.vertical .current-avatar {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: inherit;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
}
|
||||||
|
|
||||||
.avatarChanger button svg {
|
.avatarChanger button svg {
|
||||||
width: 1.2em;
|
width: 1.2em;
|
||||||
height: 1.2em;
|
height: 1.2em;
|
||||||
margin-right: 0.5em;
|
margin-right: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatarChanger input[type=file] {
|
.avatarChanger input[type="file"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uploadButton {
|
.uploadButton {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0.15rem;
|
border-radius: 0.15rem;
|
||||||
padding: 0.25em 1em;
|
padding: 0.5em 1em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
|
@ -8,10 +8,12 @@ import UserIcon from "./icons/UserIcon";
|
||||||
|
|
||||||
export default function AvatarChanger({
|
export default function AvatarChanger({
|
||||||
currentAvatarBlob,
|
currentAvatarBlob,
|
||||||
onChange
|
onChange,
|
||||||
|
vertical = false
|
||||||
}: {
|
}: {
|
||||||
currentAvatarBlob: string | null;
|
currentAvatarBlob: string | null;
|
||||||
onChange: (newAvatar: string) => void;
|
onChange: (newAvatar: string) => void;
|
||||||
|
vertical?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const input = React.useRef<HTMLInputElement>(null);
|
const input = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
@ -25,9 +27,19 @@ export default function AvatarChanger({
|
||||||
|
|
||||||
// I give you the most support and well wishes while you work on this project -Ari
|
// I give you the most support and well wishes while you work on this project -Ari
|
||||||
return (
|
return (
|
||||||
<div className={classnames(styles.avatarChanger, "avatar-changer")}>
|
<div
|
||||||
|
className={classnames(
|
||||||
|
styles.avatarChanger,
|
||||||
|
"avatar-changer",
|
||||||
|
vertical ? styles.vertical : null
|
||||||
|
)}
|
||||||
|
>
|
||||||
{currentAvatarBlob != null ? (
|
{currentAvatarBlob != null ? (
|
||||||
<img src={currentAvatarBlob!} alt="Your avatar" />
|
<img
|
||||||
|
className="current-avatar"
|
||||||
|
src={currentAvatarBlob!}
|
||||||
|
alt="Your avatar"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UserIcon />
|
<UserIcon />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
.connection {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
background-color: var(--bg-dark);
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
width: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
column-gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection svg {
|
||||||
|
height: 1.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconContainer {
|
||||||
|
font-size: 2.5em;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column nowrap;
|
||||||
|
row-gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.serviceName {
|
||||||
|
font-weight: 500;
|
||||||
|
font-stretch: expanded;
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkedIdentity {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable.unavailable {
|
||||||
|
background-color: var(--bg-darker);
|
||||||
|
color: var(--fg-dark);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import styles from "./Connection.module.css";
|
||||||
|
import classnames from "classnames";
|
||||||
|
import CheckIcon from "./icons/CheckIcon";
|
||||||
|
import { type AuthProviderState } from "@/auth/AuthProvider";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Connection({
|
||||||
|
service,
|
||||||
|
unavailable = false,
|
||||||
|
authState
|
||||||
|
}: {
|
||||||
|
service: string;
|
||||||
|
unavailable?: boolean;
|
||||||
|
authState?: AuthProviderState;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [changing, setChanging] = React.useState(false);
|
||||||
|
|
||||||
|
// TODO: Reimplement hold-to-unlink.
|
||||||
|
|
||||||
|
async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (unavailable) return;
|
||||||
|
|
||||||
|
const provider = service.toLowerCase();
|
||||||
|
if (authState?.connected === false) {
|
||||||
|
setChanging(true);
|
||||||
|
router.push(`/oauth/${provider}/login`);
|
||||||
|
} else {
|
||||||
|
setChanging(true);
|
||||||
|
if (confirm(`Unlink your ${service} account?`)) {
|
||||||
|
await fetch(`/api/unlink?provider=${provider}`, { method: "POST" });
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
setChanging(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={classnames(
|
||||||
|
styles.connection,
|
||||||
|
unavailable ? styles.unavailable : null,
|
||||||
|
!authState?.connected ? styles.disconnected : null
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={changing}
|
||||||
|
>
|
||||||
|
<div className={styles.iconContainer}>●</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.serviceName}>{service}</div>
|
||||||
|
{authState?.connected !== false ? (
|
||||||
|
<div
|
||||||
|
className={styles.linkedIdentity}
|
||||||
|
title={
|
||||||
|
authState?.id ?? "This integration is currently unavailable."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{unavailable ? "Unavailable" : authState!.username}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{authState?.connected && !unavailable ? <CheckIcon /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
|
@ -13,7 +13,7 @@
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formRow label {
|
.label {
|
||||||
display: block;
|
display: block;
|
||||||
font-variant: all-small-caps;
|
font-variant: all-small-caps;
|
||||||
font-size: 105%;
|
font-size: 105%;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
|
import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
|
||||||
import React from "react";
|
import React, { LabelHTMLAttributes } from "react";
|
||||||
import styles from "./Input.module.css";
|
import styles from "./Input.module.css";
|
||||||
|
import classnames from "classnames";
|
||||||
|
|
||||||
type CustomInputProps<T> = {
|
type CustomInputProps<T> = {
|
||||||
customRender?: (fieldProps: FieldProps) => React.ReactNode;
|
customRender?: (fieldProps: FieldProps) => React.ReactNode;
|
||||||
|
@ -11,15 +12,26 @@ type CustomInputProps<T> = {
|
||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function Label({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: LabelHTMLAttributes<HTMLLabelElement>) {
|
||||||
|
return (
|
||||||
|
<label className={classnames(styles.label, props.className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Input<T>(
|
export default function Input<T>(
|
||||||
props: CustomInputProps<T> &
|
props: CustomInputProps<T> &
|
||||||
FieldAttributes<{ hint?: string; label: string; disabled?: boolean }>
|
FieldAttributes<{ hint?: string; label?: string; disabled?: boolean }>
|
||||||
) {
|
) {
|
||||||
const generatedId = React.useId();
|
const generatedId = React.useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.formRow}>
|
<div className={classnames("form-row", styles.formRow)}>
|
||||||
<label htmlFor={generatedId}>{props.label}</label>
|
{props.label ? <Label htmlFor={generatedId}>{props.label}</Label> : null}
|
||||||
<Field id={generatedId} {...props}>
|
<Field id={generatedId} {...props}>
|
||||||
{(fieldProps: FieldProps) => {
|
{(fieldProps: FieldProps) => {
|
||||||
let { field, meta, form } = fieldProps;
|
let { field, meta, form } = fieldProps;
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
.form {
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form :is(button, input)[type="submit"] {
|
.form :is(button, input)[type="submit"] {
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
Loading…
Reference in New Issue