This commit is contained in:
Maxim
2025-12-16 22:15:39 +03:00
parent 0bfbd58090
commit ccf0130bba
7 changed files with 427 additions and 3 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.git
.gitignore
*.log
.env*

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:22-alpine AS build
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build-only
# Production stage with nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

23
nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { RouterView, RouterLink } from 'vue-router';
</script>
<template>
<main class="container">
<nav class="nav">
<RouterLink to="/" class="nav-link">Randomizer</RouterLink>
<RouterLink to="/build-of-day" class="nav-link">Build of the Day</RouterLink>
</nav>
<RouterView />
</main>
</template>
@@ -89,4 +93,35 @@ import { RouterView } from 'vue-router';
@media (max-width: 720px){
.container{ padding: 20px; }
}
/* Navigation */
.nav {
display: flex;
gap: 12px;
margin-bottom: 24px;
justify-content: center;
}
.nav-link {
padding: 10px 20px;
border-radius: 10px;
text-decoration: none;
color: #e6f0fa;
font-weight: 600;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s ease;
}
.nav-link:hover {
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 12px rgba(79, 195, 247, 0.2);
}
.nav-link.router-link-exact-active {
background: linear-gradient(90deg, #b63a2b, #8b241b);
color: #f8e7c6;
border-color: rgba(182, 58, 43, 0.5);
box-shadow: 0 0 12px rgba(182, 58, 43, 0.45);
}
</style>

View File

@@ -0,0 +1,306 @@
<template>
<div class="build-of-day">
<h2>Build of the Day</h2>
<p v-if="loading" class="loading">Loading...</p>
<p v-if="error" class="error">Failed to load: {{ error }}</p>
<div v-if="build" class="result">
<div class="date-badge">{{ formattedDate }}</div>
<div class="hero-section">
<h3>Hero</h3>
<div class="hero">{{ build.hero.name }}</div>
<div class="hero-badge">{{ build.hero.primary }}</div>
</div>
<div class="build-section">
<h3>Items</h3>
<ul>
<li v-for="it in build.items" :key="it.id">{{ it.name }}</li>
</ul>
<h3>Skill Build</h3>
<div class="skill-build">
<div v-for="level in skillLevels" :key="level" class="skill-level">
<span class="level-num">{{ level }}</span>
<span class="skill-key" :class="getSkillClass(build.skillBuild[String(level)])">
{{ formatSkill(build.skillBuild[String(level)]) }}
</span>
</div>
</div>
<div v-if="build.aspect">
<h3>Aspect</h3>
<div class="aspect">{{ build.aspect }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRandomDataStore } from '@/stores/randomData'
const randomDataStore = useRandomDataStore()
const { buildOfDay: build, buildOfDayLoading: loading, buildOfDayError: error } = storeToRefs(randomDataStore)
const skillLevels = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 25]
const formattedDate = computed(() => {
if (!build.value?.date) return ''
const d = new Date(build.value.date)
return d.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
})
})
function formatSkill(skill: string | undefined): string {
if (!skill) return '-'
const map: Record<string, string> = {
q: 'Q',
w: 'W',
e: 'E',
r: 'R',
left_talent: 'L',
right_talent: 'R'
}
return map[skill] ?? skill
}
function getSkillClass(skill: string | undefined): string {
if (!skill) return ''
if (skill === 'r') return 'skill-ult'
if (skill === 'left_talent' || skill === 'right_talent') return 'skill-talent'
return 'skill-basic'
}
onMounted(() => {
randomDataStore.fetchBuildOfDay()
})
</script>
<style scoped>
:root {
--dota-red: #b63a2b;
--dota-red-glow: rgba(182, 58, 43, 0.45);
--dota-gold: #c8a86a;
--dota-gold-glow: rgba(200, 168, 106, 0.3);
--dota-blue: #4fc3f7;
--dota-blue-glow: rgba(79, 195, 247, 0.35);
--panel: rgba(10, 14, 20, 0.6);
}
/* MAIN BLOCK -------------------------------------------------- */
.build-of-day {
padding: 26px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(20, 25, 40, 0.9), rgba(8, 10, 16, 0.92)),
radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.05), transparent);
color: #e6f0fa;
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow:
0 0 18px rgba(0, 0, 0, 0.6),
0 0 32px rgba(0, 0, 0, 0.4),
inset 0 0 20px rgba(255, 255, 255, 0.02);
position: relative;
overflow: hidden;
}
/* Decorative glow line */
.build-of-day::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at top left,
rgba(255, 255, 255, 0.10),
transparent 50%);
pointer-events: none;
}
/* TITLE -------------------------------------------------- */
.build-of-day > h2 {
text-align: center;
font-weight: 800;
font-size: 1.7rem;
letter-spacing: 1px;
margin-bottom: 18px;
text-shadow: 0 0 6px var(--dota-red-glow);
color: var(--dota-gold);
}
.loading {
text-align: center;
color: var(--dota-blue);
}
.error {
color: #f87171;
text-align: center;
}
/* DATE BADGE -------------------------------------------------- */
.date-badge {
text-align: center;
font-size: 1.1rem;
font-weight: 600;
color: var(--dota-gold);
margin-bottom: 18px;
padding: 10px 20px;
background: rgba(200, 168, 106, 0.1);
border: 1px solid rgba(200, 168, 106, 0.3);
border-radius: 999px;
display: inline-block;
width: 100%;
box-sizing: border-box;
}
/* RESULT PANEL -------------------------------------------------- */
.result {
margin-top: 12px;
background: linear-gradient(180deg, rgba(15, 17, 23, 0.9), rgba(6, 8, 13, 0.9));
padding: 18px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: inset 0 0 18px rgba(0, 0, 0, 0.45);
}
.hero-section {
margin-bottom: 20px;
}
/* HERO -------------------------------------------------- */
.hero {
font-weight: 900;
font-size: 1.25rem;
margin-bottom: 8px;
color: var(--dota-blue);
text-shadow: 0 0 6px var(--dota-blue-glow);
}
.hero-badge {
display: inline-block;
padding: 8px 14px;
border-radius: 999px;
background: linear-gradient(90deg,
rgba(255, 255, 255, 0.07),
rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--dota-gold);
font-weight: 600;
}
/* ITEMS -------------------------------------------------- */
.result ul {
list-style: none;
padding: 0;
margin: 10px 0;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.result li {
background: rgba(255, 255, 255, 0.04);
padding: 8px 12px;
border-radius: 999px;
color: #f2f7ff;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow:
inset 0 -2px 0 rgba(0, 0, 0, 0.3),
0 0 10px rgba(40, 150, 255, 0.15);
transition: 0.15s ease;
}
.result li:hover {
background: rgba(255, 255, 255, 0.09);
box-shadow:
inset 0 -3px 0 rgba(0, 0, 0, 0.4),
0 0 14px rgba(40, 150, 255, 0.22);
}
/* SKILL + ASPECT -------------------------------------------------- */
h3 {
margin: 8px 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--dota-gold);
text-shadow: 0 0 4px var(--dota-gold-glow);
}
.aspect {
background: rgba(255, 255, 255, 0.04);
padding: 8px 14px;
border-radius: 10px;
color: #f2f7ff;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.06);
display: inline-block;
}
/* SKILL BUILD -------------------------------------------------- */
.skill-build {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 10px 0;
}
.skill-level {
display: flex;
flex-direction: column;
align-items: center;
min-width: 32px;
}
.level-num {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 2px;
}
.skill-key {
background: rgba(255, 255, 255, 0.08);
padding: 6px 10px;
border-radius: 6px;
font-weight: 700;
font-size: 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.skill-basic {
color: #8bc34a;
}
.skill-ult {
color: #ffd54f;
background: rgba(255, 213, 79, 0.15);
border-color: rgba(255, 213, 79, 0.3);
}
.skill-talent {
color: #4fc3f7;
background: rgba(79, 195, 247, 0.15);
border-color: rgba(79, 195, 247, 0.3);
}
/* MOBILE -------------------------------------------------- */
@media (max-width: 720px) {
.build-of-day {
padding: 18px;
}
}
</style>

View File

@@ -1,4 +1,5 @@
import HomePage from '@/pages/HomePage.vue'
import BuildOfTheDayPage from '@/pages/BuildOfTheDayPage.vue'
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
@@ -8,6 +9,11 @@ const router = createRouter({
path: '/',
name: 'HomePage',
component: HomePage
},
{
path: '/build-of-day',
name: 'BuildOfTheDayPage',
component: BuildOfTheDayPage
}
]
});

