изменения по стилям приложения

This commit is contained in:
Alexander Andreev 2026-01-05 22:57:35 +03:00
parent 8e1d026fd4
commit 5f8dc428be
14 changed files with 831 additions and 775 deletions

View File

@ -19,19 +19,4 @@ onMounted(() => {
}); });
</script> </script>
<style> <style src="../assets/css/main.css"></style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f4f5;
}
.loading-overlay {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 1.5em;
color: #555;
}
</style>

View File

@ -0,0 +1,37 @@
export const useVillageHelpers = () => {
/**
* Converts numeric coordinates to a chess-like format (e.g., 0,0 -> A7).
* The grid is 5 columns (A-E) and 7 rows (1-7).
* Rows are numbered from bottom to top, so y=6 is row '1'.
* @param x The column index (0-4).
* @param y The row index (0-6).
*/
const formatCoordinates = (x: number, y: number): string => {
const col = String.fromCharCode('A'.charCodeAt(0) + x);
const row = 7 - y;
return `${col}${row}`;
};
/**
* Finds and replaces all occurrences of numeric coordinates like (x, y)
* in a string with the chess-like format.
* @param message The message string.
*/
const formatMessageCoordinates = (message: string): string => {
if (!message) return '';
// Regex to find coordinates like (1, 2)
return message.replace(/\((\d+), (\d+)\)/g, (match, xStr, yStr) => {
const x = parseInt(xStr, 10);
const y = parseInt(yStr, 10);
if (!isNaN(x) && !isNaN(y)) {
return formatCoordinates(x, y);
}
return match; // Return original if parsing fails
});
};
return {
formatCoordinates,
formatMessageCoordinates,
};
};

View File

@ -5,7 +5,7 @@
<span>{{ user.nickname }}</span> <span>{{ user.nickname }}</span>
<span>💰 {{ displayedCoins }}</span> <span>💰 {{ displayedCoins }}</span>
<span> {{ displayedExp }}</span> <span> {{ displayedExp }}</span>
<button @click="handleLogout" class="logout-button">Logout</button> <button @click="handleLogout" class="btn btn-danger btn-sm">Выйти</button>
</div> </div>
</header> </header>
@ -94,43 +94,31 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
background-color: var(--background-color);
} }
.top-bar { .top-bar {
background-color: #5a4b3a; /* Dark earthy brown */ background-color: var(--container-bg-color);
padding: 10px 15px; padding: 10px 20px;
border-bottom: 2px solid #4a3b2a; /* Darker brown border */ border-bottom: 1px solid var(--border-color);
display: flex; display: flex;
justify-content: flex-end; /* Align user info to the right */ justify-content: flex-end;
align-items: center; align-items: center;
color: #f0ead6; /* Creamy white text */ color: var(--text-color);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
} }
.user-info-top { .user-info-top {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 15px; gap: 20px;
font-size: 0.9em; font-size: 0.95em;
} font-weight: 500;
.logout-button {
background-color: #a34a2a; /* Burnt orange/reddish brown */
color: white;
border: 1px solid #7b3b22;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
font-size: 0.8em;
transition: background-color 0.2s;
}
.logout-button:hover {
background-color: #8e3f22;
} }
.main-content { .main-content {
flex-grow: 1; flex-grow: 1;
padding-bottom: 60px; /* Space for bottom nav */ padding-bottom: 70px; /* Space for bottom nav */
} }
.bottom-nav { .bottom-nav {
@ -138,12 +126,12 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 100%; width: 100%;
background-color: #5a4b3a; /* Dark earthy brown */ background-color: var(--container-bg-color);
border-top: 2px solid #4a3b2a; /* Darker brown border */ border-top: 1px solid var(--border-color);
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
padding: 5px 0; padding: 5px 0;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.2); /* Slightly darker shadow */ box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
z-index: 1000; z-index: 1000;
} }
@ -152,22 +140,23 @@ watch(() => user.value?.exp, (newExp, oldExp) => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-decoration: none; text-decoration: none;
color: #f0ead6; /* Creamy white text */ color: var(--text-color-light);
font-size: 0.7em; font-size: 0.75em;
padding: 5px; padding: 5px;
transition: color 0.2s; transition: color 0.2s;
} }
.nav-item:hover { .nav-item:hover {
color: #ffcc00; /* Gold/yellow on hover */ color: var(--primary-color);
} }
.nav-item .icon { .nav-item .icon {
font-size: 1.5em; font-size: 1.8em;
margin-bottom: 2px; margin-bottom: 2px;
} }
.nav-item.router-link-active { .nav-item.router-link-exact-active {
color: #ffcc00; /* Gold/yellow for active link */ color: var(--primary-color);
font-weight: 600;
} }
</style> </style>

View File

