Initial commit

This commit is contained in:
Phil Zhitnikov 2023-11-11 10:34:31 +04:00
commit affcc00ba8
119 changed files with 2724 additions and 0 deletions

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

92
.gitignore vendored Normal file
View File

@ -0,0 +1,92 @@
# Created by .ignore support plugin (hsz.mobi)
### Node template
# Logs
/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# IDE / Editor
.idea
# Service worker
sw.*
# macOS
.DS_Store
# Vim swap files
*.swp
/backend/env/
/yarn.lock

2
.nuxtignore Normal file
View File

@ -0,0 +1,2 @@
pages/projects/*.vue
components/projects/*.vue

7
README.md Normal file
View File

@ -0,0 +1,7 @@
# Useful commands
### Create thumbnail (400px side max) for all jpg/JPG images in folder, move it to `thumbs` folder
```for ext in jpg JPG; do mogrify -format $ext -path thumbs -auto-orient -thumbnail '400' *.$ext; done```
### Get thumbnail size for every file in folder
```identify -ping -format 'image: "%[basename].%[extension]", thumbSize: {w: %[width], h: %[height]}},\n' *```

View File

@ -0,0 +1,30 @@
export default async function (to, from, savedPosition) {
if (savedPosition) {
return savedPosition;
}
const findEl = async (hash, x = 0) => {
return (
document.querySelector(hash) ||
new Promise(resolve => {
if (x > 50) {
return resolve(document.querySelector("#app"));
}
setTimeout(() => {
resolve(findEl(hash, ++x || 1));
}, 100);
})
);
};
if (to.hash) {
let el = await findEl(to.hash);
if ("scrollBehavior" in document.documentElement.style) {
return window.scrollTo({top: el.offsetTop, behavior: "smooth"});
} else {
return window.scrollTo(0, el.offsetTop);
}
}
return {x: 0, y: 0};
}

0
assets/app.css Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,249 @@
/* === Font-faces for FuturaPT === */
/* Light regular */
@font-face {
font-family: 'FuturaPT-Light';
src: url('FuturaPT-Light.woff2') format('woff2'),
url('FuturaPT-Light.woff') format('woff'),
url('FuturaPT-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
}
/* Light italic */
@font-face {
font-family: 'FuturaPT-LightItalic';
src: url('FuturaPT-LightObl.woff2') format('woff2'),
url('FuturaPT-LightObl.woff') format('woff'),
url('FuturaPT-LightObl.ttf') format('truetype');
font-weight: 300;
font-style: italic;
}
/* Regular */
@font-face {
font-family: 'FuturaPT-Regular';
src: url('FuturaPT-Book.woff2') format('woff2'),
url('FuturaPT-Book.woff') format('woff'),
url('FuturaPT-Book.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
/* Regular italic */
@font-face {
font-family: 'FuturaPT-Italic';
src: url('FuturaPT-BookObl.woff2') format('woff2'),
url('FuturaPT-BookObl.woff') format('woff'),
url('FuturaPT-BookObl.ttf') format('truetype');
font-weight: normal;
font-style: italic;
}
/* Medium */
@font-face {
font-family: 'FuturaPT-Medium';
src: url('FuturaPT-Medium.woff2') format('woff2'),
url('FuturaPT-Medium.woff') format('woff'),
url('FuturaPT-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
/* Medium italic */
@font-face {
font-family: 'FuturaPT-MediumItalic';
src: url('FuturaPT-MediumObl.woff2') format('woff2'),
url('FuturaPT-MediumObl.woff') format('woff'),
url('FuturaPT-MediumObl.ttf') format('truetype');
font-weight: 500;
font-style: italic;
}
/* Semibold */
@font-face {
font-family: 'FuturaPT-SemiBold';
src: url('FuturaPT-Demi.woff2') format('woff2'),
url('FuturaPT-Demi.woff') format('woff'),
url('FuturaPT-Demi.ttf') format('truetype');
font-weight: 600;
font-style: normal;
}
/* Semibold italic */
@font-face {
font-family: 'FuturaPT-SemiBoldItalic';
src: url('FuturaPT-DemiObl.woff2') format('woff2'),
url('FuturaPT-DemiObl.woff') format('woff'),
url('FuturaPT-DemiObl.ttf') format('truetype');
font-weight: 600;
font-style: italic;
}
/* Bold */
@font-face {
font-family: 'FuturaPT-Bold';
src: url('FuturaPT-Bold.woff2') format('woff2'),
url('FuturaPT-Bold.woff') format('woff'),
url('FuturaPT-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
}
/* Bold italic */
@font-face {
font-family: 'FuturaPT-BoldItalic';
src: url('FuturaPT-BoldObl.woff2') format('woff2'),
url('FuturaPT-BoldObl.woff') format('woff'),
url('FuturaPT-BoldObl.ttf') format('truetype');
font-weight: bold;
font-style: italic;
}
/* Heavy */
@font-face {
font-family: 'FuturaPT-Heavy';
src: url('FuturaPT-Heavy.woff2') format('woff2'),
url('FuturaPT-Heavy.woff') format('woff'),
url('FuturaPT-Heavy.ttf') format('truetype');
font-weight: 900;
font-style: normal;
}
/* Heavy italic */
@font-face {
font-family: 'FuturaPT-HeavyItalic';
src: url('FuturaPT-HeavyObl.woff2') format('woff2'),
url('FuturaPT-HeavyObl.woff') format('woff'),
url('FuturaPT-HeavyObl.ttf') format('truetype');
font-weight: 900;
font-style: italic;
}
/* Extra bold */
@font-face {
font-family: 'FuturaPT-ExtraBold';
src: url('FuturaPT-ExtraBold.woff2') format('woff2'),
url('FuturaPT-ExtraBold.woff') format('woff'),
url('FuturaPT-ExtraBold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
}
/* Extra bold italic */
@font-face {
font-family: 'FuturaPT-ExtraBoldItalic';
src: url('FuturaPT-ExtraBoldObl.woff2') format('woff2'),
url('FuturaPT-ExtraBoldObl.woff') format('woff'),
url('FuturaPT-ExtraBoldObl.ttf') format('truetype');
font-weight: bold;
font-style: italic;
}
/* === Futura PT Condensed === */
/* Condensed regular */
@font-face {
font-family: 'FuturaPT-Condensed-Regular';
src: url('FuturaPTCond-Book.woff2') format('woff2'),
url('FuturaPTCond-Book.woff') format('woff'),
url('FuturaPTCond-Book.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
/* Condensed italic */
@font-face {
font-family: 'FuturaPT-Condensed-Italic';
src: url('FuturaPTCond-BookObl.woff2') format('woff2'),
url('FuturaPTCond-BookObl.woff') format('woff'),
url('FuturaPTCond-BookObl.ttf') format('truetype');
font-weight: normal;
font-style: italic;
}
/* Condensed bold italic */
@font-face {
font-family: 'FuturaPT-Condensed-BoldItalic';
src: url('FuturaPTCond-BoldObl.woff2') format('woff2'),
url('FuturaPTCond-BoldObl.woff') format('woff'),
url('FuturaPTCond-BoldObl.ttf') format('truetype');
font-weight: bold;
font-style: italic;
}
/* Condensed medium */
@font-face {
font-family: 'FuturaPT-Condensed-Medium';
src: url('FuturaPTCond-Medium.woff2') format('woff2'),
url('FuturaPTCond-Medium.woff') format('woff'),
url('FuturaPTCond-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
/* Condensed medium italic */
@font-face {
font-family: 'FuturaPT-Condensed-MediumItalic';
src: url('FuturaPTCond-MediumObl.woff2') format('woff2'),
url('FuturaPTCond-MediumObl.woff') format('woff'),
url('FuturaPTCond-MediumObl.ttf') format('truetype');
font-weight: 500;
font-style: italic;
}
/* Condensed medium bold */
@font-face {
font-family: 'FuturaPTCondensed-Bold';
src: url('FuturaPTCond-Bold.woff2') format('woff2'),
url('FuturaPTCond-Bold.woff') format('woff'),
url('FuturaPTCond-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
}
/* Condensed extra bold */
@font-face {
font-family: 'FuturaPT-Condensed-ExtraBold';
src: url('FuturaPTCond-ExtraBold.woff2') format('woff2'),
url('FuturaPTCond-ExtraBold.woff') format('woff'),
url('FuturaPTCond-ExtraBold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
}
/* Condensed extra bold italic */
@font-face {
font-family: 'FuturaPT-Condensed-ExtraBoldItalic';
src: url('FuturaPTCond-ExtraBoldObl.woff2') format('woff2'),
url('FuturaPTCond-ExtraBoldObl.woff') format('woff'),
url('FuturaPTCond-ExtraBoldObl.ttf') format('truetype');
font-weight: bold;
font-style: italic;
}
/* === CSS === */
.font-general-light {
font-family: "FuturaPT-Light";
}
.font-general-regular {
font-family: "FuturaPT-Regular";
}
.font-general-medium {
font-family: "FuturaPT-Medium";
}
.font-general-semibold {
font-family: "FuturaPT-SemiBold";
}
.font-general-bold {
font-family: "FuturaPT-Bold";
}

21
backend/config.py Normal file
View File

@ -0,0 +1,21 @@
import os
# TODO: store keys in env variables
RECAPTCHA_PUBLIC_KEY = "RECAPTCHA_PUBLIC_KEY"
RECAPTCHA_PRIVATE_KEY = "RECAPTCHA_PRIVATE_KEY-L4DsNDlza17N7dEz36"
FLASK_APIKEY = "FLASK_APIKEY"
if 'ALEX_DEBUG' in os.environ:
DEPLOY_PATH = '.'
else:
DEPLOY_PATH = '/home/c/cn52774/alex-sharoff/public_html/backend'
CONTACT_REQUEST_TEMPLATE = """
Обращение через форму обратной связи с сайта alex-sharoff.ru:
Имя: {name}
Почта: {email}
-------
{message}
"""

9
backend/index.wsgi Normal file
View File

@ -0,0 +1,9 @@
#!/home/c/cn52774/alex-sharoff/public_html/backend/env/bin/python
import sys
sys.path.append('/home/c/cn52774/alex-sharoff/public_html/backend/')
sys.path.append('/home/c/cn52774/alex-sharoff/public_html/backend/env/lib/python3.6/site-packages/')
from main import app
application = app

74
backend/main.py Normal file
View File

@ -0,0 +1,74 @@
import locale
import logging
from flask import Flask, request
from flask_cors import CORS, cross_origin
from flask_executor import Executor
from flask_wtf import FlaskForm, RecaptchaField
import telegram_send
from wtforms import StringField, EmailField
from wtforms.validators import DataRequired, Email
import config as cfg
# Set locale to UTF-8 as server's one is ANSI - this breaks cyrilic loggng
locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')
logging.basicConfig(filename=f"{cfg.DEPLOY_PATH}/log.log",
filemode='a',
format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s',
datefmt='%H:%M:%S',
level=logging.INFO)
logger = logging.getLogger()
app = Flask(__name__)
app.secret_key = cfg.FLASK_APIKEY
app.config["APPLICATION_ROOT"] = "/api"
app.config["WTF_CSRF_ENABLED"] = False
app.config["RECAPTCHA_PUBLIC_KEY"] = cfg.RECAPTCHA_PUBLIC_KEY
app.config["RECAPTCHA_PRIVATE_KEY"] = cfg.RECAPTCHA_PRIVATE_KEY
app.config['EXECUTOR_TYPE'] = 'process'
app.config['EXECUTOR_PROPAGATE_EXCEPTIONS'] = True
executor = Executor(app)
def send_telegram(message, silent=False):
telegram_send.send(messages=[message], conf=f'{cfg.DEPLOY_PATH}/telegram.conf', silent=silent)
class ContactForm(FlaskForm):
name = StringField('Username', validators=[DataRequired()])
email = EmailField('Email', validators=[DataRequired(), Email()])
message = StringField('Message')
recaptcha = RecaptchaField()
@app.route('/contact/', methods=['POST'])
def contact():
form = ContactForm(request.form)
if form.validate_on_submit():
text = cfg.CONTACT_REQUEST_TEMPLATE.format(
name=form.name.data,
email=form.email.data,
message=form.message.data
)
logger.info(f'{form.name.data} - {form.email.data}: [{form.message.data}]')
executor.submit(send_telegram, text)
return '', 200
return '', 400
@app.route('/')
def hello_world():
return 'Hello, World!'
if __name__ == '__main__':
app.run()

8
backend/telegram.conf Normal file
View File

@ -0,0 +1,8 @@
[telegram]
# alexsharoff_bot
# token = token
# chat_id = chat_id
# phzhik_dev_bot
token = token
chat_id = chat_id

View File

@ -0,0 +1,32 @@
<template>
<button
class="
font-general-medium
text-md
rounded-full
border-2
px-4
py-2
bg-white
hover:bg-background-dark
text-background-dark
hover:text-white
transition-colors duration-400
"
>
<slot/>
</button>
</template>
<script>
export default {
name: "RoundButton",
props: {
}
}
</script>
<style scoped>
</style>

83
components/SideBar.vue Normal file
View File

@ -0,0 +1,83 @@
<script>
import feather from "feather-icons";
import {mapState} from "vuex";
import SocialButton from "@/components/SocialButton.vue";
export default {
components: {SocialButton},
data() {
return {
userScrollPosition: 0,
};
},
computed: {
...mapState(["socialProfiles"]),
isScrolled() {
return this.userScrollPosition > 200;
},
},
mounted() {
window.addEventListener("scroll", this.updateScrollPosition);
feather.replace();
},
updated() {
feather.replace();
},
beforeDestroy() {
window.removeEventListener("scroll", this.updateScrollPosition);
},
methods: {
updateScrollPosition() {
this.userScrollPosition = window.scrollY;
},
backToTop() {
window.scrollTo({
top: 0,
behavior: "smooth",
});
},
},
};
</script>
<template>
<transition name="fade">
<div
v-show="isScrolled"
class="
flex
flex-col
sm:space-y-2
mr-4
sm:mr-8
xl:mr-16
mb-6
right-0
bottom-0
z-50
fixed
items-center
">
<SocialButton class="mb-4" feather-icon="chevron-up" @click.native="backToTop()"/>
<SocialButton v-for="social in socialProfiles"
:key="social.id"
:feather-icon="social.icon"
:url="social.url"
class="hidden lg:block"
/>
</div>
</transition>
</template>
<style lang="css" scoped>
</style>

View File

@ -0,0 +1,40 @@
<template>
<a :href="url"
target="_blank"
class="
transform
hover:scale-110
cursor-pointer
p-2
text-white
items-center
shadow-none
border-none
ring-none
outline-none
">
<i class="w-7 h-7" :data-feather="featherIcon"/>
</a>
</template>
<script>
import feather from "feather-icons";
export default {
name: "SocialButton",
props: ["featherIcon", "url"],
mounted() {
feather.replace();
},
updated() {
feather.replace();
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,25 @@
<script>
export default {
props: ["client"],
data: () => {
return {};
},
};
</script>
<template>
<div class="shadow-sm border border-ternary-light rounded-lg bg-white">
<a :href="client.url" class="h-full" target="_blank">
<img
:src="client.img"
:alt="client.title"
class="h-20 md:h-28 max-h-28 md:max-h-32 p-4 sm:p-4 flex-shrink-0 flex-none"
/>
</a>
</div>
</template>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,36 @@
<script>
import { mapState } from "vuex";
export default {
data: () => {
return {};
},
computed: {
...mapState(["clientsHeading", "clients"]),
},
};
</script>
<template>
<!-- About clients section -->
<div class="mt-10 sm:mt-20">
<p
class="
font-general-medium
text-2xl text-center
sm:text-3xl
text-white
"
>
{{ clientsHeading }}
</p>
<div id="clients-list" class="flex flex-row flex-wrap mt-10 sm:mt-14 gap-4 mx-4 sm:mx-12 justify-center">
<AboutClientSingle
v-for="client in clients"
:key="client.id"
:client="client"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,100 @@
<script>
export default {
data: () => {
return {
};
},
};
</script>
<template>
<div class="mt-10 sm:mt-20 bg-primary-light dark:bg-ternary-dark shadow-sm">
<!-- About me counters start -->
<div
class="
font-general-regular
container
mx-auto
py-20
block
sm:flex sm:justify-between sm:items-center
text-center
"
>
<!-- Years of experience counter -->
<div class="mb-20 sm:mb-0">
<span
class="
font-general-medium
text-4xl
font-general-bold
text-secondary-dark
dark:text-secondary-light
mb-2
"
>7+</span
>
<span class="block text-md text-ternary-dark dark:text-ternary-light"
>Years of experience</span
>
</div>
<!-- GitHub stars counter -->
<div class="mb-20 sm:mb-0">
<span
class="
font-general-medium
text-4xl
font-general-bold
text-secondary-dark
dark:text-secondary-light
mb-2
"
>2k</span
>
<span class="block text-md text-ternary-dark dark:text-ternary-light"
>Stars on GitHub</span
>
</div>
<!-- Positive feedback counter -->
<div class="mb-20 sm:mb-0">
<span
class="
font-general-medium
text-4xl
font-general-bold
text-secondary-dark
dark:text-secondary-light
mb-2
"
>32</span
>
<span class="block text-md text-ternary-dark dark:text-ternary-light"
>Positive feedback</span
>
</div>
<!-- Projects completed counter -->
<div class="mb-20 sm:mb-0">
<span
class="
font-general-medium
text-4xl
font-general-bold
text-secondary-dark
dark:text-secondary-light
mb-2
"
>77</span
>
<span class="block text-md text-ternary-dark dark:text-ternary-light"
>Projects completed</span
>
</div>
</div>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,40 @@
<script>
import { mapState } from "vuex";
export default {
data: () => {
return {
};
},
computed: {
...mapState(["aboutMe"]),
},
};
</script>
<template>
<div class="block sm:flex sm:gap-6 mt-10 sm:mt-20">
<!-- About profile image -->
<div class="md:w-2/4 mb-6">
<img src="/profile.jpg" class="rounded-lg w-96" alt="Profile pic" />
</div>
<!-- About details -->
<div class="w-full sm:w-2/4 text-left">
<p
v-for="(bio, index) in aboutMe"
:key="index"
class="
font-general-regular
clear-left
mb-4
text-white
text-xl
"
>
{{ bio }}
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,47 @@
<script>
export default {
props: ["contacts"],
data: () => {
return {
};
},
};
</script>
<template>
<div class="text-left max-w-xl px-6">
<h2
class="
font-general-medium
text-2xl text-white
mt-8
mb-8
"
>
Контакты
</h2>
<ul class="font-general-regular">
<li class="flex" v-for="contact in contacts" :key="contact.id">
<i
:data-feather="contact.icon"
class="w-5 text-gray-500 mr-4"
></i>
<a
:href="contact.url"
class="text-lg mb-4 text-white"
:class="
contact.icon === 'mail' || contact.icon === 'phone'
? 'hover:underline cursor-pointer'
: ''
"
aria-label="Website and Phone"
>
{{ contact.name }}
</a>
</li>
</ul>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,190 @@
<script>
export default {
data() {
return {
endpoint: process.env.NODE_ENV === 'production'
? '/api/contact'
: 'http://127.0.0.1:3001/contact',
labelStyle: "block text-lg text-primary-dark mb-2",
inputStyle: "w-full px-5 py-2 " +
"border border-gray-300 " +
"text-primary-dark bg-ternary-light " +
"rounded-md shadow-sm text-md " +
"focus:border-primary-dark focus:ring-0",
name: "",
email: "",
message: "",
captchaSolved: false
};
},
computed: {
formReady() {
let isNotEmpty = this.name !== '' && this.email !== ''
let isValid = this.$refs.name.checkValidity() && this.$refs.email.checkValidity()
return this.captchaSolved && isNotEmpty && isValid
}
},
async mounted() {
await this.$recaptcha.init()
},
beforeDestroy() {
this.$recaptcha.destroy()
},
methods: {
async resetCaptcha() {
await this.$recaptcha.reset()
this.captchaSolved = false
},
async onSubmit() {
// To prevent `:invalid` CSS class to be applied on page load
this.$refs.form.classList.add("submitted")
console.log(this.endpoint)
if (!this.formReady) {
return
}
try {
let formData = new FormData()
formData.append('name', this.name)
formData.append('email', this.email)
formData.append('message', this.message)
formData.append('g-recaptcha-response', await this.$recaptcha.getResponse())
fetch(this.endpoint,
{
method: "post",
mode: "cors",
body: formData,
})
.then((response) => {
if (response.ok) {
return;
}
return Promise.reject(response);
})
.then(_ => {
alert("Спасибо за обращение!")
this.resetCaptcha()
})
.catch(error => {
alert("Произошла ошибка, повторите отправку формы позднее")
console.error('Error:', error);
this.resetCaptcha()
});
} catch (error) {
console.error(error)
}
}
},
};
</script>
<template>
<div
class="
leading-loose
max-w-xl
p-7
bg-secondary-light
rounded-xl
shadow-xl
text-left
"
>
<p
class="
font-general-medium
text-primary-dark
text-2xl
mb-8
"
>
Свяжитесь со мной
</p>
<form
class="font-general-regular space-y-7"
ref="form"
novalidate
>
<div class="">
<label :class="labelStyle" for="name">Ваше имя*</label>
<input
ref="name"
v-model="name"
:class="inputStyle"
type="text"
placeholder=""
aria-label="Name"
required
/>
</div>
<div>
<label :class="labelStyle" for="email">Электронная почта*</label>
<input
ref="email"
v-model="email"
:class="inputStyle"
type="email"
pattern="^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,})+$"
placeholder=""
aria-label="Email"
required
/>
</div>
<div>
<label :class="labelStyle" for="message">Сообщение</label>
<textarea
ref="message"
:class="inputStyle"
v-model="message"
cols="14"
rows="6"
aria-label="Message"
></textarea>
</div>
<recaptcha
@error="captchaSolved = false"
@expired="captchaSolved = false"
@success="captchaSolved = true"
/>
<div>
<RoundButton
@click.native.prevent="onSubmit()"
class="px-6 inline-flex items-center"
aria-label="Send Message"
>
Отправить
</RoundButton>
</div>
</form>
</div>
</template>
<style lang="css" scoped>
form.submitted input:invalid {
@apply invalid:border-red-500;
}
</style>

View File

@ -0,0 +1,50 @@
<script>
import FilterOption from "@/components/gallery/FilterOption.vue";
export default {
components: {FilterOption},
props: {
options: {
type: Array,
default: []
},
initialOption: {
type: String
},
},
data: function () {
return {
selectedOption: this.initialOption,
}
},
watch: {
selectedOption: function () {
this.$emit('change', this.selectedOption)
}
}
};
</script>
<template>
<!-- <ul id="gallery-filter" class="flex flex-row flex-wrap text-left mt-50">-->
<ul id="gallery-filter"
class="grid grid-cols-1 sm:grid-cols-2 md:flex md:flex-wrap gap-y-2.5 gap-x-6
text-left mt-50">
<!-- class="flex-auto mr-2.5 mb-2.5 text-white font-general-regular text-xl cursor-pointer select-none"-->
<FilterOption
class="text-white font-general-light text-xl lg:text-2xl cursor-pointer select-none tracking-wide"
v-for="(option, index) in options"
:key="index"
:name="option.name"
:value="option.value"
:isActive="selectedOption === option.value"
@change="selectedOption = $event"/>
</ul>
</template>
<style lang="css" scoped>
</style>

View File

@ -0,0 +1,35 @@
<template>
<!-- <li class="flex-auto rounded-md border-2 px-1 py-1.5 mr-2.5 mb-2.5 font-general-medium text-base sm:text-lg cursor-pointer select-none"-->
<li :class="[isActive ? activeClass : inactiveClass, hoverClass]"
:data-filter="`.${value}`"
@click="$emit('change', value)"
>
{{ name }}
</li>
</template>
<script>
export default {
name: "FilterOption",
props: {
name: {type: String, required: true},
value: {type: String, required: true},
isActive: {type: Boolean, default: false},
},
data: () => ({
inactiveClass: "",
activeClass: "underline underline-offset-8",
hoverClass: "hover:underline hover:underline-offset-8",
// inactiveClass: "border-gray-700 text-gray-700",
// activeClass: "bg-secondary-dark border-secondary-dark text-white",
// hoverClass: "hover:bg-ternary-dark hover:bg-ternary-dark hover:text-white",
})
}
</script>
<style lang="css" scoped>
ul li {
transition: all .5s ease;
}
</style>

View File

@ -0,0 +1,168 @@
<template>
<section class="transition-opacity duration-500 ease-in-out opacity-0"
:class="[galleryLoading ? 'opacity-0' : 'opacity-100']">
<FilterGroup
:options="categories"
:initial-option="initialCategory"
@change="selectedCategory = $event"
v-if="filterEnabled"/>
<div id='gallery' class='columns-auto min-h-screen items-center mt-2 sm:mt-5'>
<GalleryItem
v-for='img in filteredPhotos'
:key='img.id'
:category='img.category'
:image='img.image'
:thumb-size='img.thumbSize'
/>
</div>
</section>
</template>
<script>
import lightGallery from 'lightgallery';
import lgThumbnail from 'lightgallery/plugins/thumbnail';
import lgZoom from 'lightgallery/plugins/zoom';
import lgFullScreen from 'lightgallery/plugins/fullscreen';
import ImagesLoaded from "imagesloaded/imagesloaded.js";
import {mapState} from "vuex";
import FilterGroup from "@/components/gallery/FilterGroup.vue";
import Shuffle from 'shufflejs'
// let ScrollReveal;
// if (process.browser) {
// ScrollReveal = require('scrollreveal/dist/scrollreveal')
// }
export default {
name: 'Gallery',
components: {FilterGroup},
props: {
initialCategory: {type: String},
images: {type: Array, default: []},
filterEnabled: {type: Boolean, default: false},
allCategoryEnabled: {type: Boolean, default: false},
},
created() {
this.ALL_CATEGORY = Shuffle.ALL_ITEMS;
// Object storage
this.shuffle = null
this.lg = null
},
data: function () {
return {
selectedCategory: this.initialCategory,
galleryLoading: true,
}
},
computed: {
...mapState(["categoriesNames"]),
categories() {
// Go through all images, collect unique categories keys
let categories = [...new Set(this.images.map((img) => img.category))]
if (this.allCategoryEnabled) {
categories = [this.ALL_CATEGORY, ...categories]
}
// Prepare data for GalleryFilter: category key & human-readable name
return categories.map(c => {
return {
value: c,
name: this.categoriesNames[c] ?? "UNKNOWN"
}
})
},
filteredPhotos() {
if (this.selectedCategory === this.ALL_CATEGORY || this.selectedCategory === null) {
return this.images;
} else {
return this.images.filter((photo) => photo.category === this.selectedCategory);
}
},
},
watch: {
filteredPhotos: function () {
this.$nextTick(() => {
this.refreshGallery()
})
},
},
methods: {
refreshGallery() {
// Update LightGallery elements (click ability etc)
this.lg.refresh()
// Filter Shuffle & prevent from breaking the layout
this.shuffle.resetItems()
// Jerky animation fix
setTimeout(() => {
this.shuffle.filter(this.selectedCategory);
}, 300);
},
galleryInit(element) {
this.lg = lightGallery(element, {
plugins: [lgThumbnail, lgZoom, lgFullScreen],
mode: "lg-fade", // animation mode for fullscreen images
selector: '.gallery-item',
swipeToClose: false,
speed: 400,
alignThumbnails: "left",
download: false,
fullScreen: true,
mobileSettings: {
controls: true,
showCloseIcon: true,
thumbnail: false,
closeOnTap: false,
fullScreen: true
}
});
},
shuffleInit(element) {
this.shuffle = new Shuffle(element, {
group: this.initialCategory,
itemSelector: '.gallery-item',
delimiter: ',',
speed: 500,
})
},
},
mounted() {
let $gallery = document.querySelector('#gallery')
this.galleryInit($gallery)
ImagesLoaded('#gallery', {background: true}, () => {
console.log("ImagesLoaded")
this.shuffleInit($gallery)
this.galleryLoading = false
});
}
};
</script>
<style lang='css'>
@import 'lightgallery/css/lightgallery.css';
@import 'lightgallery/css/lg-thumbnail.css';
@import 'lightgallery/css/lg-zoom.css';
@import 'lightgallery/css/lg-fullscreen.css';
</style>

View File

@ -0,0 +1,23 @@
<script>
import GalleryItemMixin from "~/components/gallery/GalleryItemMixin";
export default {
mixins: [GalleryItemMixin],
};
</script>
<template>
<a class="gallery-item w-1/2 p-0.5 sm:w-1/2 md:w-1/3 xl:w-1/4 sm:p-1"
:class="`filter-${category}`"
:data-groups="category"
:data-src="full_img_url"
>
<img class="gallery-item-thumb cursor-pointer w-full"
:width="thumbSize.w" :height="thumbSize.h"
:src="thumbnail_url"/>
</a>
</template>
<style scoped>
.gallery-item {}
</style>

View File

@ -0,0 +1,20 @@
export default {
props: {
category: {type: String, required: true},
image: {type: String, required: true},
thumbSize: {
type: Object,
required: true,
default: () => ({w: 0, h: 0})
},
},
computed: {
full_img_url: function () {
return `/gallery/${this.category}/${this.image}`
},
thumbnail_url: function () {
return `/gallery/${this.category}/thumbs/${this.image}`
},
}
}

View File

@ -0,0 +1,58 @@
<template>
<div ref="slider" class="keen-slider my-24">
<HorizontalGalleryItem
v-for="img in images"
:key="img.id"
:image="img.image"
:category="img.category"
/>
</div>
</template>
<script>
import "keen-slider/keen-slider.min.css";
import KeenSlider from "keen-slider";
import GalleryItemMixin from "~/components/gallery/GalleryItemMixin";
import HorizontalGalleryItem from "~/components/gallery/HorizontalGalleryItem.vue";
const animation = {duration: 40000, easing: (t) => t}
export default {
name: "HorizontalGallery",
components: {HorizontalGalleryItem},
props: {
images: {type: Array, default: []},
},
mixins: [GalleryItemMixin],
mounted() {
this.slider = new KeenSlider(this.$refs.slider, {
loop: true,
renderMode: "performance",
drag: false,
slides: {perView: "auto"},
created(s) {
s.moveToIdx(5, true, animation)
},
updated(s) {
s.moveToIdx(s.track.details.abs + 5, true, animation)
},
animationEnded(s) {
s.moveToIdx(s.track.details.abs + 5, true, animation)
},
});
},
beforeDestroy() {
if (this.slider) this.slider.destroy();
},
methods: {
}
}
</script>
<style lang="css" scoped>
</style>

View File

@ -0,0 +1,17 @@
<template>
<div class="keen-slider__slide">
<img class="" :src="thumbnail_url"/>
</div>
</template>
<script>
import GalleryItemMixin from "~/components/gallery/GalleryItemMixin";
export default {
name: "HorizontalGalleryItem",
mixins: [GalleryItemMixin],
}
</script>
<style lang="css" scoped>
</style>

View File

@ -0,0 +1,96 @@
<script>
import feather from "feather-icons";
export default {
data: () => {
return {};
},
mounted() {
feather.replace();
},
updated() {
feather.replace();
},
};
</script>
<template>
<section
class="
background
static
grayscale
min-h-screen
max-h-screen
md:pl-12
xl:pl-16
md:pt-20
lg:pt-28
pt-16
">
<div class="xl:w-3/4 mr-auto mx-4 my-20 text-left animate-slide-up">
<h1
class="
font-general-medium
text-5xl
sm:text-7xl
md:text-7xl
lg:text-8xl
text-left
mb-2
sm:mb-4
text-gray-300
"
>
Привет, это Саша.
</h1>
<p
class="
font-general-medium
mb-8
text-2xl
sm:text-3xl
md:text-3xl
xl:text-3xl
leading-none
text-gray-300
"
>
Интерьерный и репортажный фотограф
</p>
<NuxtLink :to="{path: '/', hash: '#contact'}">
<RoundButton class="text-xl px-6 sm:px-8">
Написать мне
</RoundButton>
</NuxtLink>
</div>
<!-- Banner right illustration -->
<!-- <div class="w-full md:w-3/5 text-right float-right">-->
<!-- <img src="/photographer.svg" alt="Photographer illustration"/>-->
<!-- </div>-->
</section>
</template>
<style lang="css" scoped>
.background {
/* TODO: grayscale the file itself to reduce file size */
/*background-image: url("/background.jpg");*/
background-image: url("/background-mirrored.jpg");
background-position: 50% 50%;
background-size: cover;
background-repeat: no-repeat;
}
</style>

View File

@ -0,0 +1,125 @@
<script>
import {mapState, mapMutations} from "vuex";
import AppNavigation from "./AppNavigation.vue";
import feather from "feather-icons";
export default {
props: ["showMobileMenu"],
components: {
AppNavigation,
},
data: () => {
return {
modal: false,
};
},
computed: {
...mapState(["socialProfiles"]),
isMobileMenuOpened() {
return this.$store.state.isMobileMenuOpened
}
},
watch: {
// Watch for route change to hide mobile menu
isMobileMenuOpened() {
feather.replace()
}
},
mounted() {
feather.replace();
},
methods: {
themeSwitcher() {
this.$colorMode.preference =
this.$colorMode.value === "light" ? "dark" : "light";
},
toggleMobileMenu() {
this.$store.commit('toggleMobileMenu')
},
toggleModal() {
if (this.modal) {
// Stop screen scrolling
document
.getElementsByTagName("html")[0]
.classList.remove("overflow-y-hidden");
this.modal = false;
} else {
document
.getElementsByTagName("html")[0]
.classList.add("overflow-y-hidden");
this.modal = true;
}
},
},
};
</script>
<template>
<nav id="nav" class="top-0 z-50 absolute w-full">
<div class="sm:w-full sm:px-14 px-4">
<!-- Header -->
<div
class="
z-10
block
sm:flex sm:justify-between sm:items-center
py-6
"
>
<!-- Header menu links and small screen hamburger menu -->
<div class="flex justify-between items-center px-6 sm:px-0">
<!-- Header logos -->
<div>
<NuxtLink to="/">
<img
src="/logo-white.png"
alt="Logo"
class="sm:w-32 w-24"
/>
</NuxtLink>
</div>
<!-- Small screen hamburger menu -->
<div class="sm:hidden">
<button
@click="toggleMobileMenu()"
type="button"
class="focus:outline-none"
aria-label="Hamburger Menu"
>
<div class="h-7 w-7 mt-1 fill-current text-white">
<div v-show="!isMobileMenuOpened"><i data-feather="menu"/></div>
<div v-show="isMobileMenuOpened"><i data-feather="x"/></div>
</div>
</button>
</div>
</div>
<!-- Header links -->
<AppNavigation
:toggleModal="toggleModal"
:modal="modal"
/>
<div class="flex flex-row">
<SocialButton v-for="social in socialProfiles"
:key="social.id"
:feather-icon="social.icon"
:url="social.url"
class="hidden md:block"
/>
</div>
</div>
</div>
</nav>
</template>

View File

@ -0,0 +1,101 @@
<script>
import {mapState} from "vuex";
export default {
props: ["toggleModal", "modal"],
data: function () {
return {
linkStyle: "block text-left text-xl " +
"text-white " +
"hover:text-accent transition-colors duration-400 " +
"sm:mx-3 md:mx-4 sm:pt-2 mb-2"
}
},
computed: {
...mapState(["socialProfiles"]),
isMobileMenuOpened() {
return this.$store.state.isMobileMenuOpened
}
},
methods: {
onMobileMenuLinkClick() {
this.$store.commit('toggleMobileMenu', false)
}
}
};
</script>
<template>
<!-- App header navigation links -->
<div
:class="isMobileMenuOpened ? 'block' : 'hidden'"
class="
font-general-regular
m-0
mt-5
p-5
py-12
backdrop-blur-lg
justify-center
items-center
animate-slide-down-fast
sm:backdrop-filter-none
sm:animate-none
sm:mt-0
sm:flex
sm:p-0
"
>
<NuxtLink
:to="{path: '/', hash: '#portfolio'}"
:class="linkStyle"
aria-label="Portfolio"
@click.native="onMobileMenuLinkClick"
>
Портфолио
</NuxtLink>
<NuxtLink
:to="{path: '/', hash: '#about'}"
:class="linkStyle"
class="
border-t-2
pt-3
sm:pt-2 sm:border-t-0
border-primary-light
"
aria-label="About Me"
@click.native="onMobileMenuLinkClick"
>
Обо мне
</NuxtLink>
<NuxtLink
:to="{path: '/', hash: '#contact'}"
:class="linkStyle"
class="
border-t-2
pt-3
sm:pt-2 sm:border-t-0
border-primary-light
"
aria-label="Contact"
@click.native="onMobileMenuLinkClick"
>
Контакты
</NuxtLink>
</div>
</template>
<style>
</style>

71
layouts/default.vue Normal file
View File

@ -0,0 +1,71 @@
<template>
<div class="bg-background-dark min-h-screen overscroll-none">
<!-- App header -->
<AppHeader/>
<!-- Render contents with transition -->
<transition name="fade" mode="out-in">
<Nuxt/>
</transition>
<!-- Go back to top when scrolled down -->
<SideBar/>
</div>
</template>
<script>
import feather from "feather-icons";
import smoothscroll from 'smoothscroll-polyfill';
if (process.browser) {
smoothscroll.polyfill();
}
import AppBanner from "@/components/shared/AppBanner.vue";
import AppHeader from "../components/shared/AppHeader.vue";
export default {
components: {AppBanner, AppHeader},
data: () => {
return {};
},
computed: {},
async mounted() {
// feather.replace();
try {
await this.$recaptcha.init()
} catch (e) {
console.error(e);
}
},
};
</script>
<style scoped>
@keyframes going {
from {
transform: translateX(0);
}
to {
transform: translateX(-10px);
opacity: 0;
}
}
@keyframes coming {
from {
transform: translateX(-10px);
opacity: 0;
}
to {
transform: translateX(0px);
opacity: 1;
}
}
</style>

66
nuxt.config.js Normal file
View File

@ -0,0 +1,66 @@
export default {
server: {
host: '0' // default: localhost
},
// Target: https://go.nuxtjs.dev/config-target
target: "static",
colorMode: {
classSuffix: "",
},
// Global page headers: https://go.nuxtjs.dev/config-head
head: {
title: "Alex Sharoff",
htmlAttrs: {
lang: "en"
},
meta: [
{ charset: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ hid: "description", name: "description", content: "" },
{ name: "format-detection", content: "telephone=no" },
],
link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.png" }],
},
// Global CSS: https://go.nuxtjs.dev/config-css
css: [
"~/assets/app.css",
"~/assets/fonts/FuturaPT/futura-pt.css"
],
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: [
// { src: '~/plugins/vue-isotope', mode: 'client' },
],
// Auto import components: https://go.nuxtjs.dev/config-components
components: true,
// Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
buildModules: [
"@nuxtjs/tailwindcss",
"@nuxtjs/color-mode",
],
// Modules: https://go.nuxtjs.dev/config-modules
modules: [
'@nuxtjs/recaptcha',
],
// Build Configuration: https://go.nuxtjs.dev/config-build
build: {
// extend (config, ctx) {
// if (ctx.isDev) {
// config.devtool = ctx.isClient ? 'source-map' : 'inline-source-map'
// }
// }
},
recaptcha: {
hideBadge: true,
siteKey: '6LeGbUElAAAAACaZos82QiUanP78WgZTrc4KCCRj',
version: 2
}
};

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "nuxtjs-tailwindcss-portfolio",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "nuxt",
"build": "nuxt build",
"start": "nuxt start",
"generate": "nuxt generate"
},
"dependencies": {
"@nuxtjs/recaptcha": "^1.1.2",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.3.4",
"core-js": "^3.15.1",
"feather-icons": "^4.28.0",
"imagesloaded": "^5.0.0",
"keen-slider": "^6.8.5",
"lightgallery": "^2.7.1",
"nuxt": "^2.15.7",
"shufflejs": "^6.1.0",
"smoothscroll-polyfill": "^0.4.4",
"uuid": "^8.3.2",
"vanilla-lazyload": "^17.8.3"
},
"devDependencies": {
"@nuxtjs/color-mode": "^2.1.1",
"@nuxtjs/tailwindcss": "^4.2.0",
"postcss": "^8.3.5"
}
}

55
pages/index.vue Normal file
View File

@ -0,0 +1,55 @@
<script>
import {mapState, mapGetters} from "vuex";
import feather from "feather-icons";
import AppBanner from "@/components/shared/AppBanner.vue";
import Gallery from "@/components/gallery/Gallery.vue";
import AboutMe from "@/components/about/AboutMe.vue";
import AboutClients from "@/components/about/AboutClients.vue";
import ContactForm from "@/components/contact/ContactForm.vue";
import ContactDetails from "@/components/contact/ContactDetails.vue";
export default {
scrollToTop: true,
components: {AppBanner, Gallery, AboutMe, AboutClients, ContactForm, ContactDetails},
computed: {
...mapState(["photos", "initialCategory", "contacts"]),
...mapGetters(["showcasePhotos"])
},
mounted() {
feather.replace();
},
updated() {
feather.replace();
},
};
</script>
<template>
<div class="">
<AppBanner/>
<div class="container mx-auto mt-20">
<Gallery id="portfolio"
:images="photos"
:initial-category="initialCategory"
:filter-enabled="true"/>
<AboutMe id="about" class=""/>
<AboutClients class=""/>
<div id="contact" class="flex flex-col-reverse md:flex-row md:py-10 md:mt-20">
<ContactForm class="w-full md:w-3/5"/>
<ContactDetails class="w-full md:w-2/5" :contacts="contacts"/>
</div>
</div>
</div>
</template>
<style scoped>
</style>

Some files were not shown because too many files have changed in this diff Show More