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,45 @@
<template>
<div class="child-selector" v-if="childrenStore.children.length > 1">
<button
v-for="child in childrenStore.children.filter(c => c.is_active)"
:key="child.id"
class="child-btn"
:class="{ active: childrenStore.activeChild?.id === child.id }"
:style="{ '--color': child.color }"
@click="childrenStore.setActiveChild(child)"
>
{{ child.name }}
</button>
</div>
</template>
<script setup>
import { useChildrenStore } from '@/stores/children'
const childrenStore = useChildrenStore()
</script>
<style scoped>
.child-selector {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.child-btn {
padding: 0.4rem 1rem;
border: 2px solid var(--color, #4f46e5);
background: transparent;
color: #94a3b8;
border-radius: 999px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.child-btn:hover,
.child-btn.active {
background: var(--color, #4f46e5);
color: #fff;
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<nav class="navbar">
<RouterLink class="nav-brand" to="/dashboard">🏠 Homeschool</RouterLink>
<div class="nav-links">
<RouterLink to="/dashboard" active-class="active">Dashboard</RouterLink>
<RouterLink to="/schedules" active-class="active">Schedules</RouterLink>
<RouterLink to="/logs" active-class="active">Logs</RouterLink>
<RouterLink to="/admin" active-class="active">Admin</RouterLink>
</div>
<div class="nav-user" v-if="auth.user">
<span class="nav-name">{{ auth.user.full_name }}</span>
<button class="btn-logout" @click="logout">Sign out</button>
</div>
</nav>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
async function logout() {
await auth.logout()
router.push('/login')
}
</script>
<style scoped>
.navbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.875rem 2rem;
background: #1e293b;
border-bottom: 1px solid #334155;
}
.nav-brand {
font-size: 1.1rem;
font-weight: 700;
color: #818cf8;
text-decoration: none;
white-space: nowrap;
}
.nav-links {
display: flex;
gap: 0.25rem;
flex: 1;
}
.nav-links a {
padding: 0.4rem 0.9rem;
border-radius: 0.5rem;
color: #94a3b8;
font-size: 0.9rem;
transition: all 0.2s;
text-decoration: none;
}
.nav-links a:hover,
.nav-links a.active {
background: #334155;
color: #f1f5f9;
}
.nav-user {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
}
.nav-name { font-size: 0.875rem; color: #64748b; }
.btn-logout {
padding: 0.35rem 0.75rem;
border: 1px solid #334155;
background: transparent;
color: #94a3b8;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.btn-logout:hover { background: #334155; color: #f1f5f9; }
</style>

View File

@@ -0,0 +1,29 @@
<template>
<div class="progress-track">
<div
class="progress-fill"
:style="{ width: `${Math.min(100, Math.max(0, percent))}%` }"
></div>
</div>
</template>
<script setup>
defineProps({ percent: { type: Number, default: 0 } })
</script>
<style scoped>
.progress-track {
width: 100%;
height: 8px;
background: #0f172a;
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4f46e5, #818cf8);
border-radius: 999px;
transition: width 0.5s ease;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div
class="block-card"
:class="{
'is-current': isCurrent,
'is-completed': isCompleted,
compact,
}"
>
<div class="block-indicator" :style="{ background: subjectColor }"></div>
<div class="block-body">
<div class="block-title">
{{ block.label || subjectName || 'Block' }}
</div>
<div class="block-time">{{ block.time_start }} {{ block.time_end }}</div>
</div>
<div class="block-status" v-if="isCompleted"></div>
<div class="block-status active" v-else-if="isCurrent"></div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
block: { type: Object, required: true },
isCurrent: { type: Boolean, default: false },
isCompleted: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
})
const subjectColor = computed(() => props.block.subject?.color || '#475569')
const subjectName = computed(() => props.block.subject?.name || null)
</script>
<style scoped>
.block-card {
display: flex;
align-items: center;
gap: 0.75rem;
background: #1e293b;
border-radius: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid transparent;
transition: all 0.2s;
cursor: default;
}
.block-card.is-current {
border-color: #4f46e5;
background: #1e1b4b;
}
.block-card.is-completed {
opacity: 0.5;
}
.block-card.compact {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
}
.block-indicator {
width: 4px;
height: 36px;
border-radius: 2px;
flex-shrink: 0;
}
.block-card.compact .block-indicator {
height: 24px;
}
.block-body { flex: 1; min-width: 0; }
.block-title {
font-size: 0.95rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.block-card.compact .block-title { font-size: 0.85rem; }
.block-time {
font-size: 0.75rem;
color: #64748b;
margin-top: 0.15rem;
font-variant-numeric: tabular-nums;
}
.block-status {
font-size: 0.85rem;
color: #64748b;
flex-shrink: 0;
}
.block-status.active { color: #818cf8; }
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="timer-wrap">
<!-- SVG circular ring -->
<svg class="timer-ring" viewBox="0 0 200 200">
<circle cx="100" cy="100" r="88" class="ring-bg" />
<circle
cx="100" cy="100" r="88"
class="ring-fill"
:style="{ strokeDashoffset: dashOffset, stroke: ringColor }"
/>
</svg>
<div class="timer-inner">
<div class="timer-time">{{ display }}</div>
<div class="timer-label">{{ label }}</div>
</div>
</div>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
block: { type: Object, default: null },
session: { type: Object, default: null },
})
const elapsed = ref(0)
let interval = null
function parseTime(str) {
if (!str) return 0
const [h, m, s = 0] = str.split(':').map(Number)
return h * 3600 + m * 60 + s
}
const blockDuration = computed(() => {
if (!props.block) return 0
return parseTime(props.block.time_end) - parseTime(props.block.time_start)
})
const remaining = computed(() => Math.max(0, blockDuration.value - elapsed.value))
const display = computed(() => {
const s = remaining.value
const m = Math.floor(s / 60)
const sec = s % 60
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
})
const label = computed(() => {
if (remaining.value === 0) return 'Done!'
return 'remaining'
})
const CIRCUMFERENCE = 2 * Math.PI * 88
const dashOffset = computed(() => {
if (!blockDuration.value) return CIRCUMFERENCE
const pct = remaining.value / blockDuration.value
return CIRCUMFERENCE * (1 - pct)
})
const ringColor = computed(() => props.block?.subject?.color || '#4f46e5')
// Tick timer
watch(() => props.block?.id, () => { elapsed.value = 0 })
function startTick() {
if (interval) clearInterval(interval)
interval = setInterval(() => { elapsed.value++ }, 1000)
}
startTick()
onUnmounted(() => clearInterval(interval))
</script>
<style scoped>
.timer-wrap {
position: relative;
width: 280px;
height: 280px;
}
.timer-ring {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ring-bg {
fill: none;
stroke: #1e293b;
stroke-width: 12;
}
.ring-fill {
fill: none;
stroke-width: 12;
stroke-linecap: round;
stroke-dasharray: 552.92; /* 2π×88 */
transition: stroke-dashoffset 1s linear, stroke 0.5s;
}
.timer-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.timer-time {
font-size: 4rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -0.02em;
}
.timer-label {
font-size: 0.9rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.1em;
}
</style>