Add subject options and redesign TV dashboard layout

Subject options:
- New subject_options table (auto-created on startup)
- SubjectOut now includes options list; all eager-loading chains updated
- Admin: Options panel per subject with add, inline edit, and delete
- WS broadcast and dashboard API include options in block subject data

TV dashboard:
- Three equal columns: Timer | Activities | Schedule
- Activities column shows current subject's options in large readable text
- Activities area has subject-colored border and tinted background
- Subject name and label displayed correctly using embedded subject data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 11:18:55 -08:00
parent c12f07daa3
commit c9441a9c9a
11 changed files with 375 additions and 51 deletions

View File

@@ -10,7 +10,8 @@
<div class="block-indicator" :style="{ background: subjectColor }"></div>
<div class="block-body">
<div class="block-title">
{{ block.label || subjectName || 'Block' }}
<span>{{ subjectName || block.label || 'Block' }}</span>
<span v-if="subjectName && block.label" class="block-label-suffix"> - {{ block.label }}</span>
</div>
<div class="block-time">
{{ block.time_start }} {{ block.time_end }}
@@ -108,6 +109,7 @@ const durationLabel = computed(() => {
font-variant-numeric: tabular-nums;
}
.block-label-suffix { font-weight: 400; color: #94a3b8; }
.block-duration { color: #475569; }
.block-duration-custom { color: #818cf8; }

View File

@@ -61,25 +61,57 @@
</form>
<div class="item-list">
<div v-for="subject in subjects" :key="subject.id" class="item-row">
<template v-if="editingSubject && editingSubject.id === subject.id">
<input v-model="editingSubject.name" class="edit-input" required />
<input v-model="editingSubject.icon" placeholder="Icon" maxlength="4" style="width:60px" class="edit-input" />
<input v-model="editingSubject.color" type="color" title="Color" />
<div class="item-actions">
<button class="btn-sm btn-primary" @click="saveSubject">Save</button>
<button class="btn-sm" @click="editingSubject = null">Cancel</button>
<div v-for="subject in subjects" :key="subject.id" class="subject-block">
<!-- Subject row -->
<div class="item-row">
<template v-if="editingSubject && editingSubject.id === subject.id">
<input v-model="editingSubject.name" class="edit-input" required />
<input v-model="editingSubject.icon" placeholder="Icon" maxlength="4" style="width:60px" class="edit-input" />
<input v-model="editingSubject.color" type="color" title="Color" />
<div class="item-actions">
<button class="btn-sm btn-primary" @click="saveSubject">Save</button>
<button class="btn-sm" @click="editingSubject = null">Cancel</button>
</div>
</template>
<template v-else>
<div class="item-color" :style="{ background: subject.color }"></div>
<span class="item-icon">{{ subject.icon }}</span>
<span class="item-name">{{ subject.name }}</span>
<div class="item-actions">
<button class="btn-sm" @click="startEditSubject(subject)">Edit</button>
<button
class="btn-sm"
@click="expandedSubject = expandedSubject === subject.id ? null : subject.id"
>{{ expandedSubject === subject.id ? 'Hide Options' : 'Options' }}</button>
<button class="btn-sm btn-danger" @click="deleteSubject(subject.id)">Delete</button>
</div>
</template>
</div>
<!-- Options panel -->
<div v-if="expandedSubject === subject.id" class="options-panel">
<div class="option-list">
<template v-for="opt in subject.options" :key="opt.id">
<div v-if="editingOption && editingOption.id === opt.id" class="option-edit-row">
<input v-model="editingOption.text" class="option-input" @keyup.enter="saveOption(subject.id)" />
<button class="btn-sm btn-primary" @click="saveOption(subject.id)">Save</button>
<button class="btn-sm" @click="editingOption = null">Cancel</button>
</div>
<div v-else class="option-row">
<span class="option-text">{{ opt.text }}</span>
<div class="item-actions">
<button class="btn-sm" @click="startEditOption(opt)">Edit</button>
<button class="btn-sm btn-danger" @click="deleteOption(subject.id, opt.id)"></button>
</div>
</div>
</template>
<div v-if="subject.options.length === 0" class="empty-small">No options yet.</div>
</div>
</template>
<template v-else>
<div class="item-color" :style="{ background: subject.color }"></div>
<span class="item-icon">{{ subject.icon }}</span>
<span class="item-name">{{ subject.name }}</span>
<div class="item-actions">
<button class="btn-sm" @click="startEditSubject(subject)">Edit</button>
<button class="btn-sm btn-danger" @click="deleteSubject(subject.id)">Delete</button>
</div>
</template>
<form class="option-add-row" @submit.prevent="addOption(subject.id)">
<input v-model="newOptionText" placeholder="Add an option..." class="option-input" required />
<button type="submit" class="btn-primary btn-sm">Add</button>
</form>
</div>
</div>
<div v-if="subjects.length === 0" class="empty-small">No subjects added yet.</div>
</div>
@@ -269,6 +301,11 @@ async function deleteChild(id) {
const showSubjectForm = ref(false)
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })
const editingSubject = ref(null)
const expandedSubject = ref(null)
// Subject options
const newOptionText = ref('')
const editingOption = ref(null)
async function loadSubjects() {
const res = await api.get('/api/subjects')
@@ -305,6 +342,32 @@ async function deleteSubject(id) {
}
}
async function addOption(subjectId) {
await api.post(`/api/subjects/${subjectId}/options`, {
text: newOptionText.value,
order_index: subjects.value.find(s => s.id === subjectId)?.options.length || 0,
})
newOptionText.value = ''
await loadSubjects()
}
function startEditOption(opt) {
editingOption.value = { id: opt.id, text: opt.text }
}
async function saveOption(subjectId) {
await api.patch(`/api/subjects/${subjectId}/options/${editingOption.value.id}`, {
text: editingOption.value.text,
})
editingOption.value = null
await loadSubjects()
}
async function deleteOption(subjectId, optionId) {
await api.delete(`/api/subjects/${subjectId}/options/${optionId}`)
await loadSubjects()
}
// Schedules
const templates = ref([])
const showCreateForm = ref(false)
@@ -535,6 +598,57 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
.edit-block-form span { color: #64748b; }
.edit-block-form { border: 1px solid #4f46e5; }
.subject-block { display: flex; flex-direction: column; gap: 0; }
.options-panel {
background: #0f172a;
border-radius: 0 0 0.75rem 0.75rem;
padding: 0.75rem 1rem;
margin-top: -0.5rem;
border: 1px solid #1e293b;
border-top: none;
}
.option-list { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.75rem; }
.option-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.6rem;
background: #1e293b;
border-radius: 0.4rem;
}
.option-text { flex: 1; font-size: 0.9rem; }
.option-edit-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: #1e293b;
border: 1px solid #4f46e5;
border-radius: 0.4rem;
}
.option-input {
flex: 1;
padding: 0.4rem 0.6rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.4rem;
color: #f1f5f9;
font-size: 0.9rem;
}
.option-add-row {
display: flex;
gap: 0.5rem;
}
.option-add-row .option-input { background: #1e293b; }
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
.btn-primary {

View File

@@ -25,12 +25,9 @@
<!-- Active session -->
<div v-else class="tv-main">
<!-- Current block (big display) -->
<div class="tv-current" v-if="scheduleStore.currentBlock">
<div
class="tv-subject-badge"
:style="{ background: currentSubjectColor }"
>
<!-- Left: timer -->
<div class="tv-timer-col" v-if="scheduleStore.currentBlock">
<div class="tv-subject-badge" :style="{ background: currentSubjectColor }">
{{ currentSubjectIcon }} {{ currentSubjectName }}
</div>
<TimerDisplay
@@ -45,8 +42,26 @@
</div>
</div>
<!-- Center: subject options -->
<div class="tv-options-col" :style="{ background: currentSubjectColor + '22', borderColor: currentSubjectColor }">
<div class="tv-options-title">Activities</div>
<div
v-if="currentSubjectOptions.length"
class="tv-options-list"
>
<div
v-for="opt in currentSubjectOptions"
:key="opt.id"
class="tv-option-item"
>
{{ opt.text }}
</div>
</div>
<div v-else class="tv-options-empty">No activities listed for this subject.</div>
</div>
<!-- Right: schedule list -->
<div class="tv-sidebar">
<!-- Schedule list -->
<div class="tv-schedule-list">
<ScheduleBlock
v-for="block in scheduleStore.blocks"
@@ -123,6 +138,9 @@ const currentSubjectIcon = computed(() => scheduleStore.currentBlock?.subject?.i
const currentSubjectName = computed(() =>
scheduleStore.currentBlock?.label || scheduleStore.currentBlock?.subject?.name || 'Current Block'
)
const currentSubjectOptions = computed(() =>
scheduleStore.currentBlock?.subject?.options || []
)
// WebSocket
const { connected: wsConnected } = useWebSocket(childId, (msg) => {
@@ -188,11 +206,12 @@ onMounted(async () => {
.tv-main {
flex: 1;
display: grid;
grid-template-columns: 1fr 380px;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
min-height: 0;
}
.tv-current {
.tv-timer-col {
display: flex;
flex-direction: column;
gap: 1.5rem;
@@ -201,18 +220,56 @@ onMounted(async () => {
}
.tv-subject-badge {
font-size: 1.75rem;
font-size: 1.4rem;
font-weight: 600;
padding: 0.75rem 2rem;
padding: 0.6rem 1.5rem;
border-radius: 999px;
color: #fff;
text-align: center;
}
.tv-block-notes {
font-size: 1.25rem;
font-size: 1rem;
color: #94a3b8;
text-align: center;
max-width: 600px;
}
.tv-options-col {
display: flex;
flex-direction: column;
gap: 1rem;
justify-content: center;
border: 2px solid;
border-radius: 1rem;
padding: 1.5rem 2rem;
}
.tv-options-title {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #475569;
}
.tv-options-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tv-option-item {
font-size: 1.6rem;
font-weight: 500;
color: #e2e8f0;
padding: 0.6rem 0;
border-bottom: 1px solid #1e293b;
line-height: 1.3;
}
.tv-options-empty {
font-size: 1.1rem;
color: #334155;
font-style: italic;
}
.tv-day-progress {