@ -1,53 +1,69 @@
<template> <template>
<div class="habits-container"> <div class="page-container">
<h3>Мои Привычки</h3> <h1>Мои Привычки</h1>
<!-- Create Habit Form --> <!-- Create Habit Form -->
<form @submit.prevent="createHabit" class="create-habit-form"> <div class="form-container">
<h4>Создать новую привычку</h4> <h2>Создать новую привычку</h2>
<div v-if="error" class="error-message">{{ error }}</div> <form @submit.prevent="createHabit">
<input v-model="newHabitName" type="text" placeholder="Например, читать 15 минут" required /> <div v-if="error" class="error-message">{{ error }}</div>
<div class="days-selector">
<label v-for="day in dayOptions" :key="day.value" class="day-label"> <div class="form-group">
<input type="checkbox" :value="day.name" v-model="newHabitDays" /> <label for="newHabitName" class="form-label">Название привычки</label>
<span>{{ day.name }}</span> <input id="newHabitName" v-model="newHabitName" type="text" placeholder="Например, читать 15 минут" class="form-control" required />
</label> </div>
</div>
<button type="submit" :disabled="loading.create"> <div class="form-group">
{{ loading.create ? 'Добавляем...' : 'Добавить Привычку' }} <label class="form-label">Дни недели</label>
</button> <div class="days-selector">
</form> <label v-for="day in dayOptions" :key="day.value" class="day-label">
<input type="checkbox" :value="day.name" v-model="newHabitDays" />
<span>{{ day.name }}</span>
</label>
</div>
</div>
<button type="submit" :disabled="loading.create" class="btn btn-primary">
{{ loading.create ? 'Добавляем...' : 'Добавить Привычку' }}
</button>
</form>
</div>
<!-- Habits List --> <!-- Habits List -->
<div class="habits-list"> <div class="habits-list">
<h2>Список привычек</h2>
<p v-if="loading.fetch">Загрузка привычек...</p> <p v-if="loading.fetch">Загрузка привычек...</p>
<div v-for="habit in habits" :key="habit.id" class="habit-card"> <div v-for="habit in habits" :key="habit.id" class="habit-card">
<!-- Viewing Mode --> <!-- Viewing Mode -->
<div v-if="editingHabitId !== habit.id" class="habit-view"> <div v-if="editingHabitId !== habit.id">
<div class="habit-info"> <div class="habit-info">
<h4>{{ habit.name }}</h4> <h3>{{ habit.name }}</h3>
<div class="habit-days"> <div class="habit-days">
<span v-for="day in habit.daysOfWeek" :key="day" class="day-chip">{{ dayMap[day] }}</span> <span v-for="day in habit.daysOfWeek" :key="day" class="day-chip">{{ dayMap[day] }}</span>
</div> </div>
</div> </div>
<div class="habit-actions"> <div class="habit-actions">
<button @click="startEditing(habit)" class="edit-btn">Редактировать</button> <button @click="startEditing(habit)" class="btn btn-secondary btn-sm">Редактировать</button>
<button @click="promptForDelete(habit.id)" :disabled="loading.delete" class="delete-btn">Удалить</button> <button @click="promptForDelete(habit.id)" :disabled="loading.delete" class="btn btn-danger btn-sm">Удалить</button>
</div> </div>
</div> </div>
<!-- Editing Mode --> <!-- Editing Mode -->
<form v-else @submit.prevent="saveHabit(habit.id)" class="habit-edit-form"> <form v-else @submit.prevent="saveHabit(habit.id)" class="habit-edit-form">
<input v-model="editHabitName" type="text" required /> <div class="form-group">
<div class="days-selector edit-days"> <input v-model="editHabitName" type="text" class="form-control" required />
<label v-for="day in dayOptions" :key="day.value" class="day-label"> </div>
<input type="checkbox" :value="day.name" v-model="editHabitDays" /> <div class="form-group">
<span>{{ day.name }}</span> <div class="days-selector edit-days">
</label> <label v-for="day in dayOptions" :key="day.value" class="day-label">
<input type="checkbox" :value="day.name" v-model="editHabitDays" />
<span>{{ day.name }}</span>
</label>
</div>
</div> </div>
<div class="edit-actions"> <div class="edit-actions">
<button type="submit" :disabled="loading.edit" class="save-btn">Сохранить</button> <button type="submit" :disabled="loading.edit" class="btn btn-primary btn-sm">Сохранить</button>
<button type="button" @click="cancelEditing" class="cancel-btn">Отмена</button> <button type="button" @click="cancelEditing" class="btn btn-secondary btn-sm">Отмена</button>
</div> </div>
</form> </form>
</div> </div>
@ -243,42 +259,20 @@ onMounted(fetchHabits);
</script> </script>
<style scoped> <style scoped>
.habits-container { .form-container {
max-width: 600px; background-color: var(--container-bg-color);
margin: 0 auto; padding: 24px;
padding-bottom: 40px;
}
h3 {
text-align: center;
margin-bottom: 20px;
}
/* Create Form */
.create-habit-form {
background-color: #fff;
padding: 20px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05); box-shadow: 0 2px 4px rgba(0,0,0,0.05);
margin-bottom: 30px; margin-bottom: 32px;
} border: 1px solid var(--border-color);
.create-habit-form h4 {
margin-top: 0;
text-align: center;
}
.create-habit-form input[type="text"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 15px;
} }
.days-selector { .days-selector {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
gap: 10px;
margin-bottom: 15px; margin-bottom: 15px;
} }
@ -292,117 +286,108 @@ h3 {
} }
.day-label span { .day-label span {
display: inline-block; display: flex;
width: 35px; justify-content: center;
line-height: 35px; align-items: center;
border: 1px solid #ccc; width: 40px;
height: 40px;
border: 1px solid var(--border-color);
border-radius: 50%; border-radius: 50%;
font-size: 0.9em; font-size: 0.9em;
transition: all 0.2s ease-in-out;
} }
.day-label input:checked + span { .day-label input:checked + span {
background-color: #81a1c1; background-color: var(--primary-color);
color: white; color: white;
border-color: #81a1c1; border-color: var(--primary-color);
} }
.create-habit-form button { .form-container button {
width: 100%; width: 100%;
padding: 10px;
background-color: #5e81ac;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
} }
/* Habits List */ /* Habits List */
.habits-list { .habits-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 15px; gap: 16px;
} }
.habit-card { .habit-card {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 15px; padding: 20px;
background-color: #fff; background-color: var(--container-bg-color);
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-color);
box-shadow: 0 2px 4px rgba(0,0,0,0.05); box-shadow: 0 2px 4px rgba(0,0,0,0.05);
} }
.habit-view { .habit-card > div {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
} }
.habit-info h4 { .habit-info h3 {
margin: 0 0 10px 0; margin: 0 0 10px 0;
font-size: 1.15rem;
} }
.habit-days { .habit-days {
display: flex; display: flex;
gap: 5px; flex-wrap: wrap;
gap: 6px;
} }
.day-chip { .day-chip {
background-color: #eceff4; background-color: #e5e7eb;
color: #4c566a; color: #4b5563;
padding: 2px 6px; padding: 4px 10px;
border-radius: 10px; border-radius: 16px;
font-size: 0.8em; font-size: 0.8em;
font-weight: 500;
} }
.error-message { .error-message {
color: #bf616a; color: var(--danger-color);
background-color: #fbe2e5; background-color: #fee2e2;
padding: 10px; padding: 1rem;
border-radius: 4px; border-radius: 0.375rem;
margin-bottom: 15px; margin-bottom: 1rem;
text-align: center; text-align: center;
} }
/* Actions */ .habit-actions {
.habit-actions button { display: flex;
padding: 5px 10px; gap: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-left: 10px;
} }
.edit-btn { background-color: #d8dee9; color: #4c566a; }
.delete-btn { background-color: #bf616a; color: #fff; }
/* Edit Form */ /* Edit Form Specific Styles */
.habit-edit-form { .habit-edit-form {
display: flex;
flex-direction: column;
width: 100%; width: 100%;
gap: 16px;
} }
.habit-edit-form input[type="text"] {
width: 100%; .habit-edit-form .form-group {
padding: 8px; margin-bottom: 0;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 10px;
} }
.edit-days {
margin-bottom: 10px; .habit-edit-form .days-selector {
justify-content: flex-start;
margin-bottom: 0;
} }
.edit-actions { .edit-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
justify-content: flex-end; justify-content: flex-end;
} }
.edit-actions button {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.save-btn { background-color: #a3be8c; color: #fff; }
.cancel-btn { background-color: #eceff4; color: #4c566a; }
</style> </style>

View File

@ -1,9 +1,9 @@
<template> <template>
<div class="home-page"> <div class="page-container">
<div v-if="isAuthenticated && user" class="dashboard-content"> <div v-if="isAuthenticated && user" class="dashboard-content">
<h1>Ваши цели на сегодня</h1> <h1>Ваши цели на сегодня</h1>
<p>Цели обновляются раз в сутки. Получаемые бонусы усиливаются, если посещать сайт ежедневно.</p> <p class="text-color-light">Цели обновляются раз в сутки. Получаемые бонусы усиливаются, если посещать сайт ежедневно.</p>
<div class="streak-section"> <div class="streak-section">
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak === 1 }"> <div class="streak-card" :class="{ 'active-streak': user.dailyStreak === 1 }">
@ -21,6 +21,7 @@
</div> </div>
<div class="habits-section"> <div class="habits-section">
<h2>Привычки</h2>
<div v-if="habitsPending">Loading habits...</div> <div v-if="habitsPending">Loading habits...</div>
<div v-else-if="habitsError">Could not load habits.</div> <div v-else-if="habitsError">Could not load habits.</div>
<div v-else-if="habits && habits.length > 0"> <div v-else-if="habits && habits.length > 0">
@ -33,7 +34,7 @@
<div class="habit-action"> <div class="habit-action">
<div v-if="isScheduledForToday(habit)"> <div v-if="isScheduledForToday(habit)">
<span v-if="isCompleted(habit, today)" class="completed-text">Выполнено!</span> <span v-if="isCompleted(habit, today)" class="completed-text">Выполнено!</span>
<button v-else @click="completeHabit(habit.id, $event)" :disabled="isSubmittingHabit"> <button v-else @click="completeHabit(habit.id, $event)" :disabled="isSubmittingHabit" class="btn btn-primary btn-sm">
Выполнить Выполнить
</button> </button>
</div> </div>
@ -52,17 +53,17 @@
</div> </div>
</div> </div>
<div v-else> <div v-else>
<p>You have no habits yet. Go to the <NuxtLink to="/habits">My Habits</NuxtLink> page to create one.</p> <p>У вас еще нет привычек. Перейдите на страницу <NuxtLink to="/habits">Мои привычки</NuxtLink>, чтобы создать их.</p>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="welcome-content"> <div v-else class="welcome-content">
<h1>Добро пожаловать в SmurfHabits!</h1> <h1>Добро пожаловать в SmurfHabits!</h1>
<p>Отслеживайте свои привычки и развивайте свою деревню.</p> <p class="text-color-light">Отслеживайте свои привычки и развивайте свою деревню.</p>
<div class="auth-buttons"> <div class="auth-buttons">
<NuxtLink to="/login" class="button primary">Войти</NuxtLink> <NuxtLink to="/login" class="btn btn-primary">Войти</NuxtLink>
<NuxtLink to="/register" class="button secondary">Зарегистрироваться</NuxtLink> <NuxtLink to="/register" class="btn btn-secondary">Зарегистрироваться</NuxtLink>
</div> </div>
</div> </div>
</div> </div>
@ -219,185 +220,71 @@ const completeHabit = async (habitId, event) => {
</script> </script>
<style scoped> <style scoped>
.home-page { /* Scoped styles are kept for component-specific adjustments and animations */
padding: 40px; .dashboard-content, .welcome-content {
text-align: center; text-align: center;
} }
.welcome-content {
padding: 40px 0;
}
.streak-section { .streak-section {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 10px; /* Reduced gap */ gap: 16px;
margin-top: 20px; /* Added margin-top to separate from paragraph */ margin: 24px 0 32px 0;
margin-bottom: 30px; /* Slightly reduced margin-bottom */
} }
.streak-card { .streak-card {
background: #f8f9fa; background: #f8f9fa;
border: 2px solid #e9ecef; border: 2px solid var(--border-color);
border-radius: 10px; /* Slightly reduced border-radius */ border-radius: 12px;
padding: 10px 15px; /* Reduced padding */ padding: 16px 20px;
width: 100px; /* Reduced width */ width: 110px;
transition: all 0.3s ease; transition: all 0.3s ease;
font-size: 0.9em; /* Reduced base font size */
} }
.streak-card h2 { .streak-card h2 {
margin: 0 0 5px 0; /* Reduced margin */ margin: 0 0 5px 0;
font-size: 1.8em; /* Reduced font size */ font-size: 2em;
color: #adb5bd; color: var(--text-color-light);
} }
.streak-card p { .streak-card p {
margin: 0; margin: 0;
font-size: 0.8em; /* Reduced font size */ font-size: 0.9em;
color: #6c757d; color: var(--text-color-light);
} }
.active-streak { .active-streak {
border-color: #81a1c1; border-color: var(--primary-color);
background-color: #eceff4; background-color: #f0f5ff;
box-shadow: 0 4px 8px rgba(0,0,0,0.1); /* Reduced shadow */ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-3px); /* Reduced transform */ transform: translateY(-4px);
} }
.active-streak h2 { .active-streak h2 {
color: #4c566a; color: var(--primary-color);
} }
.active-streak p { .active-streak p {
color: #3b4252; color: var(--text-color);
font-weight: bold; font-weight: 600;
} }
.habits-section { .habits-section {
margin-top: 40px;
margin-bottom: 40px;
}
.habit-card {
background: #fff;
border-radius: 8px;
padding: 20px;
margin: 20px auto;
max-width: 800px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.history-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
margin: 20px 0;
}
.day-cell {
aspect-ratio: 1 / 1;
border: 1px solid #e2e8f0;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f8fafc;
}
.day-cell.completed {
background-color: #4ade80;
color: white;
border-color: #4ade80;
}
.day-cell.missed-day {
background-color: #feecf0;
}
.day-cell.scheduled-day {
border-width: 2px;
border-color: #81a1c1;
}
.future-day .day-label {
color: #adb5bd;
}
.day-cell.today-highlight .day-label {
text-decoration: underline;
font-weight: bold;
}
.day-label {
font-size: 0.9em;
}
.welcome-content {
margin-top: 50px;
}
.welcome-content h1 {
font-size: 2.5em;
margin-bottom: 20px;
color: #333;
}
.welcome-content p {
font-size: 1.2em;
color: #555;
margin-bottom: 40px;
}
.auth-buttons {
display: flex;
justify-content: center;
gap: 20px;
}
.button {
display: inline-block;
padding: 12px 25px;
border-radius: 8px;
text-decoration: none;
font-weight: bold;
font-size: 1.1em;
transition: background-color 0.3s ease;
border: 1px solid transparent;
}
.button.primary {
background-color: #007bff;
color: white;
}
.button.primary:hover {
background-color: #0056b3;
}
.button.secondary {
background-color: #6c757d;
color: white;
}
.button.secondary:hover {
background-color: #5a6268;
}
.links {
display: flex;
justify-content: center;
gap: 20px;
margin: 40px 0; margin: 40px 0;
} }
.links a.button {
background-color: #e9ecef;
color: #333;
}
.links a.button:hover {
background-color: #dee2e6;
}
/* New Habit Card Styles */
.habit-card { .habit-card {
background: var(--container-bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 24px;
margin: 24px auto;
max-width: 800px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
@ -415,31 +302,78 @@ const completeHabit = async (habitId, event) => {
.habit-details h3 { .habit-details h3 {
margin: 0; margin: 0;
font-size: 1.3rem;
} }
.habit-schedule { .habit-schedule {
margin: 5px 0 0 0; margin: 5px 0 0 0;
font-size: 0.9em; font-size: 0.9em;
color: #6c757d; color: var(--text-color-light);
}
.habit-action button {
padding: 8px 16px;
background-color: #5e81ac;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.habit-action button:hover {
background-color: #4c566a;
} }
.completed-text { .completed-text {
font-weight: bold; font-weight: bold;
color: #28a745; color: #16a34a; /* A nice green */
}
.history-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin: 20px 0;
}
.day-cell {
aspect-ratio: 1 / 1;
border: 1px solid var(--border-color);
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f8fafc;
position: relative;
}
.day-cell.completed {
background-color: #4ade80;
color: white;
border-color: #4ade80;
}
.day-cell.missed-day {
background-color: #fee2e2;
}
.day-cell.scheduled-day {
border-width: 2px;
border-color: var(--primary-color);
}
.future-day .day-label {
color: #cbd5e1;
}
.day-cell.today-highlight::after {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--primary-color);
}
.day-label {
font-size: 0.85em;
}
.auth-buttons {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 32px;
} }
/* Confetti Animation */ /* Confetti Animation */
@ -451,6 +385,7 @@ const completeHabit = async (habitId, event) => {
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;
z-index: 10;
} }
.confetti-particle { .confetti-particle {
@ -490,5 +425,4 @@ const completeHabit = async (habitId, event) => {
.confetti-particle:nth-child(13) { background-color: #ebcb8b; --x-end: 40px; animation-delay: 0.18s; } .confetti-particle:nth-child(13) { background-color: #ebcb8b; --x-end: 40px; animation-delay: 0.18s; }
.confetti-particle:nth-child(14) { background-color: #81a1c1; --x-end: -40px; animation-delay: 0.22s; } .confetti-particle:nth-child(14) { background-color: #81a1c1; --x-end: -40px; animation-delay: 0.22s; }
.confetti-particle:nth-child(15) { background-color: #b48ead; --x-end: 0px; animation-delay: 0.28s; } .confetti-particle:nth-child(15) { background-color: #b48ead; --x-end: 0px; animation-delay: 0.28s; }
</style> </style>

View File

@ -1,22 +1,32 @@
<template> <template>
<div class="leaderboard-container"> <div class="page-container">
<h3>Monthly Leaderboard</h3> <h1>Monthly Leaderboard</h1>
<div v-if="pending" class="loading">Loading leaderboard...</div> <div v-if="pending" class="loading">Loading leaderboard...</div>
<div v-else-if="error" class="error-container"> <div v-else-if="error" class="error-container">
<p>An error occurred while fetching the leaderboard. Please try again.</p> <p>An error occurred while fetching the leaderboard. Please try again.</p>
</div> </div>
<ul v-else class="leaderboard-list"> <div v-else class="table-container">
<li <table class="table table-striped table-hover">
v-for="entry in leaderboard" <thead>
:key="entry.rank + entry.nickname" <tr>
class="leaderboard-item" <th>Rank</th>
:class="{ 'self': currentUser && currentUser.nickname === entry.nickname }" <th>Nickname</th>
> <th>EXP</th>
<span class="rank">{{ entry.rank }}.</span> </tr>
<span class="name">{{ entry.nickname }}</span> </thead>
<span class="exp">{{ entry.exp }} EXP</span> <tbody>
</li> <tr
</ul> v-for="entry in leaderboard"
:key="entry.rank + entry.nickname"
:class="{ 'current-user-row': currentUser && currentUser.nickname === entry.nickname }"
>
<td>{{ entry.rank }}</td>
<td>{{ entry.nickname }}</td>
<td>{{ entry.exp }}</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</template> </template>
@ -35,49 +45,16 @@ const leaderboard = computed(() => data.value?.leaderboard || []);
</script> </script>
<style scoped> <style scoped>
.leaderboard-container { .table-container {
max-width: 600px; overflow-x: auto;
margin: 0 auto; }
.current-user-row > * {
background-color: #e0e7ff; /* A light blue/indigo for highlighting */
font-weight: 600;
color: var(--primary-color-hover);
} }
h3 { .table-hover > tbody > tr.current-user-row:hover > * {
text-align: center; background-color: #c7d2fe; /* A slightly darker shade for hover */
margin-bottom: 20px;
}
.leaderboard-list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.leaderboard-item {
display: flex;
align-items: center;
padding: 15px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.leaderboard-item.self {
background-color: #d8e1e9;
border: 1px solid #81a1c1;
}
.rank {
font-weight: bold;
width: 40px;
}
.name {
flex-grow: 1;
}
.exp {
font-weight: bold;
color: #4c566a;
} }
</style> </style>

View File

@ -1,25 +1,25 @@
<template> <template>
<div class="auth-page"> <div class="auth-container">
<div class="auth-container"> <div class="page-container auth-form">
<h1>Login</h1> <h1>Вход</h1>
<form @submit.prevent="handleLogin"> <form @submit.prevent="handleLogin">
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email" class="form-label">Email</label>
<input type="email" id="email" v-model="email" required /> <input type="email" id="email" v-model="email" class="form-control" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password" class="form-label">Пароль</label>
<input type="password" id="password" v-model="password" required /> <input type="password" id="password" v-model="password" class="form-control" required />
</div> </div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" :disabled="loading"> <button type="submit" :disabled="loading" class="btn btn-primary">
{{ loading ? 'Logging in...' : 'Login' }} {{ loading ? 'Входим...' : 'Войти' }}
</button> </button>
</form> </form>
<div class="switch-link"> <div class="switch-link">
<p> <p>
Don't have an account? Нет аккаунта?
<NuxtLink to="/register">Register here</NuxtLink> <NuxtLink to="/register">Зарегистрироваться</NuxtLink>
</p> </p>
</div> </div>
</div> </div>
@ -54,59 +54,26 @@ definePageMeta({
</script> </script>
<style scoped> <style scoped>
.auth-page { .auth-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh; min-height: 100vh;
background-color: #f0f2f5;
}
.auth-container {
width: 100%; width: 100%;
max-width: 400px;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
h1 { .auth-form {
text-align: center;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 8px;
}
input {
width: 100%; width: 100%;
padding: 10px; max-width: 420px;
border: 1px solid #ccc;
border-radius: 4px;
} }
button { .auth-form button {
width: 100%; width: 100%;
padding: 12px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
button:hover:not(:disabled) {
background-color: #0056b3;
} }
.error-message { .error-message {
color: red; color: var(--danger-color);
margin-bottom: 16px; background-color: #fee2e2;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
text-align: center; text-align: center;
} }
.switch-link { .switch-link {

View File

@ -1,30 +1,30 @@
<template> <template>
<div class="auth-page"> <div class="auth-container">
<div class="auth-container"> <div class="page-container auth-form">
<h1>Register</h1> <h1>Регистрация</h1>
<form @submit.prevent="handleRegister"> <form @submit.prevent="handleRegister">
<div class="form-group"> <div class="form-group">
<label for="nickname">Nickname</label> <label for="nickname" class="form-label">Никнейм</label>
<input type="text" id="nickname" v-model="nickname" required /> <input type="text" id="nickname" v-model="nickname" class="form-control" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email" class="form-label">Email</label>
<input type="email" id="email" v-model="email" required /> <input type="email" id="email" v-model="email" class="form-control" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password (min 8 characters)</label> <label for="password" class="form-label">Пароль (минимум 8 символов)</label>
<input type="password" id="password" v-model="password" required /> <input type="password" id="password" v-model="password" class="form-control" required />
</div> </div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
<div v-if="successMessage" class="success-message">{{ successMessage }}</div> <div v-if="successMessage" class="success-message">{{ successMessage }}</div>
<button type="submit" :disabled="loading"> <button type="submit" :disabled="loading" class="btn btn-primary">
{{ loading ? 'Registering...' : 'Register' }} {{ loading ? 'Регистрируем...' : 'Зарегистрироваться' }}
</button> </button>
</form> </form>
<div class="switch-link"> <div class="switch-link">
<p> <p>
Already have an account? Уже есть аккаунт?
<NuxtLink to="/login">Login here</NuxtLink> <NuxtLink to="/login">Войти</NuxtLink>
</p> </p>
</div> </div>
</div> </div>
@ -55,7 +55,7 @@ const handleRegister = async () => {
password: password.value, password: password.value,
}, },
}); });
successMessage.value = 'Registration successful! Please log in.'; successMessage.value = 'Регистрация прошла успешно! Пожалуйста, войдите в систему.';
setTimeout(() => { setTimeout(() => {
navigateTo('/login'); navigateTo('/login');
}, 2000); }, 2000);
@ -72,64 +72,34 @@ definePageMeta({
</script> </script>
<style scoped> <style scoped>
.auth-page { .auth-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh; min-height: 100vh;
background-color: #f0f2f5;
}
.auth-container {
width: 100%; width: 100%;
max-width: 400px;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
h1 { .auth-form {
text-align: center;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 8px;
}
input {
width: 100%; width: 100%;
padding: 10px; max-width: 420px;
border: 1px solid #ccc;
border-radius: 4px;
} }
button { .auth-form button {
width: 100%; width: 100%;
padding: 12px;
border: none;
border-radius: 4px;
background-color: #28a745;
color: white;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
button:hover:not(:disabled) {
background-color: #218838;
} }
.error-message { .error-message {
color: red; color: var(--danger-color);
margin-bottom: 16px; background-color: #fee2e2;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
text-align: center; text-align: center;
} }
.success-message { .success-message {
color: green; color: #16a34a;
margin-bottom: 16px; background-color: #dcfce7;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
text-align: center; text-align: center;
} }
.switch-link { .switch-link {

View File

@ -1,25 +1,37 @@
<template> <template>
<div class="village-page"> <div class="page-container village-page-layout">
<h1>My Village</h1> <h1>Моя деревня</h1>
<div v-if="pending" class="loading">Loading your village...</div> <div v-if="pending" class="loading">Загрузка вашей деревни...</div>
<div v-else-if="error" class="error-container"> <div v-else-if="error" class="error-container">
<p v-if="error.statusCode === 401">Please log in to view your village.</p> <p v-if="error.statusCode === 401">Пожалуйста, войдите, чтобы увидеть свою деревню.</p>
<p v-else>An error occurred while fetching your village data. Please try again.</p> <p v-else>Произошла ошибка при загрузке данных о деревне. Пожалуйста, попробуйте снова.</p>
</div> </div>
<div v-else-if="villageData" class="village-container"> <div v-else-if="villageData">
<div class="village-grid-wrapper"> <div class="village-container">
<div class="village-grid"> <div class="village-grid-wrapper"> <!-- This will be the main grid container with labels -->
<div <div class="empty-corner"></div> <!-- Top-left empty cell -->
v-for="tile in villageData.tiles"
:key="tile.id" <div class="col-labels">
class="tile" <div class="col-label" v-for="colLabel in ['A', 'B', 'C', 'D', 'E']" :key="colLabel">{{ colLabel }}</div>
:class="[tileClasses(tile), { selected: selectedTile && selectedTile.id === tile.id }]" </div>
@click="selectTile(tile)"
> <div class="row-labels">
<span class="tile-content">{{ getTileEmoji(tile) }}</span> <div class="row-label" v-for="rowLabel in ['7', '6', '5', '4', '3', '2', '1']" :key="rowLabel">{{ rowLabel }}</div>
</div>
<div class="village-grid">
<div
v-for="tile in villageData.tiles"
:key="tile.id"
class="tile"
:class="[tileClasses(tile), { selected: selectedTile && selectedTile.id === tile.id }]"
@click="selectTile(tile)"
>
<span class="tile-content">{{ getTileEmoji(tile) }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -50,7 +62,7 @@
<div class="cost"> <div class="cost">
{{ action.cost }} монет {{ action.cost }} монет
</div> </div>
<button :disabled="!action.isEnabled || isSubmitting" @click="handleActionClick(action)"> <button :disabled="!action.isEnabled || isSubmitting" @click="handleActionClick(action)" class="btn btn-primary btn-sm">
{{ getActionLabel(action) }} {{ getActionLabel(action) }}
</button> </button>
</div> </div>
@ -63,39 +75,44 @@
</div> </div>
</div> </div>
<button @click="selectedTile = null" class="close-overlay-button">Закрыть</button> <button @click="selectedTile = null" class="btn btn-secondary close-overlay-button">Закрыть</button>
</div>
</div>
<div class="bottom-content">
<!-- Admin Panel -->
<div v-if="villageData?.user?.id === 1" class="admin-panel">
<h3>Admin Tools</h3>
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
<button @click="handleAddCoins" :disabled="isSubmittingAdminAction">Add 1000 Coins</button>
</div>
<!-- Event Log -->
<div v-if="villageEvents?.length" class="event-log-container">
<h2>Журнал событий</h2>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Дата</th>
<th>Событие</th>
<th>Монеты</th>
<th>EXP</th>
</tr>
</thead>
<tbody>
<tr v-for="event in villageEvents" :key="event.id">
<td>{{ new Date(event.createdAt).toLocaleString() }}</td>
<td class="event-message">{{ formatMessageCoordinates(event.message) }}</td>
<td>{{ event.coins }}</td>
<td>{{ event.exp }}</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div>
<!-- Admin Panel -->
<div v-if="villageData?.user?.id === 1" class="admin-panel">
<h3>Admin Tools</h3>
<button @click="handleResetVillage" :disabled="isSubmittingAdminAction">Reset Village</button>
<button @click="handleCompleteClearing" :disabled="isSubmittingAdminAction">Complete All Clearing</button>
<button @click="handleTriggerTick" :disabled="isSubmittingAdminAction">Trigger Next Tick</button>
</div>
<!-- Event Log -->
<div v-if="villageEvents?.length" class="event-log-container">
<h4>Activity Log</h4>
<table class="event-log-table">
<thead>
<tr>
<th>Date</th>
<th>Event</th>
<th>Coins</th>
<th>EXP</th>
</tr>
</thead>
<tbody>
<tr v-for="event in villageEvents" :key="event.id">
<td>{{ new Date(event.createdAt).toLocaleString() }}</td>
<td>{{ event.message }}</td>
<td>{{ event.coins }}</td>
<td>{{ event.exp }}</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</template> </template>
@ -103,7 +120,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useAuth } from '~/composables/useAuth'; // Import useAuth import { useAuth } from '~/composables/useAuth'; // Import useAuth
import { useVillageHelpers } from '~/composables/useVillageHelpers';
const { formatCoordinates, formatMessageCoordinates } = useVillageHelpers();
const { user, isAuthenticated, logout, updateUser } = useAuth(); // Destructure updateUser const { user, isAuthenticated, logout, updateUser } = useAuth(); // Destructure updateUser
const { data: villageData, pending, error, refresh: refreshVillageData } = await useFetch('/api/village', { const { data: villageData, pending, error, refresh: refreshVillageData } = await useFetch('/api/village', {
@ -189,14 +208,14 @@ const getTileTitle = (tile) => {
'QUARRY': 'Каменоломня', 'QUARRY': 'Каменоломня',
'WELL': 'Колодец', 'WELL': 'Колодец',
}; };
return `${buildingMap[tile.object.type] || 'Неизвестное строение'} (${tile.x}, ${tile.y})`; return `${buildingMap[tile.object.type] || 'Неизвестное строение'} ${formatCoordinates(tile.x, tile.y)}`;
} }
const terrainMap = { const terrainMap = {
'BLOCKED_TREE': 'Лесной участок', 'BLOCKED_TREE': 'Лесной участок',
'BLOCKED_STONE': 'Каменистый участок', 'BLOCKED_STONE': 'Каменистый участок',
'EMPTY': 'Пустырь', 'EMPTY': 'Пустырь',
}; };
return `${terrainMap[tile.terrainType] || 'Неизвестная земля'} (${tile.x}, ${tile.y})`; return `${terrainMap[tile.terrainType] || 'Неизвестная земля'} ${formatCoordinates(tile.x, tile.y)}`;
}; };
const getTileDescription = (tile) => { const getTileDescription = (tile) => {
@ -268,131 +287,206 @@ const handleActionClick = async (action) => {
const isSubmittingAdminAction = ref(false); const isSubmittingAdminAction = ref(false);
async function handleAdminAction(url) { async function handleAdminAction(url: string) {
if (isSubmittingAdminAction.value) return; if (isSubmittingAdminAction.value) return;
isSubmittingAdminAction.value = true; isSubmittingAdminAction.value = true;
try { try {
const { error } = await useFetch(url, { method: 'POST' }); // 1. Perform the requested admin action (e.g., reset, trigger tick)
if (error.value) { const { error: actionError } = await useFetch(url, { method: 'POST' });
alert(error.value.data?.statusMessage || 'An admin action failed.'); if (actionError.value) {
} else { // If the action itself fails, throw to stop execution
await Promise.all([refreshVillageData(), refreshEvents()]); throw actionError.value;
} }
} catch (e) {
console.error('Failed to perform admin action:', e); // 2. Refresh the main village data. This runs the core game logic
alert('An unexpected error occurred.'); // on the backend and gets the updated state.
await refreshVillageData();
// 3. The `villageData` ref is now updated. If it contains a user object,
// we sync it with the global auth state to update the header.
if (villageData.value?.user) {
updateUser(villageData.value.user);
}
// 4. Refresh the event log to show any new events created by the action.
await refreshEvents();
} catch (e: any) {
console.error(`Failed to perform admin action at ${url}:`, e);
// Use the error's data object if it exists for a more specific message
alert(e.data?.statusMessage || e.message || 'An unexpected error occurred during the admin action.');
} finally { } finally {
isSubmittingAdminAction.value = false; isSubmittingAdminAction.value = false;
} }
} }
const handleResetVillage = () => handleAdminAction('/api/admin/village/reset'); const handleResetVillage = () => handleAdminAction('/api/admin/village/reset');
const handleCompleteClearing = () => handleAdminAction('/api/admin/village/complete-clearing');
const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick'); const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-tick');
const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
</script> </script>
<style scoped> <style scoped>
.village-page { .village-page-layout {
--tile-size: clamp(50px, 12vw, 65px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 20px;
font-family: sans-serif;
min-height: calc(100vh - 120px);
box-sizing: border-box;
} }
.loading, .error-container { .loading, .error-container {
margin-top: 50px; margin-top: 50px;
font-size: 1.2em; font-size: 1.2em;
color: #555; color: #555;
text-align: center;
} }
.village-container { .village-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
max-width: 350px; padding: 0 10px; /* Add some padding on mobile */
margin-top: 20px; margin-top: 20px;
} }
.village-grid-wrapper {
overflow-x: auto;
}
.village-grid {
display: grid;
grid-template-columns: repeat(5, 60px);
grid-template-rows: repeat(7, 60px);
gap: 4px;
border: 2px solid #333;
padding: 4px;
background-color: #f0f0f0;
width: fit-content;
margin: 0 auto;
}
.tile { .tile {
width: 60px; width: var(--tile-size);
height: 60px; height: var(--tile-size);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border: 1px solid #ccc; border: 1px solid var(--border-color);
background-color: #fff; /* Default white for empty */ border-radius: 4px;
background-color: var(--container-bg-color);
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: all 0.2s;
} }
.tile.tile-blocked { .tile.tile-blocked {
background-color: #e0e0e0; /* Light gray for blocked (tree/stone) */ background-color: #f3f4f6;
} }
.tile.tile-object { .tile.tile-object {
background-color: #e6ffe6; /* Light green for tiles with user objects */ background-color: #ecfdf5;
} }
.tile:hover { .tile:hover {
background-color: #ffffe0; /* Light yellow for hover */ background-color: #fefce8;
border: 1px solid #ffcc00; /* Subtle yellow border */ border-color: #facc15;
} }
.tile.selected { .tile.selected {
border: 2px solid #007bff; border: 2px solid var(--primary-color);
box-shadow: 0 0 10px rgba(0, 123, 255, 0.5); box-shadow: 0 0 10px rgb(59 130 246 / 50%);
transform: scale(1.05);
} }
.tile-content { .tile-content {
font-size: 2em; font-size: calc(var(--tile-size) * 0.4); /* Make emoji scale with tile */
} }
/* Styles for the grid with labels */
.village-grid-wrapper {
display: grid;
grid-template-columns: 20px repeat(5, var(--tile-size));
grid-template-rows: repeat(7, var(--tile-size)) 20px; /* Grid first, then labels */
gap: 4px;
border: 2px solid var(--border-color);
border-radius: 8px;
background-color: var(--background-color);
padding: 4px;
width: fit-content;
margin: 0 auto;
overflow-x: auto; /* Allow horizontal scroll if needed */
overflow-y: hidden; /* Prevent vertical scroll */
}
.empty-corner {
grid-column: 1;
grid-row: 8; /* Position at the bottom-left */
width: 20px;
height: 20px;
}
.col-labels {
grid-column: 2 / span 5;
grid-row: 8; /* Position labels at the bottom (8th row) */
display: flex;
justify-content: space-around;
align-items: center;
height: 20px;
color: var(--text-color-light);
font-weight: normal; /* Muted */
opacity: 0.7; /* Muted */
}
.col-label {
width: var(--tile-size); /* Match new tile width */
text-align: center;
line-height: 20px;
}
.row-labels {
grid-column: 1;
grid-row: 1 / span 7; /* Position labels on the left of the grid */
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
width: 20px;
color: var(--text-color-light);
font-weight: normal; /* Muted */
opacity: 0.7; /* Muted */
}
.row-label {
height: var(--tile-size); /* Match new tile height */
display: flex;
align-items: center;
justify-content: center;
line-height: 20px;
}
.village-grid {
grid-column: 2 / span 5;
grid-row: 1 / span 7; /* Position grid at the top */
display: grid;
grid-template-columns: repeat(5, var(--tile-size));
grid-template-rows: repeat(7, var(--tile-size));
gap: 4px;
background-color: var(--background-color);
}
/* Overlay and other styles */
.tile-overlay-backdrop { .tile-overlay-backdrop {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: flex-end; align-items: flex-end;
z-index: 1000; z-index: 1000;
padding: 10px;
} }
.tile-overlay-panel { .tile-overlay-panel {
background-color: #fff; background-color: var(--background-color);
width: 100%; width: 100%;
max-width: 500px; max-width: 500px;
padding: 20px; padding: 24px;
border-top-left-radius: 15px; border-top-left-radius: 15px;
border-top-right-radius: 15px; border-top-right-radius: 15px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2); box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
transform: translateY(0);
transition: transform 0.3s ease-out;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 15px; gap: 16px;
max-height: 90vh;
overflow-y: auto;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@ -401,20 +495,17 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
} }
.tile-overlay-panel { .tile-overlay-panel {
border-radius: 15px; border-radius: 15px;
max-height: 80vh;
} }
} }
.tile-overlay-panel h2 { .tile-overlay-panel h2 {
margin-top: 0;
text-align: center; text-align: center;
color: #333;
} }
.tile-description { .tile-description {
text-align: center; text-align: center;
margin-top: -10px; margin-top: -10px;
color: #666; color: var(--text-color-light);
font-style: italic; font-style: italic;
} }
@ -424,83 +515,33 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
.actions-header { .actions-header {
text-align: center; text-align: center;
font-size: 1.2em;
color: #444;
margin-bottom: 15px;
}
.actions-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.action-item {
margin-bottom: 10px;
}
.action-item button {
width: 100%;
padding: 10px 15px;
border: 1px solid #007bff;
background-color: #007bff;
color: white;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.2s, opacity 0.2s;
}
.action-item button:hover:not(:disabled) {
background-color: #0056b3;
}
.action-item button:disabled {
background-color: #e9ecef;
color: #6c757d;
cursor: not-allowed;
border-color: #e9ecef;
} }
.build-section { .build-section {
margin-top: 20px;
padding-top: 15px; padding-top: 15px;
border-top: 1px solid #eee; border-top: 1px solid var(--border-color);
}
.build-section h4 {
text-align: center;
color: #555;
margin-bottom: 10px;
} }
.building-description { .building-description {
font-size: 0.85em; font-size: 0.85em;
color: #555; color: var(--text-color-light);
margin-top: 5px; margin-top: 5px;
text-align: center; text-align: center;
} }
.disabled-reason {
font-size: 0.8em;
color: #dc3545;
margin-top: 5px;
text-align: center;
}
.close-overlay-button { .close-overlay-button {
width: 100%; width: 100%;
padding: 10px 15px;
margin-top: 15px; margin-top: 15px;
background-color: #6c757d;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
} }
.close-overlay-button:hover { .bottom-content {
background-color: #5a6268; width: 100%;
max-width: 800px;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
margin-top: 24px;
} }
.admin-panel { .admin-panel {
@ -508,23 +549,22 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin-top: 20px;
padding: 15px; padding: 15px;
border: 2px dashed #dc3545; border: 2px dashed var(--danger-color);
border-radius: 10px; border-radius: 10px;
max-width: 350px;
width: 100%; width: 100%;
max-width: 350px;
} }
.admin-panel h3 { .admin-panel h3 {
margin: 0 0 10px 0; margin: 0 0 10px 0;
color: #dc3545; color: var(--danger-color);
} }
.admin-panel button { .admin-panel button {
width: 100%; width: 100%;
padding: 8px; padding: 8px;
background-color: #dc3545; background-color: var(--danger-color);
color: white; color: white;
border: none; border: none;
border-radius: 5px; border-radius: 5px;
@ -537,37 +577,39 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
} }
.event-log-container { .event-log-container {
margin-top: 20px;
width: 100%; width: 100%;
max-width: 350px; max-width: 800px;
} }
.event-log-container h4 { .event-log-container h2 {
text-align: center; text-align: center;
margin-bottom: 10px;
} }
.event-log-table { .table-responsive {
overflow-x: auto;
width: 100%; width: 100%;
border-collapse: collapse; max-height: 400px;
font-size: 0.8em; overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
} }
.event-log-table th, .event-log-table td { .table td, .table th {
border: 1px solid #ccc; white-space: nowrap;
padding: 6px; padding: 12px 15px;
text-align: left; vertical-align: middle;
} }
.event-log-table th { .table .event-message {
background-color: #f0f0f0; white-space: normal;
min-width: 300px;
max-width: 500px;
} }
/* New Build Card Styles */
.build-card-grid { .build-card-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 16px;
justify-content: center; justify-content: center;
} }
@ -576,39 +618,41 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 130px; width: 140px;
padding: 10px; padding: 16px;
border: 1px solid #ccc; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
background-color: #f9f9f9; background-color: #fff;
text-align: center; text-align: center;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
} }
.building-card:not(.disabled):hover { .building-card:not(.disabled):hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.1); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
} }
.building-card.disabled { .building-card.disabled {
background-color: #e9ecef; background-color: #f9fafb;
} }
.building-icon { .building-icon {
font-size: 2em; font-size: 2.5em;
margin-bottom: 5px; margin-bottom: 8px;
} }
.building-card h5 { .building-card h5 {
margin: 0 0 5px 0; margin: 0 0 8px 0;
font-size: 1em; font-size: 1.05em;
color: #333; font-weight: 600;
} }
.building-card .building-description { .building-card .building-description {
font-size: 0.75em; font-size: 0.8rem;
color: #666; line-height: 1.5;
flex-grow: 1; /* Pushes footer down */ color: var(--text-color-light);
margin-bottom: 10px; flex-grow: 1;
margin-bottom: 16px;
} }
.building-footer { .building-footer {
@ -616,16 +660,11 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
margin-top: auto; /* Pushes footer to bottom */ margin-top: auto;
} }
.building-footer .cost { .building-footer .cost {
font-weight: bold; font-weight: 600;
font-size: 0.9em;
}
.building-footer button {
padding: 5px 10px;
font-size: 0.9em; font-size: 0.9em;
} }
@ -635,14 +674,14 @@ const handleTriggerTick = () => handleAdminAction('/api/admin/village/trigger-ti
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(233, 236, 239, 0.8); background: rgba(249, 250, 251, 0.85);
color: #dc3545; color: var(--danger-color);
font-weight: bold; font-weight: 600;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
text-align: center; text-align: center;
padding: 5px; padding: 10px;
border-radius: 8px; border-radius: 8px;
cursor: not-allowed; cursor: not-allowed;
} }

217
assets/css/main.css Normal file
View File

@ -0,0 +1,217 @@
/* assets/css/main.css */
:root {
--primary-color: #3b82f6; /* A friendly blue */
--primary-color-hover: #2563eb;
--secondary-color: #6b7280; /* A neutral gray */
--secondary-color-hover: #4b5563;
--danger-color: #ef4444; /* A soft red */
--danger-color-hover: #dc2626;
--background-color: #f9fafb; /* Very light gray for page backgrounds */
--container-bg-color: #ffffff;
--text-color: #1f2937;
--text-color-light: #6b7280;
--border-color: #e5e7eb;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
}
body {
font-family: var(--font-family);
background-color: var(--background-color);
color: var(--text-color);
margin: 0;
padding: 0;
}
.page-container {
max-width: 800px;
margin: 0 auto;
padding: 24px;
background-color: var(--container-bg-color);
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
margin-top: 24px;
margin-bottom: 24px;
}
h1, h2, h3, h4, h5, h6 {
color: var(--text-color);
font-weight: 600;
margin-top: 0;
}
h1 {
font-size: 2rem; /* 32px */
margin-bottom: 1.5rem; /* 24px */
text-align: center;
}
h2 {
font-size: 1.5rem; /* 24px */
margin-bottom: 1rem; /* 16px */
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
h3 {
font-size: 1.25rem; /* 20px */
margin-bottom: 0.75rem; /* 12px */
}
p {
line-height: 1.6;
margin-bottom: 1rem;
}
/* --- Buttons --- */
.btn {
display: inline-block;
font-weight: 600;
text-align: center;
white-space: nowrap;
vertical-align: middle;
user-select: none;
border: 1px solid transparent;
padding: 0.75rem 1.5rem;
font-size: 1rem;
line-height: 1.5;
border-radius: 0.375rem;
cursor: pointer;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.btn-primary {
color: #fff;
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.btn-primary:hover {
background-color: var(--primary-color-hover);
border-color: var(--primary-color-hover);
}
.btn-secondary {
color: #fff;
background-color: var(--secondary-color);
border-color: var(--secondary-color);
}
.btn-secondary:hover {
background-color: var(--secondary-color-hover);
border-color: var(--secondary-color-hover);
}
.btn-danger {
color: #fff;
background-color: var(--danger-color);
border-color: var(--danger-color);
}
.btn-danger:hover {
background-color: var(--danger-color-hover);
border-color: var(--danger-color-hover);
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
border-radius: 0.2rem;
}
/* --- Forms --- */
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
.form-control {
display: block;
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
line-height: 1.5;
color: var(--text-color);
background-color: #fff;
background-clip: padding-box;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.form-control:focus {
outline: 0;
border-color: var(--primary-color);
box-shadow: 0 0 0 0.25rem rgb(59 130 246 / 25%);
}
.form-select {
display: block;
width: 100%;
padding: 0.75rem 2.25rem 0.75rem 1rem;
-moz-padding-start: calc(1rem - 3px);
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
background-color: #fff;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 16px 12px;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
/* --- Tables --- */
.table {
width: 100%;
margin-bottom: 1rem;
color: var(--text-color);
vertical-align: top;
border-color: var(--border-color);
border-collapse: collapse;
}
.table > :not(caption) > * > * {
padding: 0.75rem 0.75rem;
background-color: var(--container-bg-color);
border-bottom-width: 1px;
}
.table > thead {
vertical-align: bottom;
}
.table > thead > tr > th {
text-align: left;
font-weight: 600;
color: var(--text-color);
background-color: #f3f4f6; /* A bit darker than page background */
}
.table-striped > tbody > tr:nth-of-type(odd) > * {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
.table-hover > tbody > tr:hover > * {
background-color: rgb(243 244 246 / 0.8);
}

View File

@ -1,5 +1,4 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', devtools: { enabled: true },
devtools: { enabled: true }
}) })

View File

@ -0,0 +1,22 @@
import { getUserIdFromSession } from '../../../utils/auth';
import prisma from "../../../utils/prisma";
export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event);
// Simple admin check
if (userId !== 1) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
}
const user = await prisma.user.update({
where: { id: userId },
data: {
coins: {
increment: 1000,
},
},
});
return { success: true, message: `Added 1000 coins. New balance: ${user.coins}` };
});

View File

@ -1,65 +0,0 @@
// server/api/admin/village/complete-clearing.post.ts
import { getUserIdFromSession } from '../../../utils/auth';
import { PrismaClient } from '@prisma/client';
import { REWARDS } from '../../../utils/economy';
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event);
// Simple admin check
if (userId !== 1) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
}
const village = await prisma.village.findUniqueOrThrow({ where: { userId } });
const tilesToComplete = await prisma.villageTile.findMany({
where: {
villageId: village.id,
terrainState: 'CLEARING',
},
});
if (tilesToComplete.length === 0) {
return { success: true, message: 'No clearing tasks to complete.' };
}
const totalCoins = tilesToComplete.length * REWARDS.VILLAGE.CLEARING.coins;
const totalExp = tilesToComplete.length * REWARDS.VILLAGE.CLEARING.exp;
await prisma.$transaction(async (tx) => {
// 1. Update user totals
await tx.user.update({
where: { id: userId },
data: {
coins: { increment: totalCoins },
exp: { increment: totalExp },
},
});
// 2. Update all the tiles
await tx.villageTile.updateMany({
where: { id: { in: tilesToComplete.map(t => t.id) } },
data: { terrainState: 'IDLE', terrainType: 'EMPTY', clearingStartedAt: null },
});
// 3. Create an event for each completed tile
for (const tile of tilesToComplete) {
await tx.villageEvent.create({
data: {
villageId: village.id,
type: tile.terrainType === 'BLOCKED_TREE' ? 'CLEAR_TREE' : 'CLEAR_STONE',
message: `Finished clearing ${tile.terrainType === 'BLOCKED_TREE' ? 'a tree' : 'a stone'} at (${tile.x}, ${tile.y})`,
tileX: tile.x,
tileY: tile.y,
coins: REWARDS.VILLAGE.CLEARING.coins,
exp: REWARDS.VILLAGE.CLEARING.exp,
}
});
}
});
return { success: true, message: `Completed ${tilesToComplete.length} clearing tasks.` };
});

View File

@ -19,7 +19,7 @@ export default defineEventHandler(async (event) => {
const now = Date.now(); const now = Date.now();
const yesterday = new Date(now - 24 * 60 * 60 * 1000); const yesterday = new Date(now - 24 * 60 * 60 * 1000);
const clearingFastForwardDate = new Date(now - CLEANING_TIME_MS + 5000); // 5 seconds past completion const clearingFastForwardDate = new Date(now - CLEANING_TIME_MS - 1000); // 1 second safely in the past
await prisma.$transaction([ await prisma.$transaction([
// 1. Fast-forward any tiles that are currently being cleared // 1. Fast-forward any tiles that are currently being cleared