Добавление привычек работает, в шапке отображается верная инфа пользователя
This commit is contained in:
parent
055515269b
commit
bab91b6448
11
app/app.vue
11
app/app.vue
|
|
@ -5,10 +5,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
const { fetchMe } = useAuth();
|
const { fetchMe } = useAuth();
|
||||||
|
|
||||||
// Fetch the user state once on app startup.
|
// Fetch the user state ONLY on the client-side after the app has mounted.
|
||||||
// This call is not awaited to avoid blocking the render.
|
// This ensures the browser's cookies are sent, allowing the session to persist.
|
||||||
// The `initialized` guard inside `useAuth` prevents multiple calls.
|
onMounted(() => {
|
||||||
fetchMe();
|
fetchMe();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -4,6 +4,13 @@ interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
|
avatar: string | null;
|
||||||
|
coins: number;
|
||||||
|
exp: number;
|
||||||
|
soundOn: boolean;
|
||||||
|
confettiOn: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
|
|
@ -17,16 +24,20 @@ export function useAuth() {
|
||||||
const isAuthenticated = computed(() => !!user.value);
|
const isAuthenticated = computed(() => !!user.value);
|
||||||
|
|
||||||
const fetchMe = async () => {
|
const fetchMe = async () => {
|
||||||
|
// This function can be called multiple times, but the logic inside
|
||||||
|
// will only run once thanks to the initialized flag.
|
||||||
if (initialized.value) return;
|
if (initialized.value) return;
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
initialized.value = true;
|
|
||||||
try {
|
try {
|
||||||
user.value = await api<User>('/auth/me', { method: 'GET' });
|
// The backend returns the user object nested under a 'user' key.
|
||||||
|
const response = await api<{ user: User }>('/auth/me', { method: 'GET' });
|
||||||
|
user.value = response.user; // Correctly assign the nested user object
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
user.value = null; // Silently set user to null
|
user.value = null; // Silently set user to null on 401
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
initialized.value = true; // Mark as initialized after the first attempt
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="default-layout">
|
<div class="default-layout">
|
||||||
<header class="app-header">
|
<header class="app-header" v-if="user">
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<span>SmurfCoins: 120</span>
|
<span>SmurfCoins: {{ user.coins }}</span>
|
||||||
<span>EXP: 850</span>
|
<span>EXP: {{ user.exp }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span>Lvl 5</span>
|
<!-- Level can be calculated later -->
|
||||||
<span>Smurfette</span>
|
<span>{{ user.nickname }}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -23,6 +23,10 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { user } = useAuth();
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.default-layout {
|
.default-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
<input v-model="newHabitName" type="text" placeholder="e.g., Read for 15 minutes" required />
|
<input v-model="newHabitName" type="text" placeholder="e.g., Read for 15 minutes" required />
|
||||||
<div class="days-selector">
|
<div class="days-selector">
|
||||||
<label v-for="day in daysOfWeek" :key="day" class="day-label">
|
<label v-for="day in dayOptions" :key="day" class="day-label">
|
||||||
<input type="checkbox" :value="day" v-model="newHabitDays" />
|
<input type="checkbox" :value="day" v-model="newHabitDays" />
|
||||||
<span>{{ day }}</span>
|
<span>{{ day }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -25,19 +25,9 @@
|
||||||
<div class="habit-info">
|
<div class="habit-info">
|
||||||
<h4>{{ habit.name }}</h4>
|
<h4>{{ habit.name }}</h4>
|
||||||
<div class="habit-days">
|
<div class="habit-days">
|
||||||
<span v-for="day in habit.daysOfWeek" :key="day" class="day-chip">{{ 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">
|
|
||||||
<button
|
|
||||||
v-if="isActiveToday(habit) && !isCompleteToday(habit)"
|
|
||||||
@click="completeHabit(habit.id)"
|
|
||||||
:disabled="loading.complete === habit.id"
|
|
||||||
class="complete-btn">
|
|
||||||
{{ loading.complete === habit.id ? '...' : 'Complete' }}
|
|
||||||
</button>
|
|
||||||
<span v-if="isCompleteToday(habit)" class="completed-text">Done! ✅</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!loading.fetch && habits.length === 0">No habits yet. Add one above!</p>
|
<p v-if="!loading.fetch && habits.length === 0">No habits yet. Add one above!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,8 +41,7 @@ import { ref, onMounted } from 'vue';
|
||||||
interface Habit {
|
interface Habit {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
daysOfWeek: string[];
|
daysOfWeek: number[]; // Backend returns numbers
|
||||||
completions: { id: number; date: string }[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Composables ---
|
// --- Composables ---
|
||||||
|
|
@ -61,39 +50,21 @@ const api = useApi();
|
||||||
// --- State ---
|
// --- State ---
|
||||||
const habits = ref<Habit[]>([]);
|
const habits = ref<Habit[]>([]);
|
||||||
const newHabitName = ref('');
|
const newHabitName = ref('');
|
||||||
const daysOfWeek = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
const dayOptions = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
const dayMap: { [key: number]: string } = { 0: 'Mon', 1: 'Tue', 2: 'Wed', 3: 'Thu', 4: 'Fri', 5: 'Sat', 6: 'Sun' };
|
||||||
const newHabitDays = ref<string[]>([]);
|
const newHabitDays = ref<string[]>([]);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const loading = ref({
|
const loading = ref({
|
||||||
fetch: false,
|
fetch: false,
|
||||||
create: false,
|
create: false,
|
||||||
complete: null as number | null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Helper Functions ---
|
|
||||||
const getTodayString = () => new Date().toISOString().split('T')[0];
|
|
||||||
const getTodayDay = () => {
|
|
||||||
const dayIndex = new Date().getDay(); // Sunday is 0
|
|
||||||
return daysOfWeek[(dayIndex + 6) % 7]; // Adjust to Mon = 0, Sun = 6 -> then map to our array
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCompleteToday = (habit: Habit) => {
|
|
||||||
const today = getTodayString();
|
|
||||||
return habit.completions.some(c => c.date.startsWith(today));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isActiveToday = (habit: Habit) => {
|
|
||||||
const today = getTodayDay();
|
|
||||||
return habit.daysOfWeek.includes(today);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- API Functions ---
|
// --- API Functions ---
|
||||||
const fetchHabits = async () => {
|
const fetchHabits = async () => {
|
||||||
loading.value.fetch = true;
|
loading.value.fetch = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const data = await api<Habit[]>('/habits');
|
habits.value = await api<Habit[]>('/habits');
|
||||||
habits.value = data;
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to fetch habits:', err);
|
console.error('Failed to fetch habits:', err);
|
||||||
error.value = 'Could not load habits.';
|
error.value = 'Could not load habits.';
|
||||||
|
|
@ -109,18 +80,24 @@ const createHabit = async () => {
|
||||||
}
|
}
|
||||||
loading.value.create = true;
|
loading.value.create = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
|
// Convert day names to numbers
|
||||||
|
const dayNumbers = newHabitDays.value.map(dayName => dayOptions.indexOf(dayName));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newHabit = await api<Habit>('/habits', {
|
await api<Habit>('/habits', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
name: newHabitName.value,
|
name: newHabitName.value,
|
||||||
daysOfWeek: newHabitDays.value,
|
daysOfWeek: dayNumbers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
habits.value.push(newHabit); // Optimistic update
|
|
||||||
// Reset form
|
// Clear form and re-fetch the list from the server
|
||||||
newHabitName.value = '';
|
newHabitName.value = '';
|
||||||
newHabitDays.value = [];
|
newHabitDays.value = [];
|
||||||
|
await fetchHabits();
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to create habit:', err);
|
console.error('Failed to create habit:', err);
|
||||||
error.value = err.data?.message || 'Could not create habit.';
|
error.value = err.data?.message || 'Could not create habit.';
|
||||||
|
|
@ -129,26 +106,6 @@ const createHabit = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeHabit = async (habitId: number) => {
|
|
||||||
loading.value.complete = habitId;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const completion = await api<{ id: number; date: string }>(`/habits/${habitId}/complete`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
// Optimistic update
|
|
||||||
const habit = habits.value.find(h => h.id === habitId);
|
|
||||||
if (habit) {
|
|
||||||
habit.completions.push(completion);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(`Failed to complete habit ${habitId}:`, err);
|
|
||||||
error.value = err.data?.message || 'Could not complete habit.';
|
|
||||||
} finally {
|
|
||||||
loading.value.complete = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Lifecycle Hooks ---
|
// --- Lifecycle Hooks ---
|
||||||
onMounted(fetchHabits);
|
onMounted(fetchHabits);
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -157,7 +114,7 @@ onMounted(fetchHabits);
|
||||||
.habits-container {
|
.habits-container {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding-bottom: 40px; /* Space for form */
|
padding-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
|
|
@ -262,20 +219,6 @@ h3 {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.complete-btn {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
background-color: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.completed-text {
|
|
||||||
color: #a3be8c;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: #bf616a;
|
color: #bf616a;
|
||||||
background-color: #fbe2e5;
|
background-color: #fbe2e5;
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,152 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<div v-if="!initialized">
|
<div v-if="isAuthenticated && user">
|
||||||
<p>Loading session...</p>
|
<h2>Today's Habits for {{ user.nickname }}</h2>
|
||||||
</div>
|
|
||||||
<div v-else-if="isAuthenticated && user">
|
<div v-if="loading" class="loading-message">
|
||||||
<h2>Welcome Back, {{ user.nickname }}!</h2>
|
<p>Loading habits...</p>
|
||||||
<p>Your village and habits await.</p>
|
|
||||||
<div class="quick-links">
|
|
||||||
<NuxtLink to="/village">Go to Village</NuxtLink>
|
|
||||||
<NuxtLink to="/habits">Check Habits</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="todayHabits.length > 0" class="habits-list">
|
||||||
|
<div v-for="habit in todayHabits" :key="habit.id" class="habit-card">
|
||||||
|
<span class="habit-name">{{ habit.name }}</span>
|
||||||
|
<button
|
||||||
|
v-if="!isCompleteToday(habit)"
|
||||||
|
@click="completeHabit(habit.id)"
|
||||||
|
:disabled="completing === habit.id"
|
||||||
|
class="complete-btn"
|
||||||
|
>
|
||||||
|
{{ completing === habit.id ? '...' : 'Complete' }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="completed-text">✅ Done!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p>No habits scheduled for today. Great job!</p>
|
||||||
|
<NuxtLink to="/habits">Manage Habits</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Redirect is handled in script setup, this part might not be seen -->
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p>Redirecting to login...</p>
|
<p>Loading session...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { user, isAuthenticated, initialized } = useAuth();
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
|
||||||
watchEffect(() => {
|
interface Habit {
|
||||||
// Only redirect when the auth state has been initialized and the user is not authenticated.
|
id: number;
|
||||||
if (initialized.value && !isAuthenticated.value) {
|
name: string;
|
||||||
navigateTo('/login');
|
daysOfWeek: number[];
|
||||||
|
completions: { id: number; date: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, isAuthenticated } = useAuth();
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const allHabits = ref<Habit[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const completing = ref<number | null>(null);
|
||||||
|
|
||||||
|
const dayMap = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const todayIndex = new Date().getDay(); // 0 for Sunday, 1 for Monday...
|
||||||
|
|
||||||
|
const todayHabits = computed(() => {
|
||||||
|
return allHabits.value.filter(h => h.daysOfWeek.includes(todayIndex));
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTodayString = () => new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const isCompleteToday = (habit: Habit) => {
|
||||||
|
const today = getTodayString();
|
||||||
|
return habit.completions.some(c => c.date.startsWith(today));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchHabits = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
allHabits.value = await api<Habit[]>('/habits');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch habits:", error);
|
||||||
|
allHabits.value = []; // Clear habits on error
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeHabit = async (habitId: number) => {
|
||||||
|
completing.value = habitId;
|
||||||
|
try {
|
||||||
|
await api(`/habits/${habitId}/complete`, { method: 'POST' });
|
||||||
|
await fetchHabits(); // Re-fetch the list to update UI
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to complete habit ${habitId}:`, error);
|
||||||
|
} finally {
|
||||||
|
completing.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// We only fetch habits if the user is authenticated.
|
||||||
|
// The redirect logic is handled by the middleware/layout now.
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
fetchHabits();
|
||||||
|
}
|
||||||
|
// Watch for auth state changes to fetch habits after login
|
||||||
|
watch(isAuthenticated, (isAuth) => {
|
||||||
|
if (isAuth && allHabits.value.length === 0) {
|
||||||
|
fetchHabits();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard {
|
.dashboard {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
}
|
||||||
|
.habits-list {
|
||||||
h2 {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #4c566a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-links {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
.habit-card {
|
||||||
.quick-links a {
|
display: flex;
|
||||||
display: inline-block;
|
justify-content: space-between;
|
||||||
padding: 10px 20px;
|
align-items: center;
|
||||||
background-color: #81a1c1;
|
padding: 15px;
|
||||||
color: white;
|
background-color: #fff;
|
||||||
text-decoration: none;
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.habit-name {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
.complete-btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
background-color: #88c0d0;
|
||||||
|
color: #2e3440;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.completed-text {
|
||||||
|
color: #a3be8c;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
margin-top: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.empty-state a {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
// /middleware/auth.ts
|
// /middleware/auth.ts
|
||||||
export default defineNuxtRouteMiddleware((to) => {
|
export default defineNuxtRouteMiddleware((to) => {
|
||||||
// `app.vue` is responsible for the initial fetchUser call.
|
const { isAuthenticated, initialized } = useAuth();
|
||||||
// This middleware just reads the state that's already present.
|
|
||||||
const { isAuthenticated } = useAuth();
|
|
||||||
|
|
||||||
|
// Do not run middleware until auth state is initialized on client-side
|
||||||
|
if (!initialized.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// if the user is authenticated and tries to access /login, redirect to home
|
// if the user is authenticated and tries to access /login, redirect to home
|
||||||
if (isAuthenticated.value && to.path === '/login') {
|
if (isAuthenticated.value && to.path === '/login') {
|
||||||
return navigateTo('/', { replace: true });
|
return navigateTo('/', { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the user is not authenticated and tries to access any page other than public routes, redirect to /login
|
// if the user is not authenticated and tries to access any page other than public routes, redirect to /login
|
||||||
const publicRoutes = ['/login', '/register']; // Add any other public paths here
|
const publicRoutes = ['/login', '/register'];
|
||||||
if (!isAuthenticated.value && !publicRoutes.includes(to.path)) {
|
if (!isAuthenticated.value && !publicRoutes.includes(to.path)) {
|
||||||
return navigateTo('/login', { replace: true });
|
return navigateTo('/login', { replace: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user