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:
@@ -2,7 +2,7 @@
|
|||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.child import Child
|
from app.models.child import Child
|
||||||
from app.models.subject import Subject
|
from app.models.subject import Subject, SubjectOption
|
||||||
from app.models.schedule import ScheduleTemplate, ScheduleBlock
|
from app.models.schedule import ScheduleTemplate, ScheduleBlock
|
||||||
from app.models.session import DailySession, TimerEvent, TimerEventType
|
from app.models.session import DailySession, TimerEvent, TimerEventType
|
||||||
from app.models.activity import ActivityLog
|
from app.models.activity import ActivityLog
|
||||||
@@ -13,6 +13,7 @@ __all__ = [
|
|||||||
"User",
|
"User",
|
||||||
"Child",
|
"Child",
|
||||||
"Subject",
|
"Subject",
|
||||||
|
"SubjectOption",
|
||||||
"ScheduleTemplate",
|
"ScheduleTemplate",
|
||||||
"ScheduleBlock",
|
"ScheduleBlock",
|
||||||
"DailySession",
|
"DailySession",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import String, Boolean, ForeignKey
|
from sqlalchemy import String, Boolean, ForeignKey, Text, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
@@ -20,3 +20,20 @@ class Subject(TimestampMixin, Base):
|
|||||||
activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821
|
activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821
|
||||||
"ActivityLog", back_populates="subject"
|
"ActivityLog", back_populates="subject"
|
||||||
)
|
)
|
||||||
|
options: Mapped[list["SubjectOption"]] = relationship(
|
||||||
|
"SubjectOption", back_populates="subject", cascade="all, delete-orphan",
|
||||||
|
order_by="SubjectOption.order_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectOption(Base):
|
||||||
|
__tablename__ = "subject_options"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
subject_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("subjects.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
subject: Mapped["Subject"] = relationship("Subject", back_populates="options")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.dependencies import get_db
|
from app.dependencies import get_db
|
||||||
from app.models.child import Child
|
from app.models.child import Child
|
||||||
from app.models.schedule import ScheduleBlock, ScheduleTemplate
|
from app.models.schedule import ScheduleBlock, ScheduleTemplate
|
||||||
|
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
|
||||||
from app.models.session import DailySession, TimerEvent
|
from app.models.session import DailySession, TimerEvent
|
||||||
from app.schemas.session import DashboardSnapshot
|
from app.schemas.session import DashboardSnapshot
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
blocks_result = await db.execute(
|
blocks_result = await db.execute(
|
||||||
select(ScheduleBlock)
|
select(ScheduleBlock)
|
||||||
.where(ScheduleBlock.template_id == session.template_id)
|
.where(ScheduleBlock.template_id == session.template_id)
|
||||||
|
.options(selectinload(ScheduleBlock.subject).selectinload(Subject.options))
|
||||||
.order_by(ScheduleBlock.order_index)
|
.order_by(ScheduleBlock.order_index)
|
||||||
)
|
)
|
||||||
blocks = blocks_result.scalars().all()
|
blocks = blocks_result.scalars().all()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.dependencies import get_db, get_current_user
|
from app.dependencies import get_db, get_current_user
|
||||||
from app.models.schedule import ScheduleTemplate, ScheduleBlock
|
from app.models.schedule import ScheduleTemplate, ScheduleBlock
|
||||||
|
from app.models.subject import Subject # noqa: F401 — for selectinload chain
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.schedule import (
|
from app.schemas.schedule import (
|
||||||
ScheduleTemplateCreate,
|
ScheduleTemplateCreate,
|
||||||
@@ -26,7 +27,7 @@ async def list_templates(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ScheduleTemplate)
|
select(ScheduleTemplate)
|
||||||
.where(ScheduleTemplate.user_id == current_user.id)
|
.where(ScheduleTemplate.user_id == current_user.id)
|
||||||
.options(selectinload(ScheduleTemplate.blocks))
|
.options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options))
|
||||||
.order_by(ScheduleTemplate.name)
|
.order_by(ScheduleTemplate.name)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
@@ -60,7 +61,7 @@ async def create_template(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ScheduleTemplate)
|
select(ScheduleTemplate)
|
||||||
.where(ScheduleTemplate.id == template.id)
|
.where(ScheduleTemplate.id == template.id)
|
||||||
.options(selectinload(ScheduleTemplate.blocks))
|
.options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options))
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ async def get_template(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ScheduleTemplate)
|
select(ScheduleTemplate)
|
||||||
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
|
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
|
||||||
.options(selectinload(ScheduleTemplate.blocks))
|
.options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options))
|
||||||
)
|
)
|
||||||
template = result.scalar_one_or_none()
|
template = result.scalar_one_or_none()
|
||||||
if not template:
|
if not template:
|
||||||
@@ -92,7 +93,7 @@ async def update_template(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ScheduleTemplate)
|
select(ScheduleTemplate)
|
||||||
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
|
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
|
||||||
.options(selectinload(ScheduleTemplate.blocks))
|
.options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options))
|
||||||
)
|
)
|
||||||
template = result.scalar_one_or_none()
|
template = result.scalar_one_or_none()
|
||||||
if not template:
|
if not template:
|
||||||
@@ -141,8 +142,12 @@ async def add_block(
|
|||||||
block = ScheduleBlock(template_id=template_id, **body.model_dump())
|
block = ScheduleBlock(template_id=template_id, **body.model_dump())
|
||||||
db.add(block)
|
db.add(block)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(block)
|
result = await db.execute(
|
||||||
return block
|
select(ScheduleBlock)
|
||||||
|
.where(ScheduleBlock.id == block.id)
|
||||||
|
.options(selectinload(ScheduleBlock.subject).selectinload(Subject.options))
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{template_id}/blocks/{block_id}", response_model=ScheduleBlockOut)
|
@router.patch("/{template_id}/blocks/{block_id}", response_model=ScheduleBlockOut)
|
||||||
@@ -169,8 +174,12 @@ async def update_block(
|
|||||||
for field, value in body.model_dump(exclude_unset=True).items():
|
for field, value in body.model_dump(exclude_unset=True).items():
|
||||||
setattr(block, field, value)
|
setattr(block, field, value)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(block)
|
result = await db.execute(
|
||||||
return block
|
select(ScheduleBlock)
|
||||||
|
.where(ScheduleBlock.id == block.id)
|
||||||
|
.options(selectinload(ScheduleBlock.subject).selectinload(Subject.options))
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{template_id}/blocks/{block_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{template_id}/blocks/{block_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.dependencies import get_db, get_current_user
|
from app.dependencies import get_db, get_current_user
|
||||||
from app.models.child import Child
|
from app.models.child import Child
|
||||||
from app.models.schedule import ScheduleBlock, ScheduleTemplate
|
from app.models.schedule import ScheduleBlock, ScheduleTemplate
|
||||||
|
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
|
||||||
from app.models.session import DailySession, TimerEvent
|
from app.models.session import DailySession, TimerEvent
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.session import DailySessionOut, SessionStart, TimerAction
|
from app.schemas.session import DailySessionOut, SessionStart, TimerAction
|
||||||
@@ -26,12 +27,21 @@ async def _broadcast_session(db: AsyncSession, session: DailySession) -> None:
|
|||||||
blocks_result = await db.execute(
|
blocks_result = await db.execute(
|
||||||
select(ScheduleBlock)
|
select(ScheduleBlock)
|
||||||
.where(ScheduleBlock.template_id == session.template_id)
|
.where(ScheduleBlock.template_id == session.template_id)
|
||||||
|
.options(selectinload(ScheduleBlock.subject).selectinload(Subject.options))
|
||||||
.order_by(ScheduleBlock.order_index)
|
.order_by(ScheduleBlock.order_index)
|
||||||
)
|
)
|
||||||
blocks = [
|
blocks = [
|
||||||
{
|
{
|
||||||
"id": b.id,
|
"id": b.id,
|
||||||
"subject_id": b.subject_id,
|
"subject_id": b.subject_id,
|
||||||
|
"subject": {
|
||||||
|
"id": b.subject.id,
|
||||||
|
"name": b.subject.name,
|
||||||
|
"color": b.subject.color,
|
||||||
|
"icon": b.subject.icon,
|
||||||
|
"options": [{"id": o.id, "text": o.text, "order_index": o.order_index}
|
||||||
|
for o in b.subject.options],
|
||||||
|
} if b.subject else None,
|
||||||
"time_start": str(b.time_start),
|
"time_start": str(b.time_start),
|
||||||
"time_end": str(b.time_end),
|
"time_end": str(b.time_end),
|
||||||
"duration_minutes": b.duration_minutes,
|
"duration_minutes": b.duration_minutes,
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.dependencies import get_db, get_current_user
|
from app.dependencies import get_db, get_current_user
|
||||||
from app.models.subject import Subject
|
from app.models.subject import Subject, SubjectOption
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.subject import SubjectCreate, SubjectOut, SubjectUpdate
|
from app.schemas.subject import (
|
||||||
|
SubjectCreate, SubjectOut, SubjectUpdate,
|
||||||
|
SubjectOptionCreate, SubjectOptionUpdate, SubjectOptionOut,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/subjects", tags=["subjects"])
|
router = APIRouter(prefix="/api/subjects", tags=["subjects"])
|
||||||
|
|
||||||
|
|
||||||
|
def _opts():
|
||||||
|
return selectinload(Subject.options)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SubjectOut])
|
@router.get("", response_model=list[SubjectOut])
|
||||||
async def list_subjects(
|
async def list_subjects(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Subject).where(Subject.user_id == current_user.id).order_by(Subject.name)
|
select(Subject)
|
||||||
|
.where(Subject.user_id == current_user.id)
|
||||||
|
.options(_opts())
|
||||||
|
.order_by(Subject.name)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@@ -30,8 +41,10 @@ async def create_subject(
|
|||||||
subject = Subject(**body.model_dump(), user_id=current_user.id)
|
subject = Subject(**body.model_dump(), user_id=current_user.id)
|
||||||
db.add(subject)
|
db.add(subject)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(subject)
|
result = await db.execute(
|
||||||
return subject
|
select(Subject).where(Subject.id == subject.id).options(_opts())
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{subject_id}", response_model=SubjectOut)
|
@router.get("/{subject_id}", response_model=SubjectOut)
|
||||||
@@ -41,7 +54,9 @@ async def get_subject(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id)
|
select(Subject)
|
||||||
|
.where(Subject.id == subject_id, Subject.user_id == current_user.id)
|
||||||
|
.options(_opts())
|
||||||
)
|
)
|
||||||
subject = result.scalar_one_or_none()
|
subject = result.scalar_one_or_none()
|
||||||
if not subject:
|
if not subject:
|
||||||
@@ -57,7 +72,9 @@ async def update_subject(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id)
|
select(Subject)
|
||||||
|
.where(Subject.id == subject_id, Subject.user_id == current_user.id)
|
||||||
|
.options(_opts())
|
||||||
)
|
)
|
||||||
subject = result.scalar_one_or_none()
|
subject = result.scalar_one_or_none()
|
||||||
if not subject:
|
if not subject:
|
||||||
@@ -66,8 +83,10 @@ async def update_subject(
|
|||||||
for field, value in body.model_dump(exclude_none=True).items():
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
setattr(subject, field, value)
|
setattr(subject, field, value)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(subject)
|
result = await db.execute(
|
||||||
return subject
|
select(Subject).where(Subject.id == subject.id).options(_opts())
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{subject_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{subject_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -84,3 +103,76 @@ async def delete_subject(
|
|||||||
raise HTTPException(status_code=404, detail="Subject not found")
|
raise HTTPException(status_code=404, detail="Subject not found")
|
||||||
await db.delete(subject)
|
await db.delete(subject)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Subject Option sub-routes ---
|
||||||
|
|
||||||
|
@router.post("/{subject_id}/options", response_model=SubjectOptionOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_option(
|
||||||
|
subject_id: int,
|
||||||
|
body: SubjectOptionCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=404, detail="Subject not found")
|
||||||
|
|
||||||
|
option = SubjectOption(subject_id=subject_id, **body.model_dump())
|
||||||
|
db.add(option)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(option)
|
||||||
|
return option
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{subject_id}/options/{option_id}", response_model=SubjectOptionOut)
|
||||||
|
async def update_option(
|
||||||
|
subject_id: int,
|
||||||
|
option_id: int,
|
||||||
|
body: SubjectOptionUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(SubjectOption)
|
||||||
|
.join(Subject)
|
||||||
|
.where(
|
||||||
|
SubjectOption.id == option_id,
|
||||||
|
SubjectOption.subject_id == subject_id,
|
||||||
|
Subject.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
option = result.scalar_one_or_none()
|
||||||
|
if not option:
|
||||||
|
raise HTTPException(status_code=404, detail="Option not found")
|
||||||
|
|
||||||
|
for field, value in body.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(option, field, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(option)
|
||||||
|
return option
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{subject_id}/options/{option_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_option(
|
||||||
|
subject_id: int,
|
||||||
|
option_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(SubjectOption)
|
||||||
|
.join(Subject)
|
||||||
|
.where(
|
||||||
|
SubjectOption.id == option_id,
|
||||||
|
SubjectOption.subject_id == subject_id,
|
||||||
|
Subject.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
option = result.scalar_one_or_none()
|
||||||
|
if not option:
|
||||||
|
raise HTTPException(status_code=404, detail="Option not found")
|
||||||
|
await db.delete(option)
|
||||||
|
await db.commit()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from datetime import time
|
from datetime import time
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from app.schemas.subject import SubjectOut
|
||||||
|
|
||||||
|
|
||||||
class ScheduleBlockCreate(BaseModel):
|
class ScheduleBlockCreate(BaseModel):
|
||||||
@@ -25,6 +26,7 @@ class ScheduleBlockUpdate(BaseModel):
|
|||||||
class ScheduleBlockOut(BaseModel):
|
class ScheduleBlockOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
subject_id: int | None
|
subject_id: int | None
|
||||||
|
subject: SubjectOut | None = None
|
||||||
time_start: time
|
time_start: time
|
||||||
time_end: time
|
time_end: time
|
||||||
duration_minutes: int | None
|
duration_minutes: int | None
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectOptionCreate(BaseModel):
|
||||||
|
text: str
|
||||||
|
order_index: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectOptionUpdate(BaseModel):
|
||||||
|
text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectOptionOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
text: str
|
||||||
|
order_index: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
class SubjectCreate(BaseModel):
|
class SubjectCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
color: str = "#10B981"
|
color: str = "#10B981"
|
||||||
@@ -20,5 +37,6 @@ class SubjectOut(BaseModel):
|
|||||||
color: str
|
color: str
|
||||||
icon: str
|
icon: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
options: list[SubjectOptionOut] = []
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
<div class="block-indicator" :style="{ background: subjectColor }"></div>
|
<div class="block-indicator" :style="{ background: subjectColor }"></div>
|
||||||
<div class="block-body">
|
<div class="block-body">
|
||||||
<div class="block-title">
|
<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>
|
||||||
<div class="block-time">
|
<div class="block-time">
|
||||||
{{ block.time_start }} – {{ block.time_end }}
|
{{ block.time_start }} – {{ block.time_end }}
|
||||||
@@ -108,6 +109,7 @@ const durationLabel = computed(() => {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block-label-suffix { font-weight: 400; color: #94a3b8; }
|
||||||
.block-duration { color: #475569; }
|
.block-duration { color: #475569; }
|
||||||
.block-duration-custom { color: #818cf8; }
|
.block-duration-custom { color: #818cf8; }
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,9 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="item-list">
|
<div class="item-list">
|
||||||
<div v-for="subject in subjects" :key="subject.id" class="item-row">
|
<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">
|
<template v-if="editingSubject && editingSubject.id === subject.id">
|
||||||
<input v-model="editingSubject.name" class="edit-input" required />
|
<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.icon" placeholder="Icon" maxlength="4" style="width:60px" class="edit-input" />
|
||||||
@@ -77,10 +79,40 @@
|
|||||||
<span class="item-name">{{ subject.name }}</span>
|
<span class="item-name">{{ subject.name }}</span>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<button class="btn-sm" @click="startEditSubject(subject)">Edit</button>
|
<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>
|
<button class="btn-sm btn-danger" @click="deleteSubject(subject.id)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
||||||
|
<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 v-if="subjects.length === 0" class="empty-small">No subjects added yet.</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -269,6 +301,11 @@ async function deleteChild(id) {
|
|||||||
const showSubjectForm = ref(false)
|
const showSubjectForm = ref(false)
|
||||||
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })
|
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })
|
||||||
const editingSubject = ref(null)
|
const editingSubject = ref(null)
|
||||||
|
const expandedSubject = ref(null)
|
||||||
|
|
||||||
|
// Subject options
|
||||||
|
const newOptionText = ref('')
|
||||||
|
const editingOption = ref(null)
|
||||||
|
|
||||||
async function loadSubjects() {
|
async function loadSubjects() {
|
||||||
const res = await api.get('/api/subjects')
|
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
|
// Schedules
|
||||||
const templates = ref([])
|
const templates = ref([])
|
||||||
const showCreateForm = ref(false)
|
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 span { color: #64748b; }
|
||||||
.edit-block-form { border: 1px solid #4f46e5; }
|
.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; }
|
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
|
|||||||
@@ -25,12 +25,9 @@
|
|||||||
|
|
||||||
<!-- Active session -->
|
<!-- Active session -->
|
||||||
<div v-else class="tv-main">
|
<div v-else class="tv-main">
|
||||||
<!-- Current block (big display) -->
|
<!-- Left: timer -->
|
||||||
<div class="tv-current" v-if="scheduleStore.currentBlock">
|
<div class="tv-timer-col" v-if="scheduleStore.currentBlock">
|
||||||
<div
|
<div class="tv-subject-badge" :style="{ background: currentSubjectColor }">
|
||||||
class="tv-subject-badge"
|
|
||||||
:style="{ background: currentSubjectColor }"
|
|
||||||
>
|
|
||||||
{{ currentSubjectIcon }} {{ currentSubjectName }}
|
{{ currentSubjectIcon }} {{ currentSubjectName }}
|
||||||
</div>
|
</div>
|
||||||
<TimerDisplay
|
<TimerDisplay
|
||||||
@@ -45,8 +42,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="tv-sidebar">
|
||||||
<!-- Schedule list -->
|
|
||||||
<div class="tv-schedule-list">
|
<div class="tv-schedule-list">
|
||||||
<ScheduleBlock
|
<ScheduleBlock
|
||||||
v-for="block in scheduleStore.blocks"
|
v-for="block in scheduleStore.blocks"
|
||||||
@@ -123,6 +138,9 @@ const currentSubjectIcon = computed(() => scheduleStore.currentBlock?.subject?.i
|
|||||||
const currentSubjectName = computed(() =>
|
const currentSubjectName = computed(() =>
|
||||||
scheduleStore.currentBlock?.label || scheduleStore.currentBlock?.subject?.name || 'Current Block'
|
scheduleStore.currentBlock?.label || scheduleStore.currentBlock?.subject?.name || 'Current Block'
|
||||||
)
|
)
|
||||||
|
const currentSubjectOptions = computed(() =>
|
||||||
|
scheduleStore.currentBlock?.subject?.options || []
|
||||||
|
)
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
const { connected: wsConnected } = useWebSocket(childId, (msg) => {
|
const { connected: wsConnected } = useWebSocket(childId, (msg) => {
|
||||||
@@ -188,11 +206,12 @@ onMounted(async () => {
|
|||||||
.tv-main {
|
.tv-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 380px;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tv-current {
|
.tv-timer-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -201,18 +220,56 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tv-subject-badge {
|
.tv-subject-badge {
|
||||||
font-size: 1.75rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.75rem 2rem;
|
padding: 0.6rem 1.5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tv-block-notes {
|
.tv-block-notes {
|
||||||
font-size: 1.25rem;
|
font-size: 1rem;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
text-align: center;
|
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 {
|
.tv-day-progress {
|
||||||
|
|||||||
Reference in New Issue
Block a user