feat(frontend): base pages, layout and auth scaffolding. я вижу окно авторизации, но кнопка регистрации ещё не работает

This commit is contained in:
Alexander Andreev 2026-01-03 14:22:31 +03:00
parent de30f96c57
commit c94a186029
20 changed files with 753 additions and 4 deletions

72
GEMINI.md Normal file
View File

@ -0,0 +1,72 @@
# GEMINI Project Context: SmurfHabits
This document provides a comprehensive overview of the SmurfHabits project for AI-assisted development.
## 1. Project Overview
SmurfHabits is a mobile-first web application for habit tracking with a light gamification layer. The core user loop involves completing daily habits and quests to earn rewards, which are then used to build and develop a virtual village. This progression grants Experience Points (EXP) that are tracked on a global leaderboard.
The project is a full-stack SPA (Single Page Application) built with Nuxt 3, using Vue 3 for the frontend and a Node.js backend powered by Nuxt Nitro. It uses SQLite as its database with Prisma ORM for data access.
### Key Architectural Principles:
- **Monorepo:** Frontend and backend coexist in a single codebase.
- **Backend as Source of Truth:** All progression, time-based calculations, and rewards are handled by the server-side API to ensure consistency.
- **Clear Separation of Concerns:** A strict distinction is maintained between UI (in `app/`), backend logic (in `server/`), and data persistence (in `prisma/`).
## 2. Building and Running
The project uses `npm` for dependency management and running scripts.
### First-Time Setup
1. **Install Node.js:** Ensure you are using a Node.js version in the 20.x LTS range.
2. **Install Dependencies:**
```bash
npm install
```
3. **Setup Database:** Create the initial database and apply migrations. The `.env` file is pre-configured to use a local SQLite file (`dev.db`).
```bash
npx prisma migrate dev
```
### Development
To run the development server with hot-reloading:
```bash
npm run dev
```
### Building for Production
To build the application for production:
```bash
npm run build
```
## 3. Development Conventions
Adhering to these conventions is critical for maintaining project stability.
### Project Structure
- `app/`: Contains all frontend UI code (pages, components, layouts).
- `server/`: Contains all backend API code and utilities.
- `prisma/`: Contains the database schema (`schema.prisma`) and migration files.
- `public/`: Contains static assets.
### Database and Prisma
- The project is intentionally locked to **Prisma v6.x**. Do **NOT** upgrade to v7 or introduce Prisma adapters.
- All changes to the database schema in `prisma/schema.prisma` **must** be followed by a migration command:
```bash
npx prisma migrate dev
```
- The primary Prisma client instance is exported from `server/utils/prisma.ts`. Do not initialize the client elsewhere.
### State Management
- **Frontend:** Pinia is used for client-side state management.
- **Backend:** The backend API is the authoritative source for all user progression, rewards, and time-sensitive data. The frontend should not implement its own logic for these calculations.
### API Development
- API routes are located in `server/api/`.
- Group API endpoints by domain (e.g., `/api/habits`, `/api/village`).
- All business logic should reside in the backend.
### AI / Gemini Usage Rules
- **DO NOT** allow the AI to change the Node.js version, upgrade Prisma, alter the Prisma configuration, or modify the core project structure.
- **ALLOWED:** The AI can be used to add or modify Prisma schema models, generate new API endpoints, and implement business logic within the existing architectural framework.

View File

@ -1,6 +1,14 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
const { fetchMe } = useAuth();
// Fetch the user state once on app startup.
// This call is not awaited to avoid blocking the render.
// The `initialized` guard inside `useAuth` prevents multiple calls.
fetchMe();
</script>

18
app/composables/useApi.ts Normal file
View File

@ -0,0 +1,18 @@
// /composables/useApi.ts
export const useApi = () => {
return $fetch.create({
baseURL: '/api',
credentials: 'include',
onRequest({ request, options }) {
// Log request
console.log('Fetching ', request, options);
},
onResponseError({ response }) {
if (response.status === 401) {
return; // Ignore 401 unauthorized errors, they are handled by the caller.
}
// Log all other errors
console.error('Fetch error ', response.status, response._data);
},
});
};

