Add Rules & Expectations feature with TV overlay and dashboard layout updates
- Add Rules & Expectations admin section with drag-to-reorder, add/edit/delete - Add Overlays card to Dashboard with Rules/Expectations toggle button (LIVE badge when active) - Add full-screen rules overlay on TV view (green theme, numbered list, tap to dismiss) - Backend: new RuleItem model, /api/rules CRUD + bulk reorder, /api/overlays WS broadcast endpoints - Schedule store handles show_rules / hide_rules WebSocket events - Rearrange Dashboard top row: TV Dashboard | 3 Strikes | Overlays (3-col, mobile stacks) - Put Today's Session and Today's Schedule side-by-side at 50/50 width (mobile stacks) - Update README with all new features, setup steps, WS events, and project structure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,37 +12,60 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="dashboard-grid">
|
||||
<!-- 3 Strikes -->
|
||||
<div class="card strikes-card">
|
||||
<div class="card-title">3 Strikes</div>
|
||||
<div class="strikes-list">
|
||||
<div v-for="child in childrenStore.children" :key="child.id" class="strikes-row">
|
||||
<div class="strikes-child-color" :style="{ background: child.color }"></div>
|
||||
<span class="strikes-child-name">{{ child.name }}</span>
|
||||
<div class="strikes-buttons">
|
||||
<button
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="strike-btn"
|
||||
:class="{ lit: i <= child.strikes }"
|
||||
@click="toggleStrike(child, i)"
|
||||
:title="`Strike ${i}`"
|
||||
>✕</button>
|
||||
|
||||
<!-- Top row: TV Dashboard · 3 Strikes · Overlays -->
|
||||
<div class="top-row">
|
||||
<!-- 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.tv_token}`" target="_blank" class="btn-primary">
|
||||
Open TV View →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 3 Strikes -->
|
||||
<div class="card strikes-card">
|
||||
<div class="card-title">3 Strikes</div>
|
||||
<div class="strikes-list">
|
||||
<div v-for="child in childrenStore.children" :key="child.id" class="strikes-row">
|
||||
<div class="strikes-child-color" :style="{ background: child.color }"></div>
|
||||
<span class="strikes-child-name">{{ child.name }}</span>
|
||||
<div class="strikes-buttons">
|
||||
<button
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="strike-btn"
|
||||
:class="{ lit: i <= child.strikes }"
|
||||
@click="toggleStrike(child, i)"
|
||||
:title="`Strike ${i}`"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="childrenStore.children.length === 0" class="empty-small">No children added yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlays -->
|
||||
<div class="card overlays-card">
|
||||
<div class="card-title">Overlays</div>
|
||||
<div class="overlays-grid">
|
||||
<button
|
||||
class="overlay-btn"
|
||||
:class="{ 'overlay-btn-active': scheduleStore.showRulesOverlay }"
|
||||
@click="toggleRulesOverlay"
|
||||
:disabled="!activeChild"
|
||||
>
|
||||
<span class="overlay-btn-icon">📋</span>
|
||||
<span>Rules/Expectations</span>
|
||||
<span v-if="scheduleStore.showRulesOverlay" class="overlay-live-badge">LIVE</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="childrenStore.children.length === 0" class="empty-small">No children added yet.</div>
|
||||
</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.tv_token}`" target="_blank" class="btn-primary">
|
||||
Open TV View →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Session + Schedule row -->
|
||||
<div class="bottom-row">
|
||||
<!-- Today's session card -->
|
||||
<div class="card session-card">
|
||||
<div class="card-title">Today's Session</div>
|
||||
@@ -171,6 +194,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end bottom-row -->
|
||||
|
||||
</div>
|
||||
|
||||
@@ -276,7 +300,7 @@ let wsDisconnect = null
|
||||
|
||||
async function loadDashboard() {
|
||||
if (!activeChild.value) return
|
||||
await scheduleStore.fetchDashboard(activeChild.value.id)
|
||||
await scheduleStore.fetchDashboard(activeChild.value.tv_token)
|
||||
|
||||
// Load templates for start dialog
|
||||
const res = await api.get('/api/schedules')
|
||||
@@ -284,7 +308,7 @@ async function loadDashboard() {
|
||||
|
||||
// WS subscription
|
||||
if (wsDisconnect) wsDisconnect()
|
||||
const { disconnect } = useWebSocket(activeChild.value.id, (msg) => {
|
||||
const { disconnect } = useWebSocket(activeChild.value.tv_token, (msg) => {
|
||||
scheduleStore.applyWsEvent(msg)
|
||||
})
|
||||
wsDisconnect = disconnect
|
||||
@@ -346,6 +370,14 @@ const estimatedFinishTime = computed(() => {
|
||||
return `${hour}:${String(finish.getMinutes()).padStart(2, '0')} ${period}`
|
||||
})
|
||||
|
||||
async function toggleRulesOverlay() {
|
||||
if (!activeChild.value) return
|
||||
const endpoint = scheduleStore.showRulesOverlay
|
||||
? '/api/overlays/rules/hide'
|
||||
: '/api/overlays/rules/show'
|
||||
await api.post(endpoint, { child_id: activeChild.value.id })
|
||||
}
|
||||
|
||||
function selectBlock(block) {
|
||||
if (!scheduleStore.session) return
|
||||
// Clicking the current block does nothing — use Start/Pause/Resume buttons
|
||||
@@ -368,11 +400,31 @@ watch(activeChild, loadDashboard)
|
||||
h1 { font-size: 1.75rem; font-weight: 700; }
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.top-row,
|
||||
.bottom-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1e293b;
|
||||
border-radius: 1rem;
|
||||
@@ -477,8 +529,6 @@ h1 { font-size: 1.75rem; font-weight: 700; }
|
||||
|
||||
.block-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
.strikes-card { grid-column: span 1; }
|
||||
|
||||
.strikes-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
|
||||
.strikes-row {
|
||||
@@ -516,8 +566,46 @@ h1 { font-size: 1.75rem; font-weight: 700; }
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.tv-card { grid-column: span 1; }
|
||||
.tv-desc { color: #64748b; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||
.overlays-grid { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
|
||||
.overlay-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.75rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.overlay-btn:hover:not(:disabled) { background: #334155; border-color: #475569; color: #f1f5f9; }
|
||||
.overlay-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.overlay-btn-active {
|
||||
background: #1e1b4b;
|
||||
border-color: #6366f1;
|
||||
color: #a5b4fc;
|
||||
}
|
||||
.overlay-btn-active:hover:not(:disabled) { background: #312e81; }
|
||||
.overlay-btn-icon { font-size: 1.2rem; }
|
||||
.overlay-live-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.06em;
|
||||
animation: pulse-badge 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-badge {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
|
||||
Reference in New Issue
Block a user