View File

@@ -21,6 +21,14 @@ export interface RandomizeResult {
aspect?: string
}
export interface BuildOfDayResult {
date: string
hero: Hero
items: Item[]
skillBuild: SkillBuild
aspect?: string
}
type RandomizePayload = {
includeSkills: boolean
includeAspect: boolean
@@ -29,7 +37,7 @@ type RandomizePayload = {
}
const PREFS_KEY = 'randomizer:prefs'
const BASE_URL = 'http://127.0.0.1:8000'
const BASE_URL = import.meta.env.VITE_API_URL || ''
export const useRandomDataStore = defineStore('randomData', {
state: () => ({
@@ -42,7 +50,10 @@ export const useRandomDataStore = defineStore('randomData', {
includeAspect: false,
itemsCount: 6,
heroId: null as number | null
} as RandomizePayload
} as RandomizePayload,
buildOfDay: null as BuildOfDayResult | null,
buildOfDayLoading: false,
buildOfDayError: null as string | null
}),
actions: {
@@ -107,6 +118,22 @@ export const useRandomDataStore = defineStore('randomData', {
} finally {
this.loading = false
}
},
async fetchBuildOfDay() {
if (this.buildOfDayLoading) return
this.buildOfDayLoading = true
this.buildOfDayError = null
try {
const { data } = await axios.get<BuildOfDayResult>(`${BASE_URL}/api/build-of-day`)
this.buildOfDay = data
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load build of the day'
this.buildOfDayError = message
} finally {
this.buildOfDayLoading = false
}
}
}
})