v2
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
.env*
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal 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
23
nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
37
src/App.vue
37
src/App.vue
@@ -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>
|
||||
|
||||
306
src/pages/BuildOfTheDayPage.vue
Normal file
306
src/pages/BuildOfTheDayPage.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user