101
app/composables/useAuth.ts Normal file
View File

@ -0,0 +1,101 @@
// /composables/useAuth.ts
// Define User interface
interface User {
id: string;
email: string;
nickname: string;
}
/**
* Composable for authentication management.
* All state is managed by Nuxt's useState to ensure it's shared and SSR-safe.
*/
export function useAuth() {
// --- State ---
// All state is defined here, inside the composable function.
// Nuxt's useState ensures this state is a singleton across the app.
const user = useState<User | null>('user', () => null);
const loading = useState('auth_loading', () => false);
const initialized = useState('auth_initialized', () => false);
// --- Composables ---
const api = useApi();
// --- Computed Properties ---
const isAuthenticated = computed(() => !!user.value);
// --- Methods ---
/**
* Fetches the current user from the backend.
* Runs only once, guarded by the 'initialized' state.
*/
const fetchMe = async () => {
if (initialized.value) {
return;
}
loading.value = true;
initialized.value = true;
try {
const fetchedUser = await api<User>('/auth/me', { method: 'GET' });
user.value = fetchedUser;
} catch (error) {
user.value = null; // Silently handle 401s or other errors
} finally {
loading.value = false;
}
};
/**
* Logs the user in and fetches their data on success.
*/
const login = async (email, password) => {
loading.value = true;
try {
await api('/auth/login', {
method: 'POST',
body: { email, password },
});
// We must re-fetch the user after logging in.
// Resetting 'initialized' allows fetchMe to run again.
initialized.value = false;
await fetchMe();
await navigateTo('/');
} catch (error) {
console.error('Login failed:', error);
throw error; // Re-throw to allow the UI to handle it
} finally {
loading.value = false;
}
};
/**
* Logs the user out.
*/
const logout = async () => {
loading.value = true;
try {
await api('/auth/logout', { method: 'POST' });
} catch (error) {
console.error('Logout API call failed, proceeding with client-side logout:', error);
} finally {
user.value = null;
initialized.value = false; // Allow re-fetch on next app load/login
loading.value = false;
await navigateTo('/login');
}
};
return {
user,
loading,
isAuthenticated,
fetchMe,
login,
logout,
};
}

76
app/layouts/default.vue Normal file
View File

@ -0,0 +1,76 @@
<template>
<div class="default-layout">
<header class="app-header">
<div class="stats">
<span>SmurfCoins: 120</span>
<span>EXP: 850</span>
</div>
<div class="user-info">
<span>Lvl 5</span>
<span>Smurfette</span>
</div>
</header>
<main class="app-content">
<slot />
</main>
<nav class="bottom-nav">
<NuxtLink to="/village" class="nav-item">Village</NuxtLink>
<NuxtLink to="/habits" class="nav-item">Habits</NuxtLink>
<NuxtLink to="/leaderboard" class="nav-item">Leaderboard</NuxtLink>
</nav>
</div>
</template>
<style scoped>
.default-layout {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #eef5ff;
color: #333;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background-color: #4a90e2;
color: white;
flex-shrink: 0;
}
.stats, .user-info {
display: flex;
gap: 15px;
}
.app-content {
flex-grow: 1;
overflow-y: auto;
padding: 15px;
}
.bottom-nav {
display: flex;
justify-content: space-around;
padding: 10px 0;
background-color: #ffffff;
border-top: 1px solid #dcdcdc;
flex-shrink: 0;
}
.nav-item {
text-decoration: none;
color: #4a90e2;
padding: 5px 10px;
border-radius: 5px;
}
.nav-item.router-link-exact-active {
background-color: #eef5ff;
font-weight: bold;
}
</style>

15
app/layouts/login.vue Normal file
View File

@ -0,0 +1,15 @@
<template>
<div class="login-layout">
<slot />
</div>
</template>
<style scoped>
.login-layout {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f0f4f8;
}
</style>

90
app/pages/habits.vue Normal file
View File

