Initial project scaffold

Full-stack homeschool web app with FastAPI backend, Vue 3 frontend,
MySQL database, and Docker Compose orchestration. Includes JWT auth,
WebSocket real-time TV dashboard, schedule builder, activity logging,
and multi-child support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 22:56:31 -08:00
parent 93e0494864
commit 417b3adfe8
68 changed files with 3919 additions and 0 deletions

View File

@@ -0,0 +1,264 @@
<template>
<div class="page">
<NavBar />
<main class="container">
<div class="page-header">
<h1>Dashboard</h1>
<ChildSelector />
</div>
<div v-if="!activeChild" class="empty-state">
<p>Add a child in <RouterLink to="/admin">Admin</RouterLink> to get started.</p>
</div>
<div v-else class="dashboard-grid">
<!-- Today's session card -->
<div class="card session-card">
<div class="card-title">Today's Session</div>
<div v-if="scheduleStore.session">
<div class="session-info">
<span class="badge-active">Active</span>
<span>{{ scheduleStore.progressPercent }}% complete</span>
</div>
<ProgressBar :percent="scheduleStore.progressPercent" />
<div class="session-actions">
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id"
@click="sendAction('pause')"
>Pause</button>
<button class="btn-sm" @click="sendAction('start')">Resume</button>
<button class="btn-sm btn-danger" @click="sendAction('complete')">End Day</button>
</div>
</div>
<div v-else class="no-session">
<p>No active session.</p>
<button class="btn-primary" @click="showStartDialog = true">Start Day</button>
</div>
</div>
<!-- Schedule blocks -->
<div class="card">
<div class="card-title">Today's Schedule</div>
<div v-if="scheduleStore.blocks.length === 0" class="empty-small">
No blocks loaded.
</div>
<div class="block-list" v-else>
<ScheduleBlock
v-for="block in scheduleStore.blocks"
:key="block.id"
:block="block"
:is-current="block.id === scheduleStore.session?.current_block_id"
:is-completed="scheduleStore.completedBlockIds.includes(block.id)"
@click="selectBlock(block)"
/>
</div>
</div>
<!-- TV Link -->
<div class="card tv-card">
<div class="card-title">TV Dashboard</div>
<p class="tv-desc">Open this on the living room TV for the full-screen view.</p>
<a :href="`/tv/${activeChild.id}`" target="_blank" class="btn-primary">
Open TV View →
</a>
</div>
</div>
<!-- Start session dialog -->
<div class="dialog-overlay" v-if="showStartDialog" @click.self="showStartDialog = false">
<div class="dialog">
<h2>Start School Day</h2>
<div class="field">
<label>Schedule Template</label>
<select v-model="selectedTemplate">
<option :value="null">No template (freestyle)</option>
<option v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</div>
<div class="dialog-actions">
<button @click="showStartDialog = false">Cancel</button>
<button class="btn-primary" @click="startSession">Start</button>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue'
import { useChildrenStore } from '@/stores/children'
import { useScheduleStore } from '@/stores/schedule'
import { useWebSocket } from '@/composables/useWebSocket'
import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue'
import ChildSelector from '@/components/ChildSelector.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import ScheduleBlock from '@/components/ScheduleBlock.vue'
const childrenStore = useChildrenStore()
const scheduleStore = useScheduleStore()
const activeChild = computed(() => childrenStore.activeChild)
const showStartDialog = ref(false)
const selectedTemplate = ref(null)
const templates = ref([])
let wsDisconnect = null
async function loadDashboard() {
if (!activeChild.value) return
await scheduleStore.fetchDashboard(activeChild.value.id)
// Load templates for start dialog
const res = await api.get('/api/schedules')
templates.value = res.data
// WS subscription
if (wsDisconnect) wsDisconnect()
const { disconnect } = useWebSocket(activeChild.value.id, (msg) => {
scheduleStore.applyWsEvent(msg)
})
wsDisconnect = disconnect
}
async function startSession() {
await scheduleStore.startSession(activeChild.value.id, selectedTemplate.value)
showStartDialog.value = false
await loadDashboard()
}
async function sendAction(type) {
if (!scheduleStore.session) return
await scheduleStore.sendTimerAction(scheduleStore.session.id, type)
}
function selectBlock(block) {
if (!scheduleStore.session) return
scheduleStore.sendTimerAction(scheduleStore.session.id, 'start', block.id)
}
onMounted(async () => {
await childrenStore.fetchChildren()
await loadDashboard()
})
watch(activeChild, loadDashboard)
</script>
<style scoped>
.page { min-height: 100vh; background: #0f172a; }
.container { max-width: 1100px; margin: 0 auto; padding: 2rem; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2rem; }
h1 { font-size: 1.75rem; font-weight: 700; }
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.5rem;
}
.card {
background: #1e293b;
border-radius: 1rem;
padding: 1.5rem;
}
.card-title {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
margin-bottom: 1rem;
}
.session-info {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
color: #94a3b8;
}
.badge-active {
background: #14532d;
color: #4ade80;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.session-actions { display: flex; gap: 0.5rem; margin-top: 1rem; flex-wrap: wrap; }
.btn-sm {
padding: 0.4rem 0.9rem;
border: 1px solid #334155;
background: transparent;
color: #94a3b8;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-sm:hover { background: #334155; }
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
.btn-sm.btn-danger:hover { background: #7f1d1d; }
.no-session { text-align: center; padding: 1.5rem 0; color: #64748b; }
.no-session p { margin-bottom: 1rem; }
.empty-state { text-align: center; padding: 4rem; color: #64748b; }
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
.block-list { display: flex; flex-direction: column; gap: 0.5rem; }
.tv-card { grid-column: span 1; }
.tv-desc { color: #64748b; margin-bottom: 1rem; font-size: 0.9rem; }
.btn-primary {
display: inline-block;
padding: 0.7rem 1.5rem;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 0.75rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
text-align: center;
transition: background 0.2s;
}
.btn-primary:hover { background: #4338ca; }
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
background: #1e293b;
border-radius: 1rem;
padding: 2rem;
width: 380px;
max-width: 90vw;
}
.dialog h2 { margin-bottom: 1.5rem; }
.dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.4rem; }
.field select {
width: 100%;
padding: 0.65rem 0.9rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.9rem;
}
</style>