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,210 @@
<template>
<div class="page">
<NavBar />
<main class="container">
<div class="page-header">
<h1>Activity Log</h1>
<button class="btn-primary" @click="showForm = !showForm">+ Log Activity</button>
</div>
<ChildSelector style="margin-bottom: 1.5rem" />
<!-- Add form -->
<div class="card" v-if="showForm">
<h3>Log an Activity</h3>
<form @submit.prevent="createLog">
<div class="field-row">
<div class="field">
<label>Child</label>
<select v-model="newLog.child_id" required>
<option v-for="c in childrenStore.children" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field">
<label>Date</label>
<input v-model="newLog.log_date" type="date" required />
</div>
</div>
<div class="field-row">
<div class="field">
<label>Subject (optional)</label>
<select v-model="newLog.subject_id">
<option :value="null">None</option>
<option v-for="s in subjects" :key="s.id" :value="s.id">{{ s.icon }} {{ s.name }}</option>
</select>
</div>
<div class="field">
<label>Duration (minutes)</label>
<input v-model.number="newLog.duration_minutes" type="number" min="0" placeholder="e.g. 30" />
</div>
</div>
<div class="field">
<label>Notes</label>
<textarea v-model="newLog.notes" placeholder="What did they do?" rows="3"></textarea>
</div>
<div class="form-actions">
<button type="button" @click="showForm = false">Cancel</button>
<button type="submit" class="btn-primary">Save Log</button>
</div>
</form>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<input v-model="filterDate" type="date" placeholder="Filter by date" />
<button v-if="filterDate" class="btn-sm" @click="filterDate = ''">Clear</button>
</div>
<!-- Logs -->
<div class="log-list">
<div v-for="log in filteredLogs" :key="log.id" class="log-row">
<div class="log-date">{{ log.log_date }}</div>
<div class="log-content">
<div class="log-subject" v-if="log.subject_id">
{{ subjectDisplay(log.subject_id) }}
</div>
<div class="log-notes" v-if="log.notes">{{ log.notes }}</div>
<div class="log-meta" v-if="log.duration_minutes">
{{ log.duration_minutes }} min
</div>
</div>
<button class="btn-sm btn-danger" @click="deleteLog(log.id)"></button>
</div>
<div v-if="filteredLogs.length === 0" class="empty-state">
No activity logs yet.
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useChildrenStore } from '@/stores/children'
import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue'
import ChildSelector from '@/components/ChildSelector.vue'
const childrenStore = useChildrenStore()
const logs = ref([])
const subjects = ref([])
const showForm = ref(false)
const filterDate = ref('')
const today = new Date().toISOString().split('T')[0]
const newLog = ref({ child_id: null, subject_id: null, log_date: today, notes: '', duration_minutes: null })
const filteredLogs = computed(() => {
if (!filterDate.value) return logs.value
return logs.value.filter((l) => l.log_date === filterDate.value)
})
function subjectDisplay(id) {
const s = subjects.value.find((s) => s.id === id)
return s ? `${s.icon} ${s.name}` : ''
}
async function loadLogs() {
const res = await api.get('/api/logs')
logs.value = res.data
}
async function createLog() {
await api.post('/api/logs', newLog.value)
newLog.value = { child_id: newLog.value.child_id, subject_id: null, log_date: today, notes: '', duration_minutes: null }
showForm.value = false
await loadLogs()
}
async function deleteLog(id) {
if (confirm('Delete this log entry?')) {
await api.delete(`/api/logs/${id}`)
logs.value = logs.value.filter((l) => l.id !== id)
}
}
onMounted(async () => {
await childrenStore.fetchChildren()
if (childrenStore.activeChild) newLog.value.child_id = childrenStore.activeChild.id
const [sRes] = await Promise.all([api.get('/api/subjects'), loadLogs()])
subjects.value = sRes.data
})
</script>
<style scoped>
.page { min-height: 100vh; background: #0f172a; }
.container { max-width: 800px; 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; }
.card { background: #1e293b; border-radius: 1rem; padding: 1.5rem; margin-bottom: 1.5rem; }
.card h3 { margin-bottom: 1.25rem; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.4rem; }
.field input, .field select, .field textarea {
width: 100%;
padding: 0.65rem 0.9rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.9rem;
resize: vertical;
}
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
.filter-bar { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; }
.filter-bar input {
padding: 0.5rem 0.75rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.875rem;
}
.log-list { display: flex; flex-direction: column; gap: 0.5rem; }
.log-row {
display: flex;
align-items: flex-start;
gap: 1rem;
background: #1e293b;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
}
.log-date { font-size: 0.8rem; color: #64748b; width: 90px; flex-shrink: 0; padding-top: 0.1rem; }
.log-content { flex: 1; }
.log-subject { font-size: 0.85rem; color: #818cf8; margin-bottom: 0.2rem; }
.log-notes { font-size: 0.9rem; }
.log-meta { font-size: 0.8rem; color: #64748b; margin-top: 0.25rem; }
.empty-state { text-align: center; padding: 4rem; color: #64748b; }
.btn-primary {
padding: 0.65rem 1.25rem;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
}
.btn-primary:hover { background: #4338ca; }
.btn-sm {
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-sm:hover { background: #334155; }
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
.btn-sm.btn-danger:hover { background: #7f1d1d; }
</style>