@ -0,0 +1,90 @@
<template>
<div class="habits-container">
<h3>My Habits</h3>
<div class="habits-list">
<!-- Mock Habit Card 1 -->
<div class="habit-card">
<div class="habit-info">
<h4>Practice Nuxt</h4>
<p>Active: Mon, Wed, Fri</p>
</div>
<button class="complete-btn">Complete</button>
</div>
<!-- Mock Habit Card 2 -->
<div class="habit-card">
<div class="habit-info">
<h4>Walk 5,000 steps</h4>
<p>Active: Everyday</p>
</div>
<button class="complete-btn">Complete</button>
</div>
<!-- Mock Habit Card 3 -->
<div class="habit-card">
<div class="habit-info">
<h4>Read a chapter</h4>
<p>Active: Tue, Thu, Sat, Sun</p>
</div>
<button class="complete-btn completed">Done</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// No logic, just visual placeholders
</script>
<style scoped>
.habits-container {
max-width: 600px;
margin: 0 auto;
}
h3 {
text-align: center;
margin-bottom: 20px;
}
.habits-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.habit-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.habit-info h4 {
margin: 0 0 5px 0;
font-size: 1.1em;
}
.habit-info p {
margin: 0;
color: #666;
font-size: 0.9em;
}
.complete-btn {
padding: 8px 12px;
border: none;
border-radius: 5px;
background-color: #88c0d0;
color: white;
cursor: pointer;
}
.complete-btn.completed {
background-color: #a3be8c;
cursor: not-allowed;
}
</style>

60
app/pages/index.vue Normal file
View File

@ -0,0 +1,60 @@
<template>
<div class="dashboard">
<div v-if="isAuthenticated && user">
<h2>Welcome Back, {{ user.nickname }}!</h2>
<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>
<!-- You can show a loading indicator here if you want -->
<p>Loading...</p>
</div>
</div>
</template>
<script setup lang="ts">
const { user, isAuthenticated } = useAuth();
watchEffect(() => {
// Wait until the initial fetch is done and then decide.
// isAuthenticated becomes false if the fetch fails (e.g., 401)
// or true if it succeeds. We redirect only when we are sure.
if (process.client && isAuthenticated.value === false) {
navigateTo('/login');
}
});
</script>
<style scoped>
.dashboard {
text-align: center;
padding: 40px 20px;
}
h2 {
margin-bottom: 10px;
}
p {
margin-bottom: 20px;
color: #4c566a;
}
.quick-links {
display: flex;
justify-content: center;
gap: 20px;
}
.quick-links a {
display: inline-block;
padding: 10px 20px;
background-color: #81a1c1;
color: white;
text-decoration: none;
border-radius: 5px;
}
</style>

84
app/pages/leaderboard.vue Normal file
View File

@ -0,0 +1,84 @@
<template>
<div class="leaderboard-container">
<h3>Monthly Leaderboard</h3>
<ul class="leaderboard-list">
<li class="leaderboard-item">
<span class="rank">1.</span>
<span class="name">Papa Smurf</span>
<span class="exp">9800 EXP</span>
</li>
<li class="leaderboard-item self">
<span class="rank">2.</span>
<span class="name">Smurfette</span>
<span class="exp">8500 EXP</span>
</li>
<li class="leaderboard-item">
<span class="rank">3.</span>
<span class="name">Brainy Smurf</span>
<span class="exp">8250 EXP</span>
</li>
<li class="leaderboard-item">
<span class="rank">4.</span>
<span class="name">Hefty Smurf</span>
<span class="exp">7600 EXP</span>
</li>
<li class="leaderboard-item">
<span class="rank">5.</span>
<span class="name">Jokey Smurf</span>
<span class="exp">6100 EXP</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
// No logic, just visual placeholders
</script>
<style scoped>
.leaderboard-container {
max-width: 600px;
margin: 0 auto;
}
h3 {
text-align: center;
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>

75
app/pages/login.vue Normal file
View File

@ -0,0 +1,75 @@
<template>
<div class="login-container">
<h2>Smurf Habits</h2>
<form @submit.prevent>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" placeholder="papa@smurf.village" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" />
</div>
<button type="submit">Login</button>
</form>
<div class="register-link">
<p>No account? <a href="#">Register</a></p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'login',
});
</script>
<style scoped>
.login-container {
width: 100%;
max-width: 350px;
padding: 20px;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
}
h2 {
color: #4a90e2;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 15px;
text-align: left;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
width: 100%;
padding: 10px;
background-color: #4a90e2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.register-link {
margin-top: 20px;
}
</style>

59
app/pages/village.vue Normal file
View File

@ -0,0 +1,59 @@
<template>
<div class="village-container">
<h3>My Village</h3>
<div class="village-grid">
<div v-for="n in 64" :key="n" class="grid-cell">
<!-- Placeholder for village objects -->
</div>
</div>
<div class="village-actions">
<button>Build Mode</button>
</div>
</div>
</template>
<script setup lang="ts">
// No logic for now, just visual placeholders
</script>
<style scoped>
.village-container {
text-align: center;
}
h3 {
margin-bottom: 20px;
}
.village-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
width: 100%;
max-width: 500px; /* Or other size that fits your design */
margin: 0 auto;
border: 1px solid #ccc;
aspect-ratio: 1 / 1;
}
.grid-cell {
border: 1px dotted #e0e0e0;
background-color: #a3be8c; /* Grassy color */
}
.grid-cell:nth-child(5) { background-color: #bf616a; } /* Fake house */
.grid-cell:nth-child(10) { background-color: #ebcb8b; } /* Fake field */
.grid-cell:nth-child(11) { background-color: #ebcb8b; } /* Fake field */
.village-actions {
margin-top: 20px;
}
button {
padding: 10px 20px;
background-color: #5e81ac;
color: white;
border: none;
border-radius: 5px;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 161 KiB

View File

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

View File

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

17
middleware/auth.global.ts Normal file
View File

@ -0,0 +1,17 @@
// /middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
// `app.vue` is responsible for the initial fetchUser call.
// This middleware just reads the state that's already present.
const { isAuthenticated } = useAuth();
// if the user is authenticated and tries to access /login, redirect to home
if (isAuthenticated.value && to.path === '/login') {
return navigateTo('/', { replace: true });
}
// 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
if (!isAuthenticated.value && !publicRoutes.includes(to.path)) {
return navigateTo('/login', { replace: true });
}
});

12
pages/index.vue Normal file
View File

@ -0,0 +1,12 @@
<!-- /pages/index.vue -->
<template>
<div>
<h1>Dashboard</h1>
<p v-if="user">Welcome, {{ user.nickname }}!</p>
<button @click="logout">Logout</button>
</div>
</template>
<script setup lang="ts">
const { user, logout } = useAuth();
</script>

62
pages/login.vue Normal file
View File

@ -0,0 +1,62 @@
<!-- /pages/login.vue -->
<template>
<div>
<h1>Login or Register</h1>
<form @submit.prevent="isRegistering ? handleRegister() : handleLogin()">
<div>
<label for="email">Email</label>
<input type="email" id="email" v-model="email" required />
</div>
<div v-if="isRegistering">
<label for="nickname">Nickname</label>
<input type="text" id="nickname" v-model="nickname" required />
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" v-model="password" required />
</div>
<div v-if="error">{{ error }}</div>
<button type="submit" :disabled="loading">
{{ isRegistering ? 'Register' : 'Login' }}
</button>
</form>
<button @click="isRegistering = !isRegistering">
{{ isRegistering ? 'Switch to Login' : 'Switch to Register' }}
</button>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default', // Using the default layout
});
const { login, register, loading } = useAuth();
const isRegistering = ref(false);
const email = ref('');
const password = ref('');
const nickname = ref('');
const error = ref<string | null>(null);
const handleLogin = async () => {
error.value = null;
try {
await login(email.value, password.value);
} catch (e: any) {
error.value = e.data?.message || 'Login failed.';
}
};
const handleRegister = async () => {
error.value = null;
try {
await register(email.value, password.value, nickname.value);
// On successful registration, switch to the login view
isRegistering.value = false;
// You might want to auto-login or show a success message instead
} catch (e: any) {
error.value = e.data?.message || 'Registration failed.';
}
};
</script>