mirror of
https://github.com/sergeyyarkov/yarkov.tech.git
synced 2026-10-11 02:19:08 +03:00
feat: contact form submission
This commit is contained in:
parent
37cb82ccd5
commit
b808437567
@ -1,7 +1,7 @@
|
||||
{
|
||||
"printWidth": 150,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": true
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
column-gap: 7px;
|
||||
align-items: center;
|
||||
font-family: var(--font-family-base);
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--font-color);
|
||||
padding: 4px 16px;
|
||||
padding: 5px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
background: none;
|
||||
@ -12,4 +15,14 @@
|
||||
&:hover {
|
||||
border: 1px solid var(--border-color-hover);
|
||||
}
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
&:hover {
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
&[data-loading="true"] {
|
||||
user-select: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
import type { ParentComponent, JSX } from 'solid-js';
|
||||
import type { ParentComponent, JSX } from "solid-js";
|
||||
|
||||
import { splitProps } from 'solid-js';
|
||||
import './Button.scss';
|
||||
import { splitProps } from "solid-js";
|
||||
import Spinner from "@components/Spinner";
|
||||
import "./Button.scss";
|
||||
|
||||
type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
type ButtonProps = {
|
||||
isLoading?: boolean | undefined;
|
||||
loadingText?: string | undefined;
|
||||
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const Button: ParentComponent<ButtonProps> = (props) => {
|
||||
const [local, attrs] = splitProps(props, ['children']);
|
||||
const [local, attrs] = splitProps(props, ["children", "isLoading", "loadingText"]);
|
||||
return (
|
||||
<button class={`btn`} {...attrs}>
|
||||
{local.children}
|
||||
<button disabled={local.isLoading} class={`btn`} data-loading={local.isLoading} {...attrs}>
|
||||
{local.isLoading && <Spinner />}
|
||||
{local.isLoading ? local.loadingText || "Loading..." : local.children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,19 +4,42 @@
|
||||
row-gap: 7px;
|
||||
margin-bottom: 3.125rem;
|
||||
|
||||
& div:first-of-type {
|
||||
display: flex;
|
||||
column-gap: 10px;
|
||||
}
|
||||
|
||||
& div:last-of-type {
|
||||
& .form-footer {
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
& .form-result {
|
||||
font-style: italic;
|
||||
color: var(--font-color-gray);
|
||||
}
|
||||
|
||||
& .error-message {
|
||||
margin: 2px 0 3px 0;
|
||||
display: block;
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
user-select: none;
|
||||
&::before {
|
||||
content: "*";
|
||||
}
|
||||
}
|
||||
|
||||
& .flex {
|
||||
column-gap: 10px;
|
||||
& div {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
& input.error-input,
|
||||
& textarea.error-input {
|
||||
border: 1px solid var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 560px) {
|
||||
.contact-form div:first-of-type {
|
||||
.contact-form .flex {
|
||||
flex-direction: column;
|
||||
row-gap: 7px;
|
||||
}
|
||||
|
||||
@ -1,26 +1,115 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Component, JSX } from "solid-js";
|
||||
|
||||
import Button from '@components/Button';
|
||||
import './ContactForm.scss';
|
||||
import { createSignal } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { useForm } from "@hooks/index";
|
||||
import { encode } from "@root/utils/url";
|
||||
import Button from "@components/Button";
|
||||
import { validateEmail } from "./validations";
|
||||
import Submitted from "./Submitted";
|
||||
import Error from "./Error";
|
||||
import "./ContactForm.scss";
|
||||
|
||||
type InputFieldsType = {
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
};
|
||||
const ErrorMessage: Component<{ message: string }> = (props) => <span class="error-message">{props.message}</span>;
|
||||
|
||||
const ContactForm: Component = () => {
|
||||
const { formSubmit, validate, errors } = useForm({ errorClass: "error-input" });
|
||||
const [isSubmitted, setIsSubmitted] = createSignal<boolean>(false);
|
||||
const [isLoading, setIsLoading] = createSignal<boolean>(false);
|
||||
const [isError, setIsError] = createSignal<boolean>(false);
|
||||
const [state, setState] = createStore<ContactFieldsType>({ name: "", email: "", subject: "", message: "", "bot-field": "" });
|
||||
|
||||
const reset = (form: HTMLFormElement) => {
|
||||
for (const key in state) {
|
||||
form.elements[key].value = "";
|
||||
setState({ [key]: "" });
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async (form: HTMLFormElement) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
fetch("/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: encode({
|
||||
"form-name": "contact",
|
||||
...state,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
setIsError(true);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsSubmitted(true);
|
||||
reset(form);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
|
||||
const key = e.currentTarget.name;
|
||||
const value = e.currentTarget.value;
|
||||
|
||||
if (!Object.hasOwn(state, key)) return;
|
||||
setState({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<form class="contact-form" name="contact" method="post" data-netlify="true">
|
||||
<div>
|
||||
<input name="name" placeholder="Ваше имя" type="text" />
|
||||
<input name="email" placeholder="example@email.com" type="text" />
|
||||
<form use:formSubmit={sendMessage} class="contact-form" name="contact" method="post" data-netlify="true">
|
||||
<div hidden>
|
||||
Don't fill this out if you're human
|
||||
<input onInput={onChange} name="bot-field" />
|
||||
</div>
|
||||
<input name="subject" placeholder="Тема сообщения" type="text" />
|
||||
<textarea name="message" placeholder="Ваше сообщение..." id="message" cols={30} rows={10} />
|
||||
<div class="flex">
|
||||
<div>
|
||||
<Button type="submit">Отправить</Button>
|
||||
<input onInput={onChange} use:validate name="name" placeholder="Ваше имя" type="text" maxLength="20" autocomplete="off" required />
|
||||
{errors.name && <ErrorMessage message={errors.name} />}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
onInput={onChange}
|
||||
use:validate={[validateEmail]}
|
||||
name="email"
|
||||
placeholder="example@email.com"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
{errors.email && <ErrorMessage message={errors.email} />}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
value={state.subject}
|
||||
onInput={onChange}
|
||||
use:validate
|
||||
name="subject"
|
||||
placeholder="Тема сообщения"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
{errors.subject && <ErrorMessage message={errors.subject} />}
|
||||
<textarea
|
||||
onInput={onChange}
|
||||
use:validate
|
||||
name="message"
|
||||
placeholder="Ваше сообщение..."
|
||||
id="message"
|
||||
autocomplete="off"
|
||||
cols={30}
|
||||
rows={10}
|
||||
required
|
||||
/>
|
||||
{errors.message && <ErrorMessage message={errors.message} />}
|
||||
<div class="form-footer">
|
||||
{!isError() && !isSubmitted() && (
|
||||
<Button isLoading={isLoading()} loadingText="Отправка..." type="submit">
|
||||
Отправить
|
||||
</Button>
|
||||
)}
|
||||
{!isError() && isSubmitted() && <Submitted />}
|
||||
{isError() && isSubmitted() && <Error />}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
7
src/components/ContactForm/Error.tsx
Normal file
7
src/components/ContactForm/Error.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import type { Component } from "solid-js";
|
||||
|
||||
const Submitted: Component = () => {
|
||||
return <div class="form-result">❌ При отправке произошла ошибка. Повторите запрос позже.</div>;
|
||||
};
|
||||
|
||||
export default Submitted;
|
||||
7
src/components/ContactForm/Submitted.tsx
Normal file
7
src/components/ContactForm/Submitted.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import type { Component } from "solid-js";
|
||||
|
||||
const Submitted: Component = () => {
|
||||
return <div class="form-result">✔️ Сообщение было успешно отправлено!</div>;
|
||||
};
|
||||
|
||||
export default Submitted;
|
||||
3
src/components/ContactForm/validations.ts
Normal file
3
src/components/ContactForm/validations.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { isEmail } from "@utils/string";
|
||||
|
||||
export const validateEmail = (field: HTMLInputElement) => (isEmail(field.value) ? false : "Заполните поле в формате e-mail.");
|
||||
26
src/components/Spinner/Spinner.scss
Normal file
26
src/components/Spinner/Spinner.scss
Normal file
@ -0,0 +1,26 @@
|
||||
.spinner {
|
||||
--spinner-size: 1em;
|
||||
display: inline-block;
|
||||
border-top: 1px solid var(--font-color);
|
||||
border-right: 1px solid var(--font-color);
|
||||
border-bottom-style: solid;
|
||||
border-left-style: solid;
|
||||
border-radius: 99999px;
|
||||
border-bottom-width: 1px;
|
||||
border-left-width: 1px;
|
||||
border-bottom-color: transparent;
|
||||
border-left-color: transparent;
|
||||
animation: 0.45s linear 0s infinite normal none running spinner;
|
||||
width: var(--spinner-size);
|
||||
height: var(--spinner-size);
|
||||
}
|
||||
|
||||
@keyframes spinner {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
11
src/components/Spinner/Spinner.tsx
Normal file
11
src/components/Spinner/Spinner.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import type { Component, JSX } from "solid-js";
|
||||
|
||||
import "./Spinner.scss";
|
||||
|
||||
type SpinnerProps = JSX.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const Spinner: Component<SpinnerProps> = (props) => {
|
||||
return <div class="spinner" {...props} />;
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
1
src/components/Spinner/index.ts
Normal file
1
src/components/Spinner/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "./Spinner";
|
||||
1
src/hooks/index.ts
Normal file
1
src/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./useForm";
|
||||
80
src/hooks/useForm.ts
Normal file
80
src/hooks/useForm.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import type { Accessor } from "solid-js";
|
||||
|
||||
import { createStore, SetStoreFunction } from "solid-js/store";
|
||||
|
||||
type HTMLElementsType = HTMLInputElement | HTMLTextAreaElement;
|
||||
type CustomValidatorType = (element: HTMLElementsType) => string | false;
|
||||
type FieldConfigType = { element: HTMLElementsType; validators: Array<CustomValidatorType> };
|
||||
type FormSubmitFnType = (ref: HTMLFormElement, accessor: Accessor<(form: HTMLFormElement) => unknown>) => void;
|
||||
type ValidateFnType = (ref: HTMLElementsType, accessor: Accessor<Array<CustomValidatorType>>) => void;
|
||||
type СheckValidFnType = (config: FieldConfigType, setErrors: SetStoreFunction<Record<string, unknown>>, errorClass: string) => void;
|
||||
|
||||
const checkValid: СheckValidFnType = (fieldConfig, setErrors, errorClass) => {
|
||||
const { element, validators = [] } = fieldConfig;
|
||||
|
||||
element.setCustomValidity("");
|
||||
element.checkValidity();
|
||||
let message = element.validationMessage;
|
||||
|
||||
/* Run custom validators */
|
||||
if (Array.isArray(validators)) {
|
||||
if (!message) {
|
||||
for (const validator of validators) {
|
||||
if (typeof validator === "function") {
|
||||
const text = validator(element);
|
||||
if (text) {
|
||||
element.setCustomValidity(text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
message = element.validationMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/* Set errors if message exists and class to input element */
|
||||
if (message) {
|
||||
errorClass && element.classList.toggle(errorClass, true);
|
||||
setErrors({ [element.name]: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const useForm = ({ errorClass }) => {
|
||||
const [errors, setErrors] = createStore<{ [key: string]: string | undefined }>({});
|
||||
const fields = {};
|
||||
|
||||
const validate: ValidateFnType = (ref, accessor) => {
|
||||
const validators = accessor();
|
||||
let config: FieldConfigType;
|
||||
fields[ref.name] = config = { element: ref, validators };
|
||||
ref.onblur = () => checkValid(config, setErrors, errorClass);
|
||||
ref.oninput = () => {
|
||||
if (!errors[ref.name]) return;
|
||||
setErrors({ [ref.name]: undefined });
|
||||
errorClass && ref.classList.toggle(errorClass, false);
|
||||
};
|
||||
};
|
||||
|
||||
const formSubmit: FormSubmitFnType = (ref, accessor) => {
|
||||
const callback = accessor();
|
||||
ref.setAttribute("novalidate", "");
|
||||
ref.onsubmit = (e) => {
|
||||
e.preventDefault();
|
||||
let isError = false;
|
||||
|
||||
/* Validate all fields of form */
|
||||
for (const key in fields) {
|
||||
const field = fields[key] as FieldConfigType;
|
||||
checkValid(field, setErrors, errorClass);
|
||||
if (!isError && field.element.validationMessage) {
|
||||
field.element.focus();
|
||||
isError = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof callback === "function") !isError && callback(ref);
|
||||
};
|
||||
};
|
||||
|
||||
return { validate, formSubmit, errors };
|
||||
};
|
||||
21
src/i18n/en/ui.ts
Normal file
21
src/i18n/en/ui.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export default {
|
||||
"site.title": "yarkov.tech",
|
||||
/* Navigation */
|
||||
"navigation.articles": "Articles",
|
||||
"navigation.projects": "Projects",
|
||||
"navigation.about": "About",
|
||||
"navigation.contacts": "Contacts",
|
||||
/* Splash page */
|
||||
"splash.headline": "Hello, I'am Sergey",
|
||||
"splash.subheadline": "Fullstack web-developer & freelancer",
|
||||
"splash.description":
|
||||
"I develop websites and web applications of varying complexity, as well as publish various articles and tutorials on various IT topics.",
|
||||
"splash.about": "about",
|
||||
"splash.contact": "write me",
|
||||
/* Recent articles */
|
||||
"recentArticles.heading": "recent publications",
|
||||
"recentArticles.all": "see all",
|
||||
/* 404 page */
|
||||
"404.heading": "404",
|
||||
"404.description": "The page you are trying to find does not exist.",
|
||||
};
|
||||
21
src/i18n/ru/ui.ts
Normal file
21
src/i18n/ru/ui.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export default {
|
||||
"site.title": "yarkov.tech",
|
||||
/* Navigation */
|
||||
"navigation.articles": "Статьи",
|
||||
"navigation.projects": "Проекты",
|
||||
"navigation.about": "Обо мне",
|
||||
"navigation.contacts": "Контакты",
|
||||
/* Splash page */
|
||||
"splash.headline": "Hello, I'am Sergey",
|
||||
"splash.subheadline": "Fullstack web-developer & freelancer",
|
||||
"splash.description":
|
||||
"Я занимаюсь разработкой веб-сайтов и веб-приложений разной сложности, а также публикую различные статьи и туториалы на разные IT темы.",
|
||||
"splash.about": "обо мне",
|
||||
"splash.contact": "написать мне",
|
||||
/* Recent articles */
|
||||
"recentArticles.heading": "недавние публикации",
|
||||
"recentArticles.all": "смотерть все",
|
||||
/* 404 page */
|
||||
"404.heading": "404",
|
||||
"404.description": "Страницу которую вы пытаетесь найти не существует.",
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import NotFound from '@root/templates/NotFound.astro';
|
||||
import NotFound from '@templates/NotFound.astro';
|
||||
---
|
||||
|
||||
<NotFound />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import About from '@root/templates/About.astro';
|
||||
import About from '@templates/About.astro';
|
||||
---
|
||||
|
||||
<About />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import Articles from '@root/templates/Articles.astro';
|
||||
import Articles from '@templates/Articles.astro';
|
||||
---
|
||||
|
||||
<Articles />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import Contacts from '@root/templates/Contacts.astro';
|
||||
import Contacts from '@templates/Contacts.astro';
|
||||
---
|
||||
|
||||
<Contacts />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import Home from '@root/templates/Home.astro';
|
||||
import Home from '@templates/Home.astro';
|
||||
---
|
||||
|
||||
<Home />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import Projects from '@root/templates/Projects.astro';
|
||||
import Projects from '@templates/Projects.astro';
|
||||
---
|
||||
|
||||
<Projects />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
@import './theme.scss';
|
||||
@import "./theme.scss";
|
||||
|
||||
::selection {
|
||||
background: var(--color-yellow-100);
|
||||
@ -13,7 +13,7 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body[data-is-mobile-open='true'] {
|
||||
body[data-is-mobile-open="true"] {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
@ -44,6 +44,10 @@ body {
|
||||
padding: 0 var(--content-padding);
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.list-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -73,7 +77,7 @@ body {
|
||||
color: inherit;
|
||||
border-color: var(--border-color-hover);
|
||||
}
|
||||
&[data-active='true'] {
|
||||
&[data-active="true"] {
|
||||
background-color: var(--background-btn-link-active);
|
||||
border-color: var(--border-color-hover);
|
||||
}
|
||||
@ -97,6 +101,7 @@ input,
|
||||
textarea {
|
||||
-webkit-appearance: none;
|
||||
font-family: var(--font-family-base);
|
||||
font-size: 20px;
|
||||
padding: 8px 15px;
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--background-inputs-color);
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
:root,
|
||||
:root[data-theme='light'] {
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
/* Fonts */
|
||||
--font-fallback: -apple-system, BlinkMacSystemFont, Segoe UI, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji;
|
||||
--font-family-base: 'Rubik', var(--font-fallback);
|
||||
--font-family-base: "Rubik", var(--font-fallback);
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-md: 1rem;
|
||||
@ -20,7 +20,7 @@
|
||||
--font-size-9xl: 8rem;
|
||||
|
||||
/* Line heights */
|
||||
--line-height-normal: 'normal';
|
||||
--line-height-normal: "normal";
|
||||
--line-height-none: 1;
|
||||
--line-height-shorter: 1.25;
|
||||
--line-height-short: 1.375;
|
||||
@ -31,6 +31,8 @@
|
||||
--color-white: #ffffff;
|
||||
--color-black: #010409;
|
||||
|
||||
--color-error: #ff3737;
|
||||
|
||||
--color-blue-50: #cae8ff;
|
||||
--color-blue-100: #a5d6ff;
|
||||
--color-blue-200: #79c0ff;
|
||||
@ -109,7 +111,7 @@
|
||||
--icon-secondary: var(--color-gray-700);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
/* Fonts */
|
||||
--font-color: var(--color-white);
|
||||
@ -136,7 +138,7 @@
|
||||
--icon-filter: invert(100%);
|
||||
|
||||
& .header svg,
|
||||
& .site-content svg:not([data-filter='none']) {
|
||||
& .site-content svg:not([data-filter="none"]) {
|
||||
filter: var(--icon-filter);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
import PageLayout from '@layouts/default.astro';
|
||||
import ContactForm from '@components/ContactForm';
|
||||
import ContactsList from '@components/ContactsList';
|
||||
import PageLayout from "@layouts/default.astro";
|
||||
import ContactForm from "@components/ContactForm";
|
||||
import ContactsList from "@components/ContactsList";
|
||||
---
|
||||
|
||||
<PageLayout title="Контакты" description="Воспользуйтесь формой обратной связи или напишите мне в социальные сети.">
|
||||
|
||||
10
src/types/main.d.ts
vendored
10
src/types/main.d.ts
vendored
@ -58,9 +58,17 @@ declare global {
|
||||
list: Array<{ icon?: string | undefined; name: string }>;
|
||||
};
|
||||
|
||||
type ContactFieldsType = {
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
"bot-field": string;
|
||||
};
|
||||
|
||||
type ArticleBlockType = {
|
||||
[year: number]: ArticleType[];
|
||||
};
|
||||
|
||||
type ThemeModeType = 'light' | 'dark';
|
||||
type ThemeModeType = "light" | "dark";
|
||||
}
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
export function removeSlashes(string: string): string {
|
||||
return string.replaceAll('/', '');
|
||||
return string.replaceAll("/", "");
|
||||
}
|
||||
|
||||
export function isEmail(email: string): boolean {
|
||||
return new RegExp(/[a-z0-9]+@[a-z]+\.[a-z]{2,3}/).test(email);
|
||||
}
|
||||
|
||||
5
src/utils/url.ts
Normal file
5
src/utils/url.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const encode = (data: object) => {
|
||||
return Object.keys(data)
|
||||
.map((key) => encodeURIComponent(key) + "=" + encodeURIComponent(data[key]))
|
||||
.join("&");
|
||||
};
|
||||
@ -13,9 +13,11 @@
|
||||
"@root/*": ["./src/*"],
|
||||
"@styles/*": ["./src/styles/*"],
|
||||
"@components/*": ["./src/components/*"],
|
||||
"@templates/*": ["./src/templates/*"],
|
||||
"@layouts/*": ["./src/layouts/*"],
|
||||
"@utils/*": ["./src/utils/*"],
|
||||
"@stores/*": ["./src/stores/*"]
|
||||
"@stores/*": ["./src/stores/*"],
|
||||
"@hooks/*": ["./src/hooks/*"]
|
||||
},
|
||||
"outDir": "./dist",
|
||||
"typeRoots": ["./node_modules/@types", "./src/types"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user