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:
@@ -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; }
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user