init
This commit is contained in:
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
NUXT_PUBLIC_API_BASE=http://localhost:3001/api
|
||||
8
frontend/app.config.ts
Normal file
8
frontend/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'indigo',
|
||||
neutral: 'zinc',
|
||||
},
|
||||
},
|
||||
});
|
||||
41
frontend/app/assets/styles/main.scss
Normal file
41
frontend/app/assets/styles/main.scss
Normal file
@@ -0,0 +1,41 @@
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(32px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease forwards;
|
||||
}
|
||||
|
||||
.section-hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(32px);
|
||||
}
|
||||
|
||||
.section-visible {
|
||||
animation: fadeInUp 0.7s ease forwards;
|
||||
}
|
||||
|
||||
.hero-gradient {
|
||||
background: radial-gradient(ellipse at 60% 40%, rgba(99, 102, 241, 0.15) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 20% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%);
|
||||
}
|
||||
72
frontend/app/components/AboutSection.vue
Normal file
72
frontend/app/components/AboutSection.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<section id="about" class="py-24 px-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="section-hidden" ref="sectionRef">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
О себе
|
||||
</h2>
|
||||
<div class="w-16 h-1 bg-indigo-500 mb-10 rounded-full" />
|
||||
<div class="grid md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<p class="text-gray-400 text-lg leading-relaxed mb-6">
|
||||
Привет! Я fullstack-разработчик с более чем 5 годами опыта создания
|
||||
масштабируемых веб-приложений. Специализируюсь на экосистеме Node.js
|
||||
и современных JavaScript-фреймворках.
|
||||
</p>
|
||||
<p class="text-gray-400 text-lg leading-relaxed">
|
||||
Люблю чистую архитектуру, хорошо задокументированный код и всё, что
|
||||
связано с DevOps-практиками. Открыт к интересным проектам и
|
||||
долгосрочному сотрудничеству.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<UCard class="bg-gray-900 border-gray-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-heroicons-map-pin" class="text-indigo-400 w-5 h-5" />
|
||||
<span class="text-gray-300">Москва, Россия</span>
|
||||
</div>
|
||||
</UCard>
|
||||
<UCard class="bg-gray-900 border-gray-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-heroicons-briefcase" class="text-indigo-400 w-5 h-5" />
|
||||
<span class="text-gray-300">5+ лет опыта</span>
|
||||
</div>
|
||||
</UCard>
|
||||
<UCard class="bg-gray-900 border-gray-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-heroicons-language" class="text-indigo-400 w-5 h-5" />
|
||||
<span class="text-gray-300">Русский, English (B2)</span>
|
||||
</div>
|
||||
</UCard>
|
||||
<UCard class="bg-gray-900 border-gray-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-heroicons-check-circle" class="text-indigo-400 w-5 h-5" />
|
||||
<span class="text-gray-300">Доступен для фриланса</span>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const sectionRef = ref<HTMLElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.remove('section-hidden');
|
||||
entry.target.classList.add('section-visible');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.15 },
|
||||
);
|
||||
if (sectionRef.value) observer.observe(sectionRef.value);
|
||||
});
|
||||
</script>
|
||||
200
frontend/app/components/ContactForm.vue
Normal file
200
frontend/app/components/ContactForm.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<section id="contact" class="py-24 px-4 bg-gray-950">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="section-hidden" ref="sectionRef">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Связаться
|
||||
</h2>
|
||||
<div class="w-16 h-1 bg-indigo-500 mb-10 rounded-full" />
|
||||
|
||||
<UCard class="bg-gray-900 border-gray-800">
|
||||
<div v-if="success" class="space-y-4">
|
||||
<UAlert
|
||||
color="success"
|
||||
variant="soft"
|
||||
icon="i-heroicons-check-circle"
|
||||
title="Сообщение отправлено!"
|
||||
description="Мы получили ваше обращение и свяжемся с вами в ближайшее время."
|
||||
/>
|
||||
<UAlert
|
||||
v-if="aiReply"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
icon="i-heroicons-sparkles"
|
||||
title="Автоответ от AI"
|
||||
:description="aiReply"
|
||||
/>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-arrow-left"
|
||||
@click="handleReset"
|
||||
>
|
||||
Отправить ещё
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
:title="error"
|
||||
/>
|
||||
|
||||
<UFormField
|
||||
label="Имя"
|
||||
required
|
||||
:error="validationErrors.name"
|
||||
>
|
||||
<UInput
|
||||
v-model="form.name"
|
||||
placeholder="Иван Иванов"
|
||||
:disabled="loading"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Телефон"
|
||||
required
|
||||
:error="validationErrors.phone"
|
||||
>
|
||||
<UInput
|
||||
v-model="form.phone"
|
||||
placeholder="+7 999 123-45-67"
|
||||
type="tel"
|
||||
:disabled="loading"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Email"
|
||||
required
|
||||
:error="validationErrors.email"
|
||||
>
|
||||
<UInput
|
||||
v-model="form.email"
|
||||
placeholder="ivan@example.com"
|
||||
type="email"
|
||||
:disabled="loading"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Комментарий">
|
||||
<UTextarea
|
||||
v-model="form.comment"
|
||||
placeholder="Расскажите о вашем проекте..."
|
||||
:disabled="loading"
|
||||
:rows="4"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="lg"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
icon="i-heroicons-paper-airplane"
|
||||
class="w-full justify-center"
|
||||
>
|
||||
{{ loading ? 'Отправляем...' : 'Отправить' }}
|
||||
</UButton>
|
||||
</form>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const sectionRef = ref<HTMLElement | null>(null);
|
||||
const { loading, error, success, aiReply, submit, reset } = useContact();
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
comment: '',
|
||||
});
|
||||
|
||||
const validationErrors = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
});
|
||||
|
||||
function validate(): boolean {
|
||||
validationErrors.name = '';
|
||||
validationErrors.phone = '';
|
||||
validationErrors.email = '';
|
||||
let valid = true;
|
||||
|
||||
if (!form.name.trim()) {
|
||||
validationErrors.name = 'Введите имя';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!form.phone.trim()) {
|
||||
validationErrors.phone = 'Введите телефон';
|
||||
valid = false;
|
||||
}
|
||||
else if (!/^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/.test(form.phone.replace(/\s/g, ''))) {
|
||||
validationErrors.phone = 'Некорректный формат телефона';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!form.email.trim()) {
|
||||
validationErrors.email = 'Введите email';
|
||||
valid = false;
|
||||
}
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
|
||||
validationErrors.email = 'Некорректный email';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!validate()) return;
|
||||
await submit({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
email: form.email.trim(),
|
||||
comment: form.comment.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
reset();
|
||||
form.name = '';
|
||||
form.phone = '';
|
||||
form.email = '';
|
||||
form.comment = '';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.remove('section-hidden');
|
||||
entry.target.classList.add('section-visible');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
if (sectionRef.value) observer.observe(sectionRef.value);
|
||||
});
|
||||
</script>
|
||||
56
frontend/app/components/HeroSection.vue
Normal file
56
frontend/app/components/HeroSection.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<section id="hero" class="hero-gradient min-h-screen flex items-center justify-center px-4 py-24">
|
||||
<div class="max-w-4xl mx-auto text-center section-hidden" ref="sectionRef">
|
||||
<div class="mb-6">
|
||||
<UBadge color="primary" variant="soft" size="lg" class="mb-4">
|
||||
Доступен для новых проектов
|
||||
</UBadge>
|
||||
</div>
|
||||
<h1 class="text-5xl md:text-7xl font-bold text-white mb-6 leading-tight">
|
||||
Алексей<br>
|
||||
<span class="text-indigo-400">Разработчик</span>
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl text-gray-400 mb-4 font-medium">
|
||||
Fullstack Developer · NestJS · Nuxt 3 · TypeScript
|
||||
</p>
|
||||
<p class="text-lg text-gray-500 max-w-2xl mx-auto mb-10">
|
||||
Создаю современные веб-приложения с чистым кодом и отличным UX.
|
||||
От MVP до production-ready решений.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<UButton
|
||||
size="xl"
|
||||
color="primary"
|
||||
icon="i-heroicons-paper-airplane"
|
||||
@click="scrollTo('#contact')"
|
||||
>
|
||||
Связаться
|
||||
</UButton>
|
||||
<UButton
|
||||
size="xl"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
icon="i-heroicons-code-bracket"
|
||||
@click="scrollTo('#projects')"
|
||||
>
|
||||
Проекты
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const sectionRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function scrollTo(selector: string) {
|
||||
document.querySelector(selector)?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (sectionRef.value) {
|
||||
sectionRef.value.classList.remove('section-hidden');
|
||||
sectionRef.value.classList.add('section-visible');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
91
frontend/app/components/ProjectsSection.vue
Normal file
91
frontend/app/components/ProjectsSection.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<section id="projects" class="py-24 px-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="section-hidden" ref="sectionRef">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Проекты
|
||||
</h2>
|
||||
<div class="w-16 h-1 bg-indigo-500 mb-10 rounded-full" />
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<UCard
|
||||
v-for="project in projects"
|
||||
:key="project.title"
|
||||
class="bg-gray-900 border-gray-800 hover:border-indigo-500 transition-colors duration-300"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="text-white font-semibold text-lg">{{ project.title }}</h3>
|
||||
<UBadge :color="project.statusColor" variant="soft" size="sm">
|
||||
{{ project.status }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm leading-relaxed">{{ project.description }}</p>
|
||||
<div class="flex flex-wrap gap-2 pt-1">
|
||||
<UBadge
|
||||
v-for="tech in project.stack"
|
||||
:key="tech"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
>
|
||||
{{ tech }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const sectionRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const projects = [
|
||||
{
|
||||
title: 'E-Commerce Platform',
|
||||
description: 'Полноценная платформа электронной коммерции с микросервисной архитектурой, real-time уведомлениями и AI-рекомендациями.',
|
||||
stack: ['NestJS', 'Nuxt 3', 'PostgreSQL', 'Redis', 'Docker'],
|
||||
status: 'Production',
|
||||
statusColor: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: 'DevOps Dashboard',
|
||||
description: 'Мониторинговый дашборд для CI/CD пайплайнов с метриками, алертами и историей деплоев.',
|
||||
stack: ['Vue 3', 'Node.js', 'InfluxDB', 'Grafana', 'K8s'],
|
||||
status: 'Production',
|
||||
statusColor: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: 'AI Content Generator',
|
||||
description: 'Инструмент генерации контента на основе OpenAI API с кастомными промптами и экспортом.',
|
||||
stack: ['Nuxt 3', 'NestJS', 'OpenAI', 'TypeScript'],
|
||||
status: 'Beta',
|
||||
statusColor: 'warning' as const,
|
||||
},
|
||||
{
|
||||
title: 'Real-Time Chat',
|
||||
description: 'Мессенджер с WebSocket, поддержкой медиафайлов, сквозным шифрованием и мобильным приложением.',
|
||||
stack: ['NestJS', 'Socket.io', 'React Native', 'MongoDB'],
|
||||
status: 'WIP',
|
||||
statusColor: 'info' as const,
|
||||
},
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.remove('section-hidden');
|
||||
entry.target.classList.add('section-visible');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
if (sectionRef.value) observer.observe(sectionRef.value);
|
||||
});
|
||||
</script>
|
||||
62
frontend/app/components/SkillsSection.vue
Normal file
62
frontend/app/components/SkillsSection.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<section id="skills" class="py-24 px-4 bg-gray-950">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="section-hidden" ref="sectionRef">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Технологии
|
||||
</h2>
|
||||
<div class="w-16 h-1 bg-indigo-500 mb-10 rounded-full" />
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
<UCard
|
||||
v-for="skill in skills"
|
||||
:key="skill.name"
|
||||
class="bg-gray-900 border-gray-800 hover:border-indigo-500 transition-colors duration-300 cursor-default"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2 py-2">
|
||||
<span class="text-2xl">{{ skill.icon }}</span>
|
||||
<span class="text-white font-medium text-sm">{{ skill.name }}</span>
|
||||
<UBadge :color="skill.color" variant="soft" size="xs">
|
||||
{{ skill.level }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const sectionRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const skills = [
|
||||
{ name: 'NestJS', icon: '🏗️', level: 'Expert', color: 'error' as const },
|
||||
{ name: 'Nuxt 3', icon: '💚', level: 'Expert', color: 'success' as const },
|
||||
{ name: 'TypeScript', icon: '🔷', level: 'Expert', color: 'info' as const },
|
||||
{ name: 'Node.js', icon: '🟩', level: 'Expert', color: 'success' as const },
|
||||
{ name: 'PostgreSQL', icon: '🐘', level: 'Advanced', color: 'info' as const },
|
||||
{ name: 'Redis', icon: '🔴', level: 'Advanced', color: 'error' as const },
|
||||
{ name: 'Docker', icon: '🐳', level: 'Advanced', color: 'sky' as const },
|
||||
{ name: 'Vue 3', icon: '💡', level: 'Expert', color: 'success' as const },
|
||||
{ name: 'React', icon: '⚛️', level: 'Advanced', color: 'info' as const },
|
||||
{ name: 'GraphQL', icon: '🔗', level: 'Intermediate', color: 'warning' as const },
|
||||
{ name: 'Kubernetes', icon: '☸️', level: 'Intermediate', color: 'secondary' as const },
|
||||
{ name: 'AWS', icon: '☁️', level: 'Intermediate', color: 'warning' as const },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.remove('section-hidden');
|
||||
entry.target.classList.add('section-visible');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
if (sectionRef.value) observer.observe(sectionRef.value);
|
||||
});
|
||||
</script>
|
||||
45
frontend/app/composables/useContact.ts
Normal file
45
frontend/app/composables/useContact.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface ContactFormData {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export function useContact() {
|
||||
const config = useRuntimeConfig();
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const success = ref(false);
|
||||
const aiReply = ref<string | null>(null);
|
||||
|
||||
async function submit(data: ContactFormData) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await $fetch<{ success: boolean; message: string; ai: { sentiment: string; category: string; autoReply: string } | null }>(
|
||||
`${config.public.apiBase}/contact`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: data,
|
||||
},
|
||||
);
|
||||
success.value = true;
|
||||
aiReply.value = res.ai?.autoReply ?? null;
|
||||
} catch (e: any) {
|
||||
if (e.status === 429)
|
||||
error.value = 'Слишком много запросов. Попробуйте через минуту.';
|
||||
else
|
||||
error.value = e.data?.message ?? 'Что-то пошло не так';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
success.value = false;
|
||||
error.value = null;
|
||||
aiReply.value = null;
|
||||
}
|
||||
|
||||
return { loading, error, success, aiReply, submit, reset };
|
||||
}
|
||||
61
frontend/app/pages/index.vue
Normal file
61
frontend/app/pages/index.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-950">
|
||||
|
||||
|
||||
<main>
|
||||
<nav class="fixed top-0 left-0 right-0 z-50 bg-gray-950/80 backdrop-blur-md border-b border-gray-800">
|
||||
<div class="max-w-4xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<span class="text-white font-bold text-lg tracking-tight">
|
||||
<span class="text-indigo-400"><</span>dev<span class="text-indigo-400">/></span>
|
||||
</span>
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<a
|
||||
v-for="link in navLinks"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
class="text-gray-400 hover:text-white transition-colors duration-200 text-sm font-medium"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
icon="i-heroicons-envelope"
|
||||
@click="scrollToContact"
|
||||
>
|
||||
Контакт
|
||||
</UButton>
|
||||
</div>
|
||||
</nav>
|
||||
</main>
|
||||
|
||||
<footer class="py-8 px-4 border-t border-gray-800 text-center text-gray-600 text-sm">
|
||||
<p>© {{ new Date().getFullYear() }} Алексей Разработчик. Все права защищены.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const navLinks = [
|
||||
{ href: '#about', label: 'О себе' },
|
||||
{ href: '#skills', label: 'Навыки' },
|
||||
{ href: '#projects', label: 'Проекты' },
|
||||
{ href: '#contact', label: 'Контакт' },
|
||||
];
|
||||
|
||||
function scrollToContact() {
|
||||
document.querySelector('#contact')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: 'Алексей — Fullstack Developer',
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Fullstack Developer специализирующийся на NestJS, Nuxt 3 и TypeScript. Открыт для новых проектов.',
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
41
frontend/assets/styles/main.scss
Normal file
41
frontend/assets/styles/main.scss
Normal file
@@ -0,0 +1,41 @@
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(32px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease forwards;
|
||||
}
|
||||
|
||||
.section-hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(32px);
|
||||
}
|
||||
|
||||
.section-visible {
|
||||
animation: fadeInUp 0.7s ease forwards;
|
||||
}
|
||||
|
||||
.hero-gradient {
|
||||
background: radial-gradient(ellipse at 60% 40%, rgba(99, 102, 241, 0.15) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 20% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%);
|
||||
}
|
||||
23
frontend/nuxt.config.ts
Normal file
23
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@nuxt/ui'],
|
||||
devServer: {
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
},
|
||||
compatibilityDate: '2024-11-01',
|
||||
devtools: { enabled: true },
|
||||
css: ['~/assets/styles/main.scss'],
|
||||
ui: {
|
||||
theme: {
|
||||
colors: ['primary', 'secondary', 'success', 'info', 'warning', 'error', 'indigo', 'sky', 'green', 'amber', 'red', 'cyan', 'pink', 'blue'],
|
||||
},
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? 'http://localhost:3001/api',
|
||||
},
|
||||
},
|
||||
colorMode: {
|
||||
preference: 'dark',
|
||||
},
|
||||
});
|
||||
20
frontend/package.json
Normal file
20
frontend/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "dev-landing-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"preview": "nuxt preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^4.9.0",
|
||||
"nuxt": "^4.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^9.0.0",
|
||||
"eslint": "^10.5.0",
|
||||
"sass": "^1.101.0"
|
||||
}
|
||||
}
|
||||
11080
frontend/pnpm-lock.yaml
generated
Normal file
11080
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
frontend/pnpm-workspace.yaml
Normal file
5
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
unrs-resolver: true
|
||||
vue-demi: true
|
||||
Reference in New Issue
Block a user