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,
|
"printWidth": 150,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"singleQuote": true,
|
"singleQuote": false,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
"useTabs": true
|
"useTabs": true
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
.btn {
|
.btn {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
column-gap: 7px;
|
||||||
|
align-items: center;
|
||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
padding: 4px 16px;
|
padding: 5px 16px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
background: none;
|
background: none;
|
||||||
@ -12,4 +15,14 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
border: 1px solid var(--border-color-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 { splitProps } from "solid-js";
|
||||||
import './Button.scss';
|
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 Button: ParentComponent<ButtonProps> = (props) => {
|
||||||
const [local, attrs] = splitProps(props, ['children']);
|
const [local, attrs] = splitProps(props, ["children", "isLoading", "loadingText"]);
|
||||||
return (
|
return (
|
||||||
<button class={`btn`} {...attrs}>
|
<button disabled={local.isLoading} class={`btn`} data-loading={local.isLoading} {...attrs}>
|
||||||
{local.children}
|
{local.isLoading && <Spinner />}
|
||||||
|
{local.isLoading ? local.loadingText || "Loading..." : local.children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,19 +4,42 @@
|
|||||||
row-gap: 7px;
|
row-gap: 7px;
|
||||||
margin-bottom: 3.125rem;
|
margin-bottom: 3.125rem;
|
||||||
|
|
||||||
& div:first-of-type {
|
& .form-footer {
|
||||||
display: flex;
|
|
||||||
column-gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
& div:last-of-type {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 15px;
|
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) {
|
@media screen and (max-width: 560px) {
|
||||||
.contact-form div:first-of-type {
|
.contact-form .flex {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
row-gap: 7px;
|
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 { createSignal } from "solid-js";
|
||||||
import './ContactForm.scss';
|
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 = {
|
const ErrorMessage: Component<{ message: string }> = (props) => <span class="error-message">{props.message}</span>;
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
subject: string;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ContactForm: Component = () => {
|
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 (
|
return (
|
||||||
<form class="contact-form" name="contact" method="post" data-netlify="true">
|
<form use:formSubmit={sendMessage} class="contact-form" name="contact" method="post" data-netlify="true">
|
||||||
<div>
|
<div hidden>
|
||||||
<input name="name" placeholder="Ваше имя" type="text" />
|
Don't fill this out if you're human
|
||||||
<input name="email" placeholder="example@email.com" type="text" />
|
<input onInput={onChange} name="bot-field" />
|
||||||
</div>
|
</div>
|
||||||
<input name="subject" placeholder="Тема сообщения" type="text" />
|
<div class="flex">
|
||||||
<textarea name="message" placeholder="Ваше сообщение..." id="message" cols={30} rows={10} />
|
|
||||||
<div>
|
<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>
|
</div>
|
||||||
</form>
|
</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 />
|
<NotFound />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import About from '@root/templates/About.astro';
|
import About from '@templates/About.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
<About />
|
<About />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import Articles from '@root/templates/Articles.astro';
|
import Articles from '@templates/Articles.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
<Articles />
|
<Articles />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import Contacts from '@root/templates/Contacts.astro';
|
import Contacts from '@templates/Contacts.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
<Contacts />
|
<Contacts />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import Home from '@root/templates/Home.astro';
|
import Home from '@templates/Home.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
<Home />
|
<Home />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import Projects from '@root/templates/Projects.astro';
|
import Projects from '@templates/Projects.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
<Projects />
|
<Projects />
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
@import './theme.scss';
|
@import "./theme.scss";
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--color-yellow-100);
|
background: var(--color-yellow-100);
|
||||||
@ -13,7 +13,7 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-is-mobile-open='true'] {
|
body[data-is-mobile-open="true"] {
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,6 +44,10 @@ body {
|
|||||||
padding: 0 var(--content-padding);
|
padding: 0 var(--content-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.list-links {
|
.list-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -73,7 +77,7 @@ body {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
border-color: var(--border-color-hover);
|
border-color: var(--border-color-hover);
|
||||||
}
|
}
|
||||||
&[data-active='true'] {
|
&[data-active="true"] {
|
||||||
background-color: var(--background-btn-link-active);
|
background-color: var(--background-btn-link-active);
|
||||||
border-color: var(--border-color-hover);
|
border-color: var(--border-color-hover);
|
||||||
}
|
}
|
||||||
@ -97,6 +101,7 @@ input,
|
|||||||
textarea {
|
textarea {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
|
font-size: 20px;
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
background-color: var(--background-inputs-color);
|
background-color: var(--background-inputs-color);
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
:root,
|
:root,
|
||||||
:root[data-theme='light'] {
|
:root[data-theme="light"] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
--font-fallback: -apple-system, BlinkMacSystemFont, Segoe UI, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji;
|
--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-xs: 0.75rem;
|
||||||
--font-size-sm: 0.875rem;
|
--font-size-sm: 0.875rem;
|
||||||
--font-size-md: 1rem;
|
--font-size-md: 1rem;
|
||||||
@ -20,7 +20,7 @@
|
|||||||
--font-size-9xl: 8rem;
|
--font-size-9xl: 8rem;
|
||||||
|
|
||||||
/* Line heights */
|
/* Line heights */
|
||||||
--line-height-normal: 'normal';
|
--line-height-normal: "normal";
|
||||||
--line-height-none: 1;
|
--line-height-none: 1;
|
||||||
--line-height-shorter: 1.25;
|
--line-height-shorter: 1.25;
|
||||||
--line-height-short: 1.375;
|
--line-height-short: 1.375;
|
||||||
@ -31,6 +31,8 @@
|
|||||||
--color-white: #ffffff;
|
--color-white: #ffffff;
|
||||||
--color-black: #010409;
|
--color-black: #010409;
|
||||||
|
|
||||||
|
--color-error: #ff3737;
|
||||||
|
|
||||||
--color-blue-50: #cae8ff;
|
--color-blue-50: #cae8ff;
|
||||||
--color-blue-100: #a5d6ff;
|
--color-blue-100: #a5d6ff;
|
||||||
--color-blue-200: #79c0ff;
|
--color-blue-200: #79c0ff;
|
||||||
@ -109,7 +111,7 @@
|
|||||||
--icon-secondary: var(--color-gray-700);
|
--icon-secondary: var(--color-gray-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] {
|
:root[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
--font-color: var(--color-white);
|
--font-color: var(--color-white);
|
||||||
@ -136,7 +138,7 @@
|
|||||||
--icon-filter: invert(100%);
|
--icon-filter: invert(100%);
|
||||||
|
|
||||||
& .header svg,
|
& .header svg,
|
||||||
& .site-content svg:not([data-filter='none']) {
|
& .site-content svg:not([data-filter="none"]) {
|
||||||
filter: var(--icon-filter);
|
filter: var(--icon-filter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import PageLayout from '@layouts/default.astro';
|
import PageLayout from "@layouts/default.astro";
|
||||||
import ContactForm from '@components/ContactForm';
|
import ContactForm from "@components/ContactForm";
|
||||||
import ContactsList from '@components/ContactsList';
|
import ContactsList from "@components/ContactsList";
|
||||||
---
|
---
|
||||||
|
|
||||||
<PageLayout title="Контакты" description="Воспользуйтесь формой обратной связи или напишите мне в социальные сети.">
|
<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 }>;
|
list: Array<{ icon?: string | undefined; name: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ContactFieldsType = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
"bot-field": string;
|
||||||
|
};
|
||||||
|
|
||||||
type ArticleBlockType = {
|
type ArticleBlockType = {
|
||||||
[year: number]: ArticleType[];
|
[year: number]: ArticleType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ThemeModeType = 'light' | 'dark';
|
type ThemeModeType = "light" | "dark";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
export function removeSlashes(string: string): string {
|
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/*"],
|
"@root/*": ["./src/*"],
|
||||||
"@styles/*": ["./src/styles/*"],
|
"@styles/*": ["./src/styles/*"],
|
||||||
"@components/*": ["./src/components/*"],
|
"@components/*": ["./src/components/*"],
|
||||||
|
"@templates/*": ["./src/templates/*"],
|
||||||
"@layouts/*": ["./src/layouts/*"],
|
"@layouts/*": ["./src/layouts/*"],
|
||||||
"@utils/*": ["./src/utils/*"],
|
"@utils/*": ["./src/utils/*"],
|
||||||
"@stores/*": ["./src/stores/*"]
|
"@stores/*": ["./src/stores/*"],
|
||||||
|
"@hooks/*": ["./src/hooks/*"]
|
||||||
},
|
},
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"typeRoots": ["./node_modules/@types", "./src/types"]
|
"typeRoots": ["./node_modules/@types", "./src/types"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user