first prototype commit

This commit is contained in:
2026-08-09 21:45:06 -04:00
commit 40613a00d5
36 changed files with 4371 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@lucide/vue": "^1.30.0",
"@tailwindcss/vite": "^4.3.3",
"canvas-confetti": "^1.9.4",
"lucide-vue-next": "^1.0.0",
"pinia": "^4.0.2",
"tailwindcss": "^4.3.3",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.8",
"vite": "^8.2.0"
}
}
+1379
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
<template>
<div class="min-h-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row">
<!-- Navigation Sidebar / Mobile Bar -->
<Navbar v-if="authStore.isAuthenticated" />
<!-- Main Content Area -->
<main
class="flex-1 p-4 md:p-8 mb-16 md:mb-0 transition-all"
:class="authStore.isAuthenticated ? 'md:ml-64' : ''"
>
<div class="max-w-7xl mx-auto">
<router-view />
</div>
</main>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
import Navbar from './components/Navbar.vue';
const authStore = useAuthStore();
onMounted(async () => {
if (authStore.token) {
await authStore.fetchCurrentUser();
}
});
</script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+37
View File
@@ -0,0 +1,37 @@
<template>
<component :is="iconComponent" :class="customClass || 'w-5 h-5'" />
</template>
<script setup>
import { computed } from 'vue';
import * as icons from '@lucide/vue';
const props = defineProps({
name: {
type: String,
default: 'CheckSquare'
},
customClass: {
type: String,
default: ''
}
});
const iconComponent = computed(() => {
if (props.name && icons[props.name]) {
return icons[props.name];
}
// Common fallback maps
const fallbackMap = {
'Utensils': icons.Utensils || icons.CheckSquare,
'Broom': icons.Sparkles || icons.CheckSquare,
'Snowflake': icons.Snowflake || icons.Sparkles,
'ShoppingBag': icons.ShoppingBag || icons.ShoppingCart || icons.CheckSquare,
'Trash2': icons.Trash2 || icons.Trash,
'Sparkles': icons.Sparkles,
'Wrench': icons.Wrench,
'Shirt': icons.Shirt
};
return fallbackMap[props.name] || icons.CheckSquare;
});
</script>
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
+177
View File
@@ -0,0 +1,177 @@
<template>
<div>
<!-- Desktop Sidebar -->
<aside class="hidden md:flex flex-col w-64 glass-panel border-r border-slate-700/50 min-h-screen fixed left-0 top-0 z-30 p-4">
<!-- App Header & Logo -->
<div class="flex items-center space-x-3 px-2 py-4 mb-6">
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-amber-500 to-emerald-400 flex items-center justify-center shadow-lg shadow-amber-500/20">
<Sparkles class="w-6 h-6 text-slate-950 font-bold" />
</div>
<div>
<h1 class="text-xl font-bold tracking-tight bg-gradient-to-r from-amber-400 via-amber-200 to-emerald-400 bg-clip-text text-transparent">ChoreUS</h1>
<p class="text-xs text-slate-400 font-medium">Home Chore Manager</p>
</div>
</div>
<!-- User Mini Profile -->
<div v-if="authStore.user" class="glass-card rounded-xl p-3 mb-6 flex items-center space-x-3 border border-slate-700/50">
<img :src="authStore.user.avatar_url || defaultAvatar" class="w-10 h-10 rounded-full bg-slate-800 border border-amber-400/30 object-cover" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-100 truncate">{{ authStore.user.name }}</p>
<div class="flex items-center space-x-1">
<span class="inline-block w-2 h-2 rounded-full bg-emerald-400"></span>
<span class="text-xs text-slate-400 capitalize">{{ authStore.user.role }}</span>
</div>
</div>
</div>
<!-- Navigation Links -->
<nav class="flex-1 space-y-1.5">
<router-link
to="/"
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
:class="$route.path === '/' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
>
<LayoutDashboard class="w-5 h-5" />
<span>Dashboard</span>
</router-link>
<router-link
to="/chores"
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
:class="$route.path === '/chores' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
>
<CheckSquare class="w-5 h-5" />
<span>Chores List</span>
</router-link>
<router-link
to="/reports"
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
:class="$route.path === '/reports' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
>
<BarChart3 class="w-5 h-5" />
<span>Weekly Reports</span>
</router-link>
<router-link
v-if="authStore.isAdmin"
to="/admin"
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
:class="$route.path === '/admin' ? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
>
<Shield class="w-5 h-5" />
<span>Admin Console</span>
</router-link>
<router-link
to="/profile"
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
:class="$route.path === '/profile' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
>
<User class="w-5 h-5" />
<span>My Profile</span>
</router-link>
</nav>
<!-- Logout Button -->
<button
@click="handleLogout"
class="mt-auto flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm text-rose-400 hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 transition-all duration-200 w-full"
>
<LogOut class="w-5 h-5" />
<span>Sign Out</span>
</button>
</aside>
<!-- Mobile Top Header Bar -->
<header class="md:hidden glass-panel border-b border-slate-700/50 sticky top-0 z-30 px-4 py-3 flex items-center justify-between">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 rounded-lg bg-gradient-to-tr from-amber-500 to-emerald-400 flex items-center justify-center">
<Sparkles class="w-4 h-4 text-slate-950 font-bold" />
</div>
<span class="font-bold text-base bg-gradient-to-r from-amber-400 to-emerald-400 bg-clip-text text-transparent">ChoreUS</span>
</div>
<div v-if="authStore.user" class="flex items-center space-x-2">
<router-link to="/profile">
<img :src="authStore.user.avatar_url || defaultAvatar" class="w-8 h-8 rounded-full border border-amber-400/40 object-cover" />
</router-link>
<button @click="handleLogout" class="p-1.5 text-slate-400 hover:text-rose-400">
<LogOut class="w-5 h-5" />
</button>
</div>
</header>
<!-- Mobile Bottom Navigation Bar -->
<nav class="md:hidden glass-panel border-t border-slate-700/50 fixed bottom-0 left-0 right-0 z-30 px-2 py-1.5 flex justify-around items-center">
<router-link
to="/"
class="flex flex-col items-center p-2 rounded-xl transition-all"
:class="$route.path === '/' ? 'text-amber-400 font-bold' : 'text-slate-400'"
>
<LayoutDashboard class="w-5 h-5" />
<span class="text-[10px] mt-1">Home</span>
</router-link>
<router-link
to="/chores"
class="flex flex-col items-center p-2 rounded-xl transition-all"
:class="$route.path === '/chores' ? 'text-amber-400 font-bold' : 'text-slate-400'"
>
<CheckSquare class="w-5 h-5" />
<span class="text-[10px] mt-1">Chores</span>
</router-link>
<router-link
to="/reports"
class="flex flex-col items-center p-2 rounded-xl transition-all"
:class="$route.path === '/reports' ? 'text-amber-400 font-bold' : 'text-slate-400'"
>
<BarChart3 class="w-5 h-5" />
<span class="text-[10px] mt-1">Reports</span>
</router-link>
<router-link
v-if="authStore.isAdmin"
to="/admin"
class="flex flex-col items-center p-2 rounded-xl transition-all"
:class="$route.path === '/admin' ? 'text-emerald-400 font-bold' : 'text-slate-400'"
>
<Shield class="w-5 h-5" />
<span class="text-[10px] mt-1">Admin</span>
</router-link>
<router-link
to="/profile"
class="flex flex-col items-center p-2 rounded-xl transition-all"
:class="$route.path === '/profile' ? 'text-amber-400 font-bold' : 'text-slate-400'"
>
<User class="w-5 h-5" />
<span class="text-[10px] mt-1">Profile</span>
</router-link>
</nav>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import {
Sparkles,
LayoutDashboard,
CheckSquare,
BarChart3,
Shield,
User,
LogOut
} from '@lucide/vue';
const authStore = useAuthStore();
const router = useRouter();
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=FamilyUser';
function handleLogout() {
authStore.logout();
router.push('/login');
}
</script>
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.mount('#app')
+73
View File
@@ -0,0 +1,73 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import LoginView from '../views/LoginView.vue';
import DashboardView from '../views/DashboardView.vue';
import ChoresView from '../views/ChoresView.vue';
import ReportsView from '../views/ReportsView.vue';
import AdminView from '../views/AdminView.vue';
import ProfileView from '../views/ProfileView.vue';
const routes = [
{
path: '/login',
name: 'login',
component: LoginView,
meta: { public: true }
},
{
path: '/',
name: 'dashboard',
component: DashboardView
},
{
path: '/chores',
name: 'chores',
component: ChoresView
},
{
path: '/reports',
name: 'reports',
component: ReportsView
},
{
path: '/admin',
name: 'admin',
component: AdminView,
meta: { requiresAdmin: true }
},
{
path: '/profile',
name: 'profile',
component: ProfileView
},
{
path: '/:pathMatch(.*)*',
redirect: '/'
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
if (!authStore.isAuthenticated && !to.meta.public) {
return next({ name: 'login' });
}
if (authStore.isAuthenticated && to.name === 'login') {
return next({ name: 'dashboard' });
}
if (to.meta.requiresAdmin && !authStore.isAdmin) {
return next({ name: 'dashboard' });
}
next();
});
export default router;
+37
View File
@@ -0,0 +1,37 @@
const API_BASE = '/api';
export async function apiFetch(endpoint, options = {}) {
const token = localStorage.getItem('choreus_token');
const headers = {
'Content-Type': 'application/json',
...(options.headers || {})
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers
});
if (response.status === 401) {
// Unauthenticated or token expired
localStorage.removeItem('choreus_token');
localStorage.removeItem('choreus_user');
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const errorMsg = data?.detail || `HTTP Error ${response.status}`;
throw new Error(errorMsg);
}
return data;
}
+106
View File
@@ -0,0 +1,106 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { apiFetch } from '../services/api';
export const useAuthStore = defineStore('auth', () => {
const user = ref(JSON.parse(localStorage.getItem('choreus_user') || 'null'));
const token = ref(localStorage.getItem('choreus_token') || null);
const loading = ref(false);
const error = ref(null);
const isAuthenticated = computed(() => !!token.value && !!user.value);
const isAdmin = computed(() => user.value?.role === 'admin');
async function demoLogin(email) {
loading.value = true;
error.value = null;
try {
const data = await apiFetch('/auth/demo-login', {
method: 'POST',
body: JSON.stringify({ email })
});
setAuthData(data.access_token, data.user);
return data.user;
} catch (err) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
async function googleLogin(credential, payload = {}) {
loading.value = true;
error.value = null;
try {
const data = await apiFetch('/auth/google', {
method: 'POST',
body: JSON.stringify({ credential, ...payload })
});
setAuthData(data.access_token, data.user);
return data.user;
} catch (err) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
}
async function fetchCurrentUser() {
if (!token.value) return null;
try {
const userData = await apiFetch('/auth/me');
user.value = userData;
localStorage.setItem('choreus_user', JSON.stringify(userData));
return userData;
} catch (err) {
logout();
return null;
}
}
async function updateProfile(updates) {
if (!user.value) return;
try {
const updatedUser = await apiFetch(`/users/${user.value.id}`, {
method: 'PATCH',
body: JSON.stringify(updates)
});
user.value = updatedUser;
localStorage.setItem('choreus_user', JSON.stringify(updatedUser));
return updatedUser;
} catch (err) {
error.value = err.message;
throw err;
}
}
function setAuthData(accessToken, userData) {
token.value = accessToken;
user.value = userData;
localStorage.setItem('choreus_token', accessToken);
localStorage.setItem('choreus_user', JSON.stringify(userData));
}
function logout() {
token.value = null;
user.value = null;
localStorage.removeItem('choreus_token');
localStorage.removeItem('choreus_user');
}
return {
user,
token,
loading,
error,
isAuthenticated,
isAdmin,
demoLogin,
googleLogin,
fetchCurrentUser,
updateProfile,
logout
};
});
+189
View File
@@ -0,0 +1,189 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { apiFetch } from '../services/api';
import confetti from 'canvas-confetti';
export const useChoresStore = defineStore('chores', () => {
const choreTypes = ref([]);
const recentCompletions = ref([]);
const weeklyReport = ref(null);
const availableWeeks = ref([]);
const usersList = ref([]);
const loading = ref(false);
const error = ref(null);
async function fetchChoreTypes() {
loading.value = true;
try {
const data = await apiFetch('/chores/types');
choreTypes.value = data;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function completeChore(choreTypeId, notes = '') {
try {
const result = await apiFetch('/chores/complete', {
method: 'POST',
body: JSON.stringify({ chore_type_id: choreTypeId, notes })
});
// Trigger celebratory confetti burst!
confetti({
particleCount: 80,
spread: 70,
origin: { y: 0.6 }
});
// Refresh completions & report
await fetchWeeklyReport();
await fetchRecentCompletions();
return result;
} catch (err) {
error.value = err.message;
throw err;
}
}
async function fetchWeeklyReport(week = null) {
try {
const url = week ? `/reports/weekly?week=${week}` : '/reports/weekly';
const data = await apiFetch(url);
weeklyReport.value = data;
return data;
} catch (err) {
error.value = err.message;
}
}
async function fetchAvailableWeeks() {
try {
const data = await apiFetch('/reports/weeks');
availableWeeks.value = data.weeks || [];
return data;
} catch (err) {
error.value = err.message;
}
}
async function fetchRecentCompletions(week = null, limit = 50) {
try {
const url = week ? `/chores/completions?week=${week}&limit=${limit}` : `/chores/completions?limit=${limit}`;
const data = await apiFetch(url);
recentCompletions.value = data;
} catch (err) {
error.value = err.message;
}
}
async function fetchAllUsers() {
try {
const data = await apiFetch('/users');
usersList.value = data;
return data;
} catch (err) {
error.value = err.message;
}
}
async function createChoreType(choreData) {
try {
const newChore = await apiFetch('/chores/types', {
method: 'POST',
body: JSON.stringify(choreData)
});
await fetchChoreTypes();
return newChore;
} catch (err) {
error.value = err.message;
throw err;
}
}
async function updateChoreType(id, updates) {
try {
const updated = await apiFetch(`/chores/types/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates)
});
await fetchChoreTypes();
return updated;
} catch (err) {
error.value = err.message;
throw err;
}
}
async function deleteChoreType(id) {
try {
await apiFetch(`/chores/types/${id}`, { method: 'DELETE' });
await fetchChoreTypes();
} catch (err) {
error.value = err.message;
throw err;
}
}
async function createUserAccount(userData) {
try {
const newUser = await apiFetch('/users', {
method: 'POST',
body: JSON.stringify(userData)
});
await fetchAllUsers();
return newUser;
} catch (err) {
error.value = err.message;
throw err;
}
}
async function updateUserAccount(id, updates) {
try {
const updated = await apiFetch(`/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates)
});
await fetchAllUsers();
return updated;
} catch (err) {
error.value = err.message;
throw err;
}
}
async function deleteUserAccount(id) {
try {
await apiFetch(`/users/${id}`, { method: 'DELETE' });
await fetchAllUsers();
} catch (err) {
error.value = err.message;
throw err;
}
}
return {
choreTypes,
recentCompletions,
weeklyReport,
availableWeeks,
usersList,
loading,
error,
fetchChoreTypes,
completeChore,
fetchWeeklyReport,
fetchAvailableWeeks,
fetchRecentCompletions,
fetchAllUsers,
createChoreType,
updateChoreType,
deleteChoreType,
createUserAccount,
updateUserAccount,
deleteUserAccount
};
});
+70
View File
@@ -0,0 +1,70 @@
@import "tailwindcss";
@layer base {
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--text-primary: #f8fafc;
--star-amber: #f59e0b;
--emerald-accent: #10b981;
}
}
body {
margin: 0;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #0f172a;
color: #f8fafc;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Custom Glassmorphism styles */
.glass-panel {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-card {
background: rgba(30, 41, 59, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-card:hover {
background: rgba(30, 41, 59, 0.85);
border-color: rgba(245, 158, 11, 0.3);
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(245, 158, 11, 0.1);
}
/* Glowing text & icons */
.glow-amber {
text-shadow: 0 0 12px rgba(245, 158, 11, 0.5);
}
.glow-emerald {
text-shadow: 0 0 12px rgba(16, 185, 129, 0.5);
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.6);
}
::-webkit-scrollbar-thumb {
background: rgba(51, 65, 85, 0.8);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 1);
}
+382
View File
@@ -0,0 +1,382 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
<Shield class="w-8 h-8 text-emerald-400" />
<span>Admin Control Console</span>
</h1>
<p class="text-slate-400 text-sm mt-1">Manage family accounts, star quotas, and configure chore definitions.</p>
</div>
<!-- Section Switcher Tabs -->
<div class="glass-panel p-1 rounded-2xl flex space-x-1 border border-slate-700/50">
<button
@click="activeSection = 'chores'"
class="px-4 py-2 rounded-xl text-xs font-semibold transition-all capitalize"
:class="activeSection === 'chores' ? 'bg-emerald-500 text-slate-950 font-bold shadow-md' : 'text-slate-400 hover:text-slate-200'"
>
Manage Chores
</button>
<button
@click="activeSection = 'users'"
class="px-4 py-2 rounded-xl text-xs font-semibold transition-all capitalize"
:class="activeSection === 'users' ? 'bg-emerald-500 text-slate-950 font-bold shadow-md' : 'text-slate-400 hover:text-slate-200'"
>
Manage Users
</button>
</div>
</div>
<!-- SECTION 1: MANAGE CHORE TYPES -->
<div v-if="activeSection === 'chores'" class="space-y-6">
<div class="flex justify-between items-center">
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
<CheckSquare class="w-5 h-5 text-amber-400" />
<span>Chore Definitions</span>
</h2>
<button
@click="openChoreModal()"
class="bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold px-4 py-2 rounded-xl text-sm flex items-center space-x-1.5 shadow-md transition-all"
>
<Plus class="w-4 h-4" />
<span>Add New Chore</span>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="chore in choresStore.choreTypes"
:key="chore.id"
class="glass-card rounded-2xl p-5 border border-slate-700/50 flex flex-col justify-between"
>
<div>
<div class="flex items-start justify-between mb-3">
<div class="w-10 h-10 rounded-xl bg-slate-800 border border-slate-700 flex items-center justify-center text-amber-400">
<ChoreIcon :name="chore.icon" class="w-5 h-5" />
</div>
<span class="text-[10px] font-bold px-2.5 py-0.5 rounded-full uppercase bg-slate-800 text-slate-300 border border-slate-700">
{{ chore.recurrence }}
</span>
</div>
<h3 class="font-bold text-slate-100 text-base">{{ chore.title }}</h3>
<p class="text-xs text-slate-400 mt-1 line-clamp-2">{{ chore.description || 'No description' }}</p>
<div class="flex items-center space-x-1 mt-3">
<Star v-for="s in 5" :key="s" class="w-3.5 h-3.5" :class="s <= chore.star_reward ? 'fill-amber-400 text-amber-400' : 'text-slate-700'" />
<span class="text-xs font-bold text-amber-400 ml-1">+{{ chore.star_reward }} Stars</span>
</div>
</div>
<div class="flex space-x-2 mt-4 pt-3 border-t border-slate-800">
<button @click="openChoreModal(chore)" class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold py-1.5 rounded-xl border border-slate-700 flex items-center justify-center space-x-1">
<Edit2 class="w-3.5 h-3.5" />
<span>Edit</span>
</button>
<button @click="handleDeleteChore(chore.id)" class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold px-3 py-1.5 rounded-xl border border-rose-500/20 flex items-center justify-center">
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
<!-- SECTION 2: MANAGE USER ACCOUNTS -->
<div v-if="activeSection === 'users'" class="space-y-6">
<div class="flex justify-between items-center">
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
<Users class="w-5 h-5 text-emerald-400" />
<span>Family Member Accounts</span>
</h2>
<button
@click="openUserModal()"
class="bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold px-4 py-2 rounded-xl text-sm flex items-center space-x-1.5 shadow-md transition-all"
>
<UserPlus class="w-4 h-4" />
<span>Add Family Account</span>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="user in choresStore.usersList"
:key="user.id"
class="glass-card rounded-2xl p-5 border border-slate-700/50 flex flex-col justify-between"
>
<div>
<div class="flex items-center space-x-3 mb-3">
<img :src="user.avatar_url || defaultAvatar" class="w-12 h-12 rounded-full border border-slate-700 object-cover bg-slate-800" />
<div>
<h3 class="font-bold text-slate-100 text-base flex items-center space-x-1.5">
<span>{{ user.name }}</span>
<span v-if="user.role === 'admin'" class="text-[10px] bg-emerald-500/20 text-emerald-300 font-normal px-2 py-0.5 rounded-full">Admin</span>
</h3>
<p class="text-xs text-slate-400">{{ user.email }}</p>
</div>
</div>
<div class="bg-slate-900/60 rounded-xl p-3 border border-slate-800 flex justify-between items-center text-xs">
<span class="text-slate-400 font-medium">Weekly Star Quota:</span>
<span class="font-bold text-amber-400 text-sm">{{ user.weekly_star_quota }} Stars / week</span>
</div>
</div>
<div class="flex space-x-2 mt-4 pt-3 border-t border-slate-800">
<button @click="openUserModal(user)" class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold py-1.5 rounded-xl border border-slate-700 flex items-center justify-center space-x-1">
<Edit2 class="w-3.5 h-3.5" />
<span>Edit Account</span>
</button>
<button
v-if="user.id !== authStore.user?.id"
@click="handleDeleteUser(user.id)"
class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold px-3 py-1.5 rounded-xl border border-rose-500/20"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
<!-- MODAL 1: CHORE TYPE EDIT/CREATE -->
<div v-if="showChoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div class="glass-panel w-full max-w-md rounded-3xl p-6 border border-slate-700 shadow-2xl relative">
<h2 class="text-lg font-bold text-slate-100 mb-4">{{ editingChoreId ? 'Edit Chore Type' : 'Create New Chore Type' }}</h2>
<div class="space-y-4 text-xs">
<div>
<label class="block font-semibold text-slate-300 mb-1">Chore Title</label>
<input v-model="choreForm.title" type="text" placeholder="E.g. Vacuum living room" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100" />
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Description</label>
<textarea v-model="choreForm.description" rows="2" placeholder="Details on how to perform the chore..." class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-300 mb-1">Star Reward (1-5 Stars)</label>
<select v-model.number="choreForm.star_reward" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-amber-400 font-bold">
<option :value="1">1 Star (Simple)</option>
<option :value="2">2 Stars</option>
<option :value="3">3 Stars (Medium)</option>
<option :value="4">4 Stars</option>
<option :value="5">5 Stars (Difficult)</option>
</select>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Recurrence Schedule</label>
<select v-model="choreForm.recurrence" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100 capitalize">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="spontaneous">Spontaneous</option>
</select>
</div>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Icon Style</label>
<select v-model="choreForm.icon" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100">
<option value="Utensils">Utensils (Kitchen / Dishwasher)</option>
<option value="Broom">Broom (Vacuum / Sweeping)</option>
<option value="Trash2">Trash (Bins & Recycling)</option>
<option value="Sparkles">Sparkles (Cleaning / Scrubbing)</option>
<option value="Snowflake">Snowflake (Snow removal)</option>
<option value="ShoppingBag">ShoppingBag (Groceries)</option>
<option value="Wrench">Wrench (Maintenance)</option>
<option value="Shirt">Shirt (Laundry)</option>
</select>
</div>
</div>
<div class="flex space-x-3 mt-6">
<button @click="showChoreModal = false" class="w-1/2 bg-slate-800 text-slate-300 py-2.5 rounded-xl border border-slate-700 font-semibold text-xs">Cancel</button>
<button @click="saveChoreForm" class="w-1/2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold py-2.5 rounded-xl text-xs shadow-md">Save Chore</button>
</div>
</div>
</div>
<!-- MODAL 2: USER EDIT/CREATE -->
<div v-if="showUserModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div class="glass-panel w-full max-w-md rounded-3xl p-6 border border-slate-700 shadow-2xl relative">
<h2 class="text-lg font-bold text-slate-100 mb-4">{{ editingUserId ? 'Edit User Account' : 'Add New Family Member' }}</h2>
<div class="space-y-4 text-xs">
<div>
<label class="block font-semibold text-slate-300 mb-1">Full Name</label>
<input v-model="userForm.name" type="text" placeholder="E.g. Alex" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100" />
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Email Address</label>
<input v-model="userForm.email" :disabled="!!editingUserId" type="email" placeholder="alex@family.com" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100 disabled:opacity-50" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-300 mb-1">Account Role</label>
<select v-model="userForm.role" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100">
<option value="regular">Regular User</option>
<option value="admin">Administrator</option>
</select>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Weekly Star Quota</label>
<input v-model.number="userForm.weekly_star_quota" type="number" min="1" max="100" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-amber-400 font-bold" />
</div>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Custom Avatar URL (Optional)</label>
<input v-model="userForm.avatar_url" type="text" placeholder="https://..." class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100" />
</div>
</div>
<div class="flex space-x-3 mt-6">
<button @click="showUserModal = false" class="w-1/2 bg-slate-800 text-slate-300 py-2.5 rounded-xl border border-slate-700 font-semibold text-xs">Cancel</button>
<button @click="saveUserForm" class="w-1/2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold py-2.5 rounded-xl text-xs shadow-md">Save Account</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue';
import { useAuthStore } from '../stores/auth';
import { useChoresStore } from '../stores/chores';
import ChoreIcon from '../components/ChoreIcon.vue';
import {
Shield,
CheckSquare,
Users,
Plus,
UserPlus,
Edit2,
Trash2,
Star
} from '@lucide/vue';
const authStore = useAuthStore();
const choresStore = useChoresStore();
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
const activeSection = ref('chores');
// Chore Modal state
const showChoreModal = ref(false);
const editingChoreId = ref(null);
const choreForm = reactive({
title: '',
description: '',
star_reward: 1,
recurrence: 'daily',
icon: 'Utensils'
});
// User Modal state
const showUserModal = ref(false);
const editingUserId = ref(null);
const userForm = reactive({
name: '',
email: '',
role: 'regular',
weekly_star_quota: 15,
avatar_url: ''
});
onMounted(async () => {
await choresStore.fetchChoreTypes();
await choresStore.fetchAllUsers();
});
function openChoreModal(chore = null) {
if (chore) {
editingChoreId.value = chore.id;
choreForm.title = chore.title;
choreForm.description = chore.description || '';
choreForm.star_reward = chore.star_reward;
choreForm.recurrence = chore.recurrence;
choreForm.icon = chore.icon;
} else {
editingChoreId.value = null;
choreForm.title = '';
choreForm.description = '';
choreForm.star_reward = 1;
choreForm.recurrence = 'daily';
choreForm.icon = 'Utensils';
}
showChoreModal.value = true;
}
async function saveChoreForm() {
if (!choreForm.title) return alert('Title is required');
try {
if (editingChoreId.value) {
await choresStore.updateChoreType(editingChoreId.value, choreForm);
} else {
await choresStore.createChoreType(choreForm);
}
showChoreModal.value = false;
} catch (err) {
alert(err.message || 'Error saving chore');
}
}
async function handleDeleteChore(id) {
if (confirm('Are you sure you want to delete this chore type?')) {
await choresStore.deleteChoreType(id);
}
}
function openUserModal(user = null) {
if (user) {
editingUserId.value = user.id;
userForm.name = user.name;
userForm.email = user.email;
userForm.role = user.role;
userForm.weekly_star_quota = user.weekly_star_quota;
userForm.avatar_url = user.avatar_url || '';
} else {
editingUserId.value = null;
userForm.name = '';
userForm.email = '';
userForm.role = 'regular';
userForm.weekly_star_quota = 15;
userForm.avatar_url = '';
}
showUserModal.value = true;
}
async function saveUserForm() {
if (!userForm.name || !userForm.email) return alert('Name and email are required');
try {
if (editingUserId.value) {
await choresStore.updateUserAccount(editingUserId.value, {
name: userForm.name,
role: userForm.role,
weekly_star_quota: userForm.weekly_star_quota,
avatar_url: userForm.avatar_url || null
});
} else {
await choresStore.createUserAccount(userForm);
}
showUserModal.value = false;
} catch (err) {
alert(err.message || 'Error saving user');
}
}
async function handleDeleteUser(id) {
if (confirm('Are you sure you want to delete this user account?')) {
await choresStore.deleteUserAccount(id);
}
}
</script>
+233
View File
@@ -0,0 +1,233 @@
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
<CheckSquare class="w-8 h-8 text-amber-400" />
<span>Available Chores</span>
</h1>
<p class="text-slate-400 text-sm mt-1">Select a household chore, complete it, and earn your star reward!</p>
</div>
<!-- Recurrence Filter Tabs -->
<div class="glass-panel p-1 rounded-2xl flex space-x-1 border border-slate-700/50 w-full sm:w-auto">
<button
v-for="tab in tabs"
:key="tab.value"
@click="activeTab = tab.value"
class="px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 capitalize flex-1 sm:flex-initial"
:class="activeTab === tab.value ? 'bg-amber-500 text-slate-950 shadow-md font-bold' : 'text-slate-400 hover:text-slate-200'"
>
{{ tab.label }}
</button>
</div>
</div>
<!-- Search & Quick Filters -->
<div class="relative">
<Search class="w-5 h-5 absolute left-4 top-3.5 text-slate-500" />
<input
v-model="searchQuery"
type="text"
placeholder="Search chores by title or description..."
class="w-full glass-panel border border-slate-700/60 rounded-2xl pl-12 pr-4 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-amber-400/60 transition-all"
/>
</div>
<!-- Loading / Empty State -->
<div v-if="choresStore.loading" class="text-center py-12 text-slate-400">
Loading family chores...
</div>
<div v-else-if="filteredChores.length === 0" class="text-center py-12 glass-panel rounded-3xl border border-slate-800">
<Sparkles class="w-12 h-12 text-slate-600 mx-auto mb-3" />
<p class="text-slate-300 font-semibold">No chores found</p>
<p class="text-xs text-slate-500 mt-1">Try adjusting your filter or search query.</p>
</div>
<!-- Chores Cards Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<div
v-for="chore in filteredChores"
:key="chore.id"
class="glass-card rounded-3xl p-6 border border-slate-700/50 flex flex-col justify-between group hover:border-amber-400/50 transition-all duration-300"
>
<div>
<!-- Card Header & Badge -->
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 rounded-2xl bg-slate-800/80 border border-slate-700/80 flex items-center justify-center text-amber-400 group-hover:scale-110 group-hover:bg-amber-500/10 transition-all">
<ChoreIcon :name="chore.icon" class="w-6 h-6" />
</div>
<span
class="text-[11px] font-bold px-3 py-1 rounded-full uppercase tracking-wider border"
:class="getRecurrenceBadgeStyle(chore.recurrence)"
>
{{ chore.recurrence }}
</span>
</div>
<!-- Title & Description -->
<h2 class="text-lg font-bold text-slate-100 group-hover:text-amber-400 transition-colors">
{{ chore.title }}
</h2>
<p class="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
{{ chore.description || 'No description specified.' }}
</p>
<!-- Star Reward Display -->
<div class="flex items-center space-x-1 my-4">
<span class="text-xs text-slate-400 font-semibold mr-2">Reward:</span>
<div class="flex items-center space-x-0.5">
<Star
v-for="s in 5"
:key="s"
class="w-4 h-4"
:class="s <= chore.star_reward ? 'fill-amber-400 text-amber-400 glow-amber' : 'text-slate-700'"
/>
</div>
<span class="text-xs font-bold text-amber-400 ml-1.5">+{{ chore.star_reward }} Stars</span>
</div>
</div>
<!-- Execute Action Button -->
<button
@click="openCompleteModal(chore)"
class="w-full mt-2 bg-slate-800 hover:bg-gradient-to-r hover:from-amber-500 hover:to-amber-600 hover:text-slate-950 text-amber-400 border border-amber-500/30 font-bold py-2.5 px-4 rounded-2xl flex items-center justify-center space-x-2 transition-all duration-200 shadow-md group-hover:border-amber-400"
>
<CheckCircle2 class="w-5 h-5" />
<span>Mark Completed</span>
</button>
</div>
</div>
<!-- Complete Chore Modal -->
<div v-if="selectedChore" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<div class="glass-panel w-full max-w-md rounded-3xl p-6 border border-slate-700 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
<button @click="selectedChore = null" class="absolute top-4 right-4 text-slate-400 hover:text-white p-1">
<X class="w-5 h-5" />
</button>
<div class="text-center mb-6">
<div class="w-14 h-14 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center mx-auto mb-3 text-amber-400">
<ChoreIcon :name="selectedChore.icon" class="w-8 h-8" />
</div>
<h2 class="text-xl font-bold text-slate-100">{{ selectedChore.title }}</h2>
<p class="text-xs text-slate-400 mt-1">{{ selectedChore.description }}</p>
<div class="inline-flex items-center space-x-1.5 bg-amber-500/15 border border-amber-500/30 px-3 py-1 rounded-full text-amber-300 font-bold text-sm mt-3">
<Star class="w-4 h-4 fill-amber-300" />
<span>Earn +{{ selectedChore.star_reward }} Stars</span>
</div>
</div>
<div class="mb-6">
<label class="block text-xs font-semibold text-slate-300 mb-2">Optional Notes or Proof:</label>
<textarea
v-model="completionNotes"
rows="3"
placeholder="E.g., Done! Cleaned under the couch too."
class="w-full bg-slate-900 border border-slate-700 rounded-2xl p-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-amber-400"
></textarea>
</div>
<div class="flex space-x-3">
<button
@click="selectedChore = null"
class="w-1/2 bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold py-3 rounded-2xl border border-slate-700"
>
Cancel
</button>
<button
@click="confirmCompletion"
:disabled="submitting"
class="w-1/2 bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-bold py-3 rounded-2xl shadow-lg shadow-amber-500/25 flex items-center justify-center space-x-2"
>
<Sparkles class="w-5 h-5" />
<span>Claim Stars!</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useChoresStore } from '../stores/chores';
import ChoreIcon from '../components/ChoreIcon.vue';
import {
CheckSquare,
Search,
Star,
CheckCircle2,
Sparkles,
X
} from '@lucide/vue';
const choresStore = useChoresStore();
const activeTab = ref('all');
const searchQuery = ref('');
const selectedChore = ref(null);
const completionNotes = ref('');
const submitting = ref(false);
const tabs = [
{ label: 'All Chores', value: 'all' },
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Spontaneous', value: 'spontaneous' }
];
onMounted(async () => {
await choresStore.fetchChoreTypes();
});
const filteredChores = computed(() => {
let list = choresStore.choreTypes;
if (activeTab.value !== 'all') {
list = list.filter(c => c.recurrence === activeTab.value);
}
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase();
list = list.filter(c => c.title.toLowerCase().includes(q) || (c.description && c.description.toLowerCase().includes(q)));
}
return list;
});
function openCompleteModal(chore) {
selectedChore.value = chore;
completionNotes.value = '';
}
async function confirmCompletion() {
if (!selectedChore.value) return;
submitting.value = true;
try {
await choresStore.completeChore(selectedChore.value.id, completionNotes.value);
selectedChore.value = null;
} catch (err) {
alert(err.message || 'Failed to record completion');
} finally {
submitting.value = false;
}
}
function getRecurrenceBadgeStyle(recurrence) {
switch (recurrence) {
case 'daily':
return 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30';
case 'weekly':
return 'bg-indigo-500/15 text-indigo-300 border-indigo-500/30';
case 'spontaneous':
return 'bg-amber-500/15 text-amber-300 border-amber-500/30';
default:
return 'bg-slate-700 text-slate-300 border-slate-600';
}
}
</script>
+236
View File
@@ -0,0 +1,236 @@
<template>
<div class="space-y-6">
<!-- Header Banner -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50 bg-gradient-to-r from-slate-900 via-slate-900/90 to-amber-950/30 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div>
<div class="flex items-center space-x-2 text-xs font-semibold text-amber-400 uppercase tracking-wider mb-1">
<Calendar class="w-4 h-4" />
<span>Week {{ currentWeekDisplay }} Overview</span>
</div>
<h1 class="text-2xl md:text-3xl font-extrabold text-slate-100">
Welcome back, <span class="bg-gradient-to-r from-amber-400 to-emerald-400 bg-clip-text text-transparent">{{ authStore.user?.name }}</span>!
</h1>
<p class="text-slate-400 text-sm mt-1">Here is how your family is progressing on household chores this week.</p>
</div>
<router-link
to="/chores"
class="bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-bold px-5 py-3 rounded-2xl flex items-center space-x-2 shadow-lg shadow-amber-500/25 transition-all duration-200"
>
<PlusCircle class="w-5 h-5" />
<span>Complete a Chore</span>
</router-link>
</div>
<!-- Quick Stats Cards Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- My Weekly Quota Progress -->
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
<div class="flex justify-between items-start mb-3">
<div>
<span class="text-xs font-semibold text-slate-400 uppercase">My Weekly Progress</span>
<h2 class="text-2xl font-black text-amber-400 mt-1">
{{ myProgress?.stars_earned || 0 }} <span class="text-sm font-medium text-slate-400">/ {{ authStore.user?.weekly_star_quota }} Stars</span>
</h2>
</div>
<div class="p-3 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20">
<Star class="w-6 h-6 fill-amber-400" />
</div>
</div>
<div class="w-full bg-slate-800 rounded-full h-3 mb-2 overflow-hidden">
<div
class="bg-gradient-to-r from-amber-500 to-emerald-400 h-3 rounded-full transition-all duration-500"
:style="{ width: `${myProgressPercentage}%` }"
></div>
</div>
<p class="text-xs text-slate-400 flex justify-between">
<span>{{ myProgressPercentage }}% quota completed</span>
<span class="text-amber-400 font-semibold">{{ myStarsRemaining }} stars remaining</span>
</p>
</div>
<!-- Family Total Stars -->
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
<div class="flex justify-between items-start mb-3">
<div>
<span class="text-xs font-semibold text-slate-400 uppercase">Family Team Score</span>
<h2 class="text-2xl font-black text-emerald-400 mt-1">
{{ choresStore.weeklyReport?.total_stars_earned || 0 }} <span class="text-sm font-medium text-slate-400">Stars</span>
</h2>
</div>
<div class="p-3 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<Trophy class="w-6 h-6" />
</div>
</div>
<p class="text-xs text-slate-400 mt-4">
Total of <span class="text-slate-200 font-bold">{{ choresStore.weeklyReport?.total_completions || 0 }}</span> chores completed by family members this week!
</p>
</div>
<!-- Weekly Leader -->
<div class="glass-card rounded-2xl p-5 border border-slate-700/50 sm:col-span-2 lg:col-span-1">
<div class="flex justify-between items-start mb-3">
<div>
<span class="text-xs font-semibold text-slate-400 uppercase">Weekly Chore Champ</span>
<h2 class="text-xl font-bold text-slate-100 mt-1 flex items-center space-x-2">
<span>{{ topPerformer?.name || 'No Activity Yet' }}</span>
<span v-if="topPerformer" class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 font-medium">1st Place 🏆</span>
</h2>
</div>
<div class="w-10 h-10 rounded-full border border-amber-400/40 overflow-hidden bg-slate-800">
<img :src="topPerformer?.avatar_url || defaultAvatar" class="w-full h-full object-cover" />
</div>
</div>
<p class="text-xs text-slate-400 mt-4">
Leading with <span class="text-amber-400 font-bold">{{ topPerformer?.stars_earned || 0 }} stars</span> logged!
</p>
</div>
</div>
<!-- Family Member Completion Progress Bars -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
<Users class="w-5 h-5 text-amber-400" />
<span>Family Progress Dashboard</span>
</h2>
<p class="text-xs text-slate-400">Chore completion status for each family member</p>
</div>
<router-link to="/reports" class="text-xs font-semibold text-amber-400 hover:underline">
View Detailed Reports &rarr;
</router-link>
</div>
<div class="space-y-4">
<div
v-for="u in choresStore.weeklyReport?.user_progress || []"
:key="u.user_id"
class="glass-card rounded-2xl p-4 border border-slate-700/40 hover:border-slate-600 transition-all"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-3">
<img :src="u.avatar_url || defaultAvatar" class="w-10 h-10 rounded-full bg-slate-800 border border-slate-600 object-cover" />
<div>
<div class="flex items-center space-x-2">
<span class="font-bold text-sm text-slate-100">{{ u.name }}</span>
<span v-if="u.user_id === authStore.user?.id" class="text-[10px] bg-amber-500/20 text-amber-300 font-medium px-2 py-0.5 rounded-full">You</span>
<span v-if="u.role === 'admin'" class="text-[10px] bg-emerald-500/20 text-emerald-300 font-medium px-2 py-0.5 rounded-full">Admin</span>
</div>
<span class="text-xs text-slate-400">{{ u.completions_count }} chores logged</span>
</div>
</div>
<div class="text-right">
<div class="text-sm font-extrabold text-amber-400 flex items-center justify-end space-x-1">
<span>{{ u.stars_earned }} / {{ u.weekly_star_quota }}</span>
<Star class="w-4 h-4 fill-amber-400 text-amber-400" />
</div>
<span class="text-xs text-slate-400">{{ u.percentage }}%</span>
</div>
</div>
<!-- Progress Bar -->
<div class="w-full bg-slate-800 rounded-full h-2.5 overflow-hidden">
<div
class="h-2.5 rounded-full transition-all duration-500"
:class="u.percentage >= 100 ? 'bg-gradient-to-r from-emerald-500 to-teal-400' : 'bg-gradient-to-r from-amber-500 to-amber-400'"
:style="{ width: `${Math.min(100, u.percentage)}%` }"
></div>
</div>
</div>
</div>
</div>
<!-- Recent Completed Chores Stream -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
<Sparkles class="w-5 h-5 text-emerald-400" />
<span>Recent Family Activity</span>
</h2>
<span class="text-xs text-slate-400">Live Activity Feed</span>
</div>
<div v-if="choresStore.recentCompletions.length === 0" class="text-center py-8 text-slate-400 text-sm">
No chores logged yet this week. Be the first to earn stars!
</div>
<div class="space-y-3">
<div
v-for="c in choresStore.recentCompletions.slice(0, 5)"
:key="c.id"
class="glass-card rounded-xl p-3 flex items-center justify-between border border-slate-800"
>
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400">
<ChoreIcon :name="c.chore_icon" class="w-5 h-5" />
</div>
<div>
<p class="text-sm font-semibold text-slate-200">{{ c.chore_title }}</p>
<p class="text-xs text-slate-400">Completed by <span class="text-slate-300 font-medium">{{ c.user_name }}</span> &bull; {{ formatDate(c.completed_at) }}</p>
<p v-if="c.notes" class="text-xs text-slate-400 italic mt-0.5">"{{ c.notes }}"</p>
</div>
</div>
<div class="flex items-center space-x-1 bg-amber-500/15 border border-amber-500/30 text-amber-300 text-xs font-bold px-2.5 py-1 rounded-full">
<span>+{{ c.stars_earned }}</span>
<Star class="w-3.5 h-3.5 fill-amber-300" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, computed } from 'vue';
import { useAuthStore } from '../stores/auth';
import { useChoresStore } from '../stores/chores';
import ChoreIcon from '../components/ChoreIcon.vue';
import {
Calendar,
PlusCircle,
Star,
Trophy,
Users,
Sparkles
} from '@lucide/vue';
const authStore = useAuthStore();
const choresStore = useChoresStore();
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
onMounted(async () => {
await choresStore.fetchWeeklyReport();
await choresStore.fetchRecentCompletions();
});
const currentWeekDisplay = computed(() => {
return choresStore.weeklyReport?.week_identifier || '';
});
const myProgress = computed(() => {
return choresStore.weeklyReport?.user_progress.find(u => u.user_id === authStore.user?.id);
});
const myProgressPercentage = computed(() => {
if (!myProgress.value) return 0;
return myProgress.value.percentage;
});
const myStarsRemaining = computed(() => {
const quota = authStore.user?.weekly_star_quota || 15;
const earned = myProgress.value?.stars_earned || 0;
return Math.max(0, quota - earned);
});
const topPerformer = computed(() => {
if (!choresStore.weeklyReport?.user_progress?.length) return null;
return choresStore.weeklyReport.user_progress[0];
});
function formatDate(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ', ' + d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
</script>
+129
View File
@@ -0,0 +1,129 @@
<template>
<div class="min-h-screen bg-slate-950 flex flex-col justify-center items-center p-4 relative overflow-hidden">
<!-- Ambient background glow -->
<div class="absolute -top-40 -left-40 w-96 h-96 bg-amber-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="absolute -bottom-40 -right-40 w-96 h-96 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="w-full max-w-md glass-panel rounded-3xl p-8 border border-slate-700/60 shadow-2xl shadow-slate-950/80 z-10">
<!-- Logo & Welcome -->
<div class="text-center mb-8">
<div class="w-16 h-16 rounded-2xl bg-gradient-to-tr from-amber-500 via-amber-400 to-emerald-400 flex items-center justify-center mx-auto mb-4 shadow-xl shadow-amber-500/25">
<Sparkles class="w-9 h-9 text-slate-950 font-bold" />
</div>
<h1 class="text-3xl font-extrabold tracking-tight bg-gradient-to-r from-amber-400 via-amber-200 to-emerald-400 bg-clip-text text-transparent">
ChoreUS
</h1>
<p class="text-slate-400 text-sm mt-1">Family Chore & Reward Manager</p>
</div>
<div v-if="error" class="mb-4 p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-sm text-center">
{{ error }}
</div>
<!-- Quick Demo Login Accounts -->
<div class="mb-8">
<h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 text-center">
Quick Demo Login
</h2>
<div class="space-y-2.5">
<button
@click="handleDemoLogin('admin@choreus.app')"
class="w-full glass-card hover:border-amber-400/50 p-3 rounded-2xl flex items-center justify-between text-left transition-all duration-200 group"
>
<div class="flex items-center space-x-3">
<img src="https://api.dicebear.com/7.x/bottts/svg?seed=SarahAdmin" class="w-10 h-10 rounded-full bg-slate-800 border border-amber-400/40" />
<div>
<p class="text-sm font-bold text-slate-100 group-hover:text-amber-400 transition-colors">Sarah (Admin)</p>
<p class="text-xs text-slate-400">Parent / Administrator Account</p>
</div>
</div>
<Shield class="w-5 h-5 text-amber-400" />
</button>
<button
@click="handleDemoLogin('leo@choreus.app')"
class="w-full glass-card hover:border-amber-400/50 p-3 rounded-2xl flex items-center justify-between text-left transition-all duration-200 group"
>
<div class="flex items-center space-x-3">
<img src="https://api.dicebear.com/7.x/bottts/svg?seed=LeoKid" class="w-10 h-10 rounded-full bg-slate-800 border border-emerald-400/40" />
<div>
<p class="text-sm font-bold text-slate-100 group-hover:text-emerald-400 transition-colors">Leo (Kid)</p>
<p class="text-xs text-slate-400">Regular Family Member (15 Stars Quota)</p>
</div>
</div>
<User class="w-5 h-5 text-emerald-400" />
</button>
<button
@click="handleDemoLogin('maya@choreus.app')"
class="w-full glass-card hover:border-amber-400/50 p-3 rounded-2xl flex items-center justify-between text-left transition-all duration-200 group"
>
<div class="flex items-center space-x-3">
<img src="https://api.dicebear.com/7.x/bottts/svg?seed=MayaKid" class="w-10 h-10 rounded-full bg-slate-800 border border-indigo-400/40" />
<div>
<p class="text-sm font-bold text-slate-100 group-hover:text-indigo-400 transition-colors">Maya (Kid)</p>
<p class="text-xs text-slate-400">Regular Family Member (15 Stars Quota)</p>
</div>
</div>
<User class="w-5 h-5 text-indigo-400" />
</button>
</div>
</div>
<!-- Google OAuth Sign In Divider & Button -->
<div class="relative mb-6">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-700/60"></div></div>
<div class="relative flex justify-center text-xs uppercase"><span class="bg-slate-900 px-3 text-slate-400 font-medium">or continue with</span></div>
</div>
<button
@click="handleGoogleMockLogin"
class="w-full bg-slate-800 hover:bg-slate-700 text-slate-100 font-semibold py-3 px-4 rounded-2xl border border-slate-600/50 flex items-center justify-center space-x-3 transition-all duration-200 shadow-md"
>
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"/>
</svg>
<span>Sign in with Google</span>
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { Sparkles, Shield, User } from '@lucide/vue';
const authStore = useAuthStore();
const router = useRouter();
const error = ref(null);
async function handleDemoLogin(email) {
try {
error.value = null;
await authStore.demoLogin(email);
router.push('/');
} catch (err) {
error.value = err.message || 'Login failed';
}
}
async function handleGoogleMockLogin() {
try {
error.value = null;
// Simulates Google OAuth flow for test / environment demonstration
await authStore.googleLogin('google_oauth_token', {
email: 'family_google_user@choreus.app',
name: 'Google Family User',
avatar_url: 'https://api.dicebear.com/7.x/bottts/svg?seed=GoogleUser'
});
router.push('/');
} catch (err) {
error.value = err.message || 'Google Auth failed';
}
}
</script>
+115
View File
@@ -0,0 +1,115 @@
<template>
<div class="max-w-2xl mx-auto space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
<User class="w-8 h-8 text-amber-400" />
<span>My Profile & Settings</span>
</h1>
<p class="text-slate-400 text-sm mt-1">Configure your avatar, account details, and personal quotas.</p>
</div>
<div v-if="successMsg" class="p-3 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-sm text-center font-medium">
{{ successMsg }}
</div>
<!-- Profile Card -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50 space-y-6">
<!-- Avatar Section -->
<div class="flex flex-col sm:flex-row items-center space-y-4 sm:space-y-0 sm:space-x-6 pb-6 border-b border-slate-800">
<img :src="avatarUrl || defaultAvatar" class="w-24 h-24 rounded-full border-2 border-amber-400/50 shadow-xl object-cover bg-slate-800" />
<div class="space-y-2 text-center sm:text-left flex-1">
<h2 class="text-xl font-bold text-slate-100">{{ authStore.user?.name }}</h2>
<p class="text-xs text-slate-400">{{ authStore.user?.email }}</p>
<div class="inline-flex items-center space-x-1 bg-amber-500/15 border border-amber-500/30 px-3 py-1 rounded-full text-xs font-semibold text-amber-300">
<Shield class="w-3.5 h-3.5" />
<span class="capitalize">{{ authStore.user?.role }} Account</span>
</div>
</div>
</div>
<!-- Editable Profile Form -->
<form @submit.prevent="handleSaveProfile" class="space-y-4 text-xs">
<div>
<label class="block font-semibold text-slate-300 mb-1">Display Name</label>
<input
v-model="name"
type="text"
required
class="w-full bg-slate-900 border border-slate-700 rounded-2xl p-3 text-sm text-slate-100 focus:outline-none focus:border-amber-400"
/>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Avatar Image URL</label>
<input
v-model="avatarUrl"
type="text"
placeholder="https://api.dicebear.com/..."
class="w-full bg-slate-900 border border-slate-700 rounded-2xl p-3 text-sm text-slate-100 focus:outline-none focus:border-amber-400"
/>
<p class="text-[11px] text-slate-500 mt-1">Leave empty to use your default Google account avatar.</p>
</div>
<div>
<label class="block font-semibold text-slate-300 mb-1">Weekly Star Quota</label>
<input
:value="authStore.user?.weekly_star_quota"
disabled
type="number"
class="w-full bg-slate-900/50 border border-slate-800 rounded-2xl p-3 text-sm text-amber-400 font-bold opacity-70 cursor-not-allowed"
/>
<p class="text-[11px] text-slate-500 mt-1">Weekly star quotas are configured by family Administrators.</p>
</div>
<button
type="submit"
:disabled="saving"
class="w-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-bold py-3 rounded-2xl shadow-lg shadow-amber-500/25 transition-all text-sm mt-4 flex items-center justify-center space-x-2"
>
<Save class="w-4 h-4" />
<span>Save Profile Changes</span>
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useAuthStore } from '../stores/auth';
import { User, Shield, Save } from '@lucide/vue';
const authStore = useAuthStore();
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
const name = ref('');
const avatarUrl = ref('');
const saving = ref(false);
const successMsg = ref('');
onMounted(() => {
if (authStore.user) {
name.value = authStore.user.name || '';
avatarUrl.value = authStore.user.avatar_url || '';
}
});
async function handleSaveProfile() {
saving.value = true;
successMsg.value = '';
try {
await authStore.updateProfile({
name: name.value,
avatar_url: avatarUrl.value || null
});
successMsg.value = 'Profile updated successfully!';
setTimeout(() => { successMsg.value = ''; }, 3000);
} catch (err) {
alert(err.message || 'Failed to update profile');
} finally {
saving.value = false;
}
}
</script>
+201
View File
@@ -0,0 +1,201 @@
<template>
<div class="space-y-6">
<!-- Page Header & Week Selector -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
<BarChart3 class="w-8 h-8 text-amber-400" />
<span>Weekly Chore Reports</span>
</h1>
<p class="text-slate-400 text-sm mt-1">Review family progress, completed tasks, and historical weekly achievements.</p>
</div>
<!-- Week Dropdown Selector -->
<div class="flex items-center space-x-2 w-full md:w-auto">
<label class="text-xs font-semibold text-slate-400 whitespace-nowrap">Select Week:</label>
<select
v-model="selectedWeek"
@change="onWeekChange"
class="glass-card bg-slate-900 border border-slate-700/80 rounded-2xl px-4 py-2.5 text-sm text-amber-400 font-bold focus:outline-none focus:border-amber-400 w-full md:w-auto"
>
<option v-for="w in choresStore.availableWeeks" :key="w" :value="w">
Week {{ w }} {{ w === currentWeek ? '(Current)' : '' }}
</option>
</select>
</div>
</div>
<!-- Summary Metrics Grid -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
<span class="text-xs font-semibold text-slate-400 uppercase">Total Stars Earned</span>
<div class="flex items-center space-x-2 mt-1">
<span class="text-3xl font-black text-amber-400">{{ choresStore.weeklyReport?.total_stars_earned || 0 }}</span>
<Star class="w-7 h-7 fill-amber-400 text-amber-400 glow-amber" />
</div>
<p class="text-xs text-slate-400 mt-2">Combined family total for Week {{ selectedWeek }}</p>
</div>
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
<span class="text-xs font-semibold text-slate-400 uppercase">Chores Completed</span>
<div class="flex items-center space-x-2 mt-1">
<span class="text-3xl font-black text-emerald-400">{{ choresStore.weeklyReport?.total_completions || 0 }}</span>
<CheckCircle2 class="w-7 h-7 text-emerald-400 glow-emerald" />
</div>
<p class="text-xs text-slate-400 mt-2">Logged task executions</p>
</div>
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
<span class="text-xs font-semibold text-slate-400 uppercase">My Stars Remaining</span>
<div class="flex items-center space-x-2 mt-1">
<span class="text-3xl font-black text-indigo-400">{{ myStarsRemaining }}</span>
<span class="text-xs font-bold text-slate-400">/ {{ authStore.user?.weekly_star_quota }} Quota</span>
</div>
<p class="text-xs text-slate-400 mt-2">Needed to hit weekly quota</p>
</div>
</div>
<!-- Family Progress Breakdown Table/List -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
<h2 class="text-lg font-bold text-slate-100 mb-4 flex items-center space-x-2">
<Users class="w-5 h-5 text-amber-400" />
<span>Family Quota Status (Week {{ selectedWeek }})</span>
</h2>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm text-slate-300">
<thead class="text-xs uppercase bg-slate-900/60 text-slate-400 border-b border-slate-700/60">
<tr>
<th class="px-4 py-3 rounded-l-xl">Family Member</th>
<th class="px-4 py-3">Stars Earned</th>
<th class="px-4 py-3">Weekly Quota</th>
<th class="px-4 py-3">Stars Left</th>
<th class="px-4 py-3 rounded-r-xl">Progress</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/60">
<tr v-for="user in choresStore.weeklyReport?.user_progress || []" :key="user.user_id" class="hover:bg-slate-800/40">
<td class="px-4 py-3.5 flex items-center space-x-3 font-semibold text-slate-100">
<img :src="user.avatar_url || defaultAvatar" class="w-8 h-8 rounded-full border border-slate-700 object-cover" />
<span>{{ user.name }}</span>
<span v-if="user.user_id === authStore.user?.id" class="text-[10px] bg-amber-500/20 text-amber-300 px-2 py-0.5 rounded-full font-normal">You</span>
</td>
<td class="px-4 py-3 font-extrabold text-amber-400">+{{ user.stars_earned }} Stars</td>
<td class="px-4 py-3 text-slate-400">{{ user.weekly_star_quota }} Stars</td>
<td class="px-4 py-3 font-semibold" :class="Math.max(0, user.weekly_star_quota - user.stars_earned) === 0 ? 'text-emerald-400' : 'text-slate-300'">
{{ Math.max(0, user.weekly_star_quota - user.stars_earned) }} Left
</td>
<td class="px-4 py-3 w-48">
<div class="flex items-center space-x-2">
<div class="w-full bg-slate-800 rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full transition-all duration-500"
:class="user.percentage >= 100 ? 'bg-emerald-400' : 'bg-amber-400'"
:style="{ width: `${Math.min(100, user.percentage)}%` }"
></div>
</div>
<span class="text-xs text-slate-400 font-mono">{{ user.percentage }}%</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Detailed Chore Completion Log -->
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
<h2 class="text-lg font-bold text-slate-100 mb-4 flex items-center space-x-2">
<Clock class="w-5 h-5 text-emerald-400" />
<span>Chore History Log</span>
</h2>
<div v-if="choresStore.recentCompletions.length === 0" class="text-center py-8 text-slate-400 text-sm">
No completed chores recorded for Week {{ selectedWeek }}.
</div>
<div v-else class="space-y-3">
<div
v-for="log in choresStore.recentCompletions"
:key="log.id"
class="glass-card rounded-2xl p-4 border border-slate-800 flex items-center justify-between"
>
<div class="flex items-center space-x-3">
<img :src="log.user_avatar || defaultAvatar" class="w-9 h-9 rounded-full border border-slate-700 object-cover" />
<div>
<div class="flex items-center space-x-2">
<span class="font-bold text-sm text-slate-100">{{ log.chore_title }}</span>
<span class="text-xs text-slate-400">by {{ log.user_name }}</span>
</div>
<p class="text-xs text-slate-400 flex items-center space-x-1 mt-0.5">
<Calendar class="w-3.5 h-3.5 text-slate-500" />
<span>{{ formatDate(log.completed_at) }}</span>
<span v-if="log.notes" class="text-slate-300 italic ml-2">&bull; "{{ log.notes }}"</span>
</p>
</div>
</div>
<div class="flex items-center space-x-1 bg-amber-500/15 border border-amber-500/30 text-amber-300 text-xs font-bold px-3 py-1 rounded-full whitespace-nowrap">
<span>+{{ log.stars_earned }} Stars</span>
<Star class="w-3.5 h-3.5 fill-amber-300" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useAuthStore } from '../stores/auth';
import { useChoresStore } from '../stores/chores';
import {
BarChart3,
Star,
CheckCircle2,
Users,
Clock,
Calendar
} from '@lucide/vue';
const authStore = useAuthStore();
const choresStore = useChoresStore();
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
const selectedWeek = ref('');
const currentWeek = ref('');
onMounted(async () => {
const weekData = await choresStore.fetchAvailableWeeks();
if (weekData) {
currentWeek.value = weekData.current_week;
selectedWeek.value = weekData.current_week;
}
await loadWeekData(selectedWeek.value);
});
async function onWeekChange() {
await loadWeekData(selectedWeek.value);
}
async function loadWeekData(week) {
await choresStore.fetchWeeklyReport(week);
await choresStore.fetchRecentCompletions(week);
}
const myProgress = computed(() => {
return choresStore.weeklyReport?.user_progress.find(u => u.user_id === authStore.user?.id);
});
const myStarsRemaining = computed(() => {
const quota = authStore.user?.weekly_star_quota || 15;
const earned = myProgress.value?.stars_earned || 0;
return Math.max(0, quota - earned);
});
function formatDate(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) + ' at ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
</script>
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}
})