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">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router';
|
import { RouterView, RouterLink } from 'vue-router';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="container">
|
<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 />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -89,4 +93,35 @@ import { RouterView } from 'vue-router';
|
|||||||
@media (max-width: 720px){
|
@media (max-width: 720px){
|
||||||
.container{ padding: 20px; }
|
.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>
|
</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 HomePage from '@/pages/HomePage.vue'
|
||||||
|
import BuildOfTheDayPage from '@/pages/BuildOfTheDayPage.vue'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -8,6 +9,11 @@ const router = createRouter({
|
|||||||
path: '/',
|
path: '/',
|
||||||
name: 'HomePage',
|
name: 'HomePage',
|
||||||
component: HomePage
|
component: HomePage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/build-of-day',
|
||||||
|
name: 'BuildOfTheDayPage',
|
||||||
|
component: BuildOfTheDayPage
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export interface RandomizeResult {
|
|||||||
aspect?: string
|
aspect?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BuildOfDayResult {
|
||||||
|
date: string
|
||||||
|
hero: Hero
|
||||||
|
items: Item[]
|
||||||
|
skillBuild: SkillBuild
|
||||||
|
aspect?: string
|
||||||
|
}
|
||||||
|
|
||||||
type RandomizePayload = {
|
type RandomizePayload = {
|
||||||
includeSkills: boolean
|
includeSkills: boolean
|
||||||
includeAspect: boolean
|
includeAspect: boolean
|
||||||
@@ -29,7 +37,7 @@ type RandomizePayload = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PREFS_KEY = 'randomizer:prefs'
|
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', {
|
export const useRandomDataStore = defineStore('randomData', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -42,7 +50,10 @@ export const useRandomDataStore = defineStore('randomData', {
|
|||||||
includeAspect: false,
|
includeAspect: false,
|
||||||
itemsCount: 6,
|
itemsCount: 6,
|
||||||
heroId: null as number | null
|
heroId: null as number | null
|
||||||
} as RandomizePayload
|
} as RandomizePayload,
|
||||||
|
buildOfDay: null as BuildOfDayResult | null,
|
||||||
|
buildOfDayLoading: false,
|
||||||
|
buildOfDayError: null as string | null
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
@@ -107,6 +118,22 @@ export const useRandomDataStore = defineStore('randomData', {
|
|||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
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