From 2dbdf92e29cf33d1f32d1abbf58d9c6579272ba5 Mon Sep 17 00:00:00 2001 From: Screen <228304862+screen-gd@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:42:07 +0400 Subject: [PATCH 1/2] Rework the lesson view into Read, Check and Apply tabs Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/lib/learning.ts | 64 +- apps/web/src/styles/learning.css | 1107 ++++++++++++++++++++++++ apps/web/src/views/studio/learn.tsx | 1249 +++++++++++++++++++-------- 3 files changed, 2062 insertions(+), 358 deletions(-) diff --git a/apps/web/src/lib/learning.ts b/apps/web/src/lib/learning.ts index 3f49f79..dca8814 100644 --- a/apps/web/src/lib/learning.ts +++ b/apps/web/src/lib/learning.ts @@ -81,13 +81,8 @@ export function taskVideo(item: LearningTask) { ); } -export function setLearningTaskDone( - roadmap: Roadmap, - taskId: string, - completed: boolean, -) { - const update = (task: Task) => - task.id === taskId ? { ...task, completed, done: completed } : task; +/** Return a copy of the roadmap with every task passed through `update`. */ +function updateLearningTasks(roadmap: Roadmap, update: (task: Task) => Task) { const sections = roadmap.sections.map((section): Section => { if (section.type === "module") return { @@ -122,6 +117,61 @@ export function setLearningTaskDone( }); } +export function setLearningTaskDone( + roadmap: Roadmap, + taskId: string, + completed: boolean, +) { + updateLearningTasks(roadmap, (task) => + task.id === taskId ? { ...task, completed, done: completed } : task, + ); +} + +export function setLearningSubtaskDone( + roadmap: Roadmap, + taskId: string, + subtaskId: string, + completed: boolean, +) { + updateLearningTasks(roadmap, (task) => + task.id === taskId + ? { + ...task, + subtasks: task.subtasks.map((subtask) => + subtask.id === subtaskId + ? { ...subtask, completed } + : subtask, + ), + } + : task, + ); +} + +/** + * Resolve the real `Video` record behind a lesson resource. `taskVideo` + * returns a flattened `Resource`, which drops duration and timestamps. + */ +export function learningVideo(roadmap: Roadmap, url: string): Video | null { + if (!url) return null; + for (const section of roadmap.sections) { + if (section.type === "module") { + const found = section.data.videos.find( + (video) => video.url === url, + ); + if (found) return found; + } + if (section.type === "milestones") { + for (const milestone of section.data) { + const found = milestone.videos.find( + (video) => video.url === url, + ); + if (found) return found; + } + } + } + return null; +} + export function safeResourceUrl(value: string) { try { const url = new URL(value); diff --git a/apps/web/src/styles/learning.css b/apps/web/src/styles/learning.css index ef645ee..9dd8b19 100644 --- a/apps/web/src/styles/learning.css +++ b/apps/web/src/styles/learning.css @@ -1543,3 +1543,1110 @@ transform: none; } } + +/* ============================================================ + Learn · Desk + A paper workbench for one lesson. Scoped to .sy-desk so the + rest of the app keeps the dark shell. + ============================================================ */ +.sy-main:has(> .sy-desk) { + padding: 0; +} +.sy-desk { + --color-page: #f7f6f2; + --color-surface: #ffffff; + --color-surface-subtle: #f1efe8; + --color-raised: #eae7dd; + --color-border: #e0ded5; + --color-border-strong: #c9c6ba; + --color-text: #1e2025; + --color-text-muted: #5d5f57; + --color-text-soft: #7a7c73; + --color-accent: #3f4cc4; + --color-accent-strong: #2f3aa8; + --color-accent-soft: #eef0fd; + position: relative; + min-width: 0; + min-height: 100dvh; + background: #f7f6f2; + color: var(--color-text); + color-scheme: light; +} +.sy-desk button, +.sy-desk summary { + cursor: pointer; +} +.sy-desk button:focus-visible, +.sy-desk a:focus-visible, +.sy-desk input:focus-visible, +.sy-desk textarea:focus-visible, +.sy-desk summary:focus-visible { + outline: 2px solid #3f4cc4; + outline-offset: 2px; +} +.sy-desk-mono { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +/* Top bar */ +.sy-desk-top { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + height: 54px; + padding: 0 24px; + background: rgba(247, 246, 242, 0.92); + border-bottom: 1px solid #e0ded5; + backdrop-filter: blur(10px); +} +.sy-desk-crumb { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + font-size: 12px; + color: #7a7c73; +} +.sy-desk-crumb a { + color: inherit; + flex: none; +} +.sy-desk-crumb a:hover { + color: #1e2025; +} +.sy-desk-crumb-sep { + color: #b5b1a5; + flex: none; +} +.sy-desk-crumb-topic { + flex: 0 1 auto; + min-width: 0; + max-width: 22ch; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sy-desk-crumb b { + flex: 0 1 auto; + min-width: 0; + color: #2a2c32; + font-weight: 550; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sy-desk-top-right { + display: flex; + align-items: center; + gap: 9px; + flex: none; +} +.sy-desk-count { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.08em; + color: #8a8c84; + white-space: nowrap; +} +.sy-desk-top .sy-button { + min-height: 32px; + font-size: 12px; + gap: 6px; + white-space: nowrap; +} +.sy-desk-top .sy-button svg { + width: 13px; + height: 13px; +} +.sy-desk-alert { + padding: 16px 32px 0; +} + +/* Two-column workbench */ +.sy-desk-grid { + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + align-items: start; + gap: 30px; + width: 100%; + max-width: 1440px; + margin: 0 auto; + padding: 26px 32px 90px; +} +.sy-desk-work { + display: grid; + gap: 10px; + min-width: 0; + position: sticky; + top: 76px; + max-height: calc(100dvh - 94px); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: #d5d2c7 transparent; + padding-bottom: 2px; +} +.sy-desk-work > * { + min-width: 0; +} +.sy-desk-card { + background: #fff; + border: 1px solid #e0ded5; + border-radius: 8px; + overflow: hidden; +} +.sy-desk-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #eae7dd; + font-size: 10.5px; + line-height: 1.4; + letter-spacing: 0.07em; + text-transform: uppercase; + color: #7a7c73; +} +.sy-desk-card-head b { + font-weight: 600; + color: #5d5f57; +} + +/* Video */ +.sy-desk-live { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} +.sy-desk-live i { + width: 6px; + height: 6px; + border-radius: 50%; + background: #3f4cc4; + flex: none; +} +.sy-desk-video-body { + background: #0d0d0f; +} +.sy-desk-video-body .sy-embed { + border-radius: 0; +} +.sy-desk-video-body .sy-error { + margin: 10px; +} +.sy-desk-video-empty { + padding: 26px 18px; + background: #fbfaf7; + border-bottom: 1px solid #eae7dd; +} +.sy-desk-video-empty p { + font-size: 12.5px; + line-height: 1.65; + color: #7a7c73; +} +.sy-desk-video-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; +} +.sy-desk-video-foot > div { + min-width: 0; +} +.sy-desk-video-foot b { + display: block; + font-size: 12.5px; + font-weight: 550; + line-height: 1.45; + overflow-wrap: anywhere; +} +.sy-desk-video-foot span { + display: block; + margin-top: 2px; + font-size: 11px; + color: #8a8c84; + font-variant-numeric: tabular-nums; +} +.sy-desk-external { + display: inline-flex; + align-items: center; + gap: 5px; + flex: none; + font-size: 11px; + color: #7a7c73; +} +.sy-desk-external:hover { + color: #1e2025; +} +.sy-desk-manual { + border-top: 1px solid #eae7dd; + padding: 0 14px 12px; +} +.sy-desk-manual summary { + padding: 9px 0; + font-size: 11px; + line-height: 1.5; + color: #8a8c84; +} +.sy-desk-manual summary:hover { + color: #1e2025; +} +.sy-desk-manual-row { + display: flex; + align-items: flex-end; + gap: 8px; +} +.sy-desk-manual-row label { + display: grid; + gap: 5px; + font-size: 10.5px; + color: #8a8c84; +} +.sy-desk-manual-row .sy-input { + width: 88px; + min-height: 34px; + padding: 4px 8px; + font-size: 12px; + background: #fbfaf7; +} +.sy-desk-manual-row .sy-button { + min-height: 34px; + font-size: 12px; +} + +/* Chapters */ +.sy-desk-chapters ol { + list-style: none; + margin: 0; + padding: 4px 0; +} +.sy-desk-chapters li + li { + border-top: 1px solid #f4f2ec; +} +.sy-desk-chapters button { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 9px; + width: 100%; + padding: 8px 14px; + text-align: left; + font-size: 12.5px; + line-height: 1.5; + color: #4c4e47; +} +.sy-desk-chapters button:hover { + background: #fbfaf7; + color: #1e2025; +} +.sy-desk-chapters button[aria-current="true"] { + background: #f6f5ef; + color: #1e2025; + font-weight: 550; + box-shadow: inset 2px 0 0 #3f4cc4; +} +.sy-desk-chapters button .sy-desk-mono { + font-size: 10.5px; + color: #8a8c84; + padding-top: 2px; +} + +/* Notes */ +.sy-desk-note-form { + display: flex; + align-items: flex-end; + gap: 7px; + padding: 12px 14px; + border-bottom: 1px solid #f4f2ec; +} +.sy-desk-note-form textarea { + flex: 1; + min-width: 0; + min-height: 36px; + max-height: 140px; + padding: 8px 10px; + resize: vertical; + background: #fbfaf7; + color: #1e2025; + border: 1px solid #dcd9cf; + border-radius: 6px; + font: 12.5px/1.5 var(--font-sans); + field-sizing: content; +} +.sy-desk-note-form textarea::placeholder { + color: #a3a59b; +} +.sy-desk-note-form textarea:focus-visible { + outline-offset: 0; + border-color: #3f4cc4; +} +.sy-desk-note-form button { + display: grid; + place-items: center; + width: 36px; + height: 36px; + flex: none; + border-radius: 6px; + background: #1e2025; + color: #fff; +} +.sy-desk-note-form button:disabled { + opacity: 0.3; + cursor: default; +} +.sy-desk-notes-empty { + padding: 13px 14px; + font-size: 11.5px; + line-height: 1.6; + color: #8a8c84; +} +.sy-desk-note-list { + list-style: none; + margin: 0; + padding: 0; +} +.sy-desk-note-list li { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 9px; + padding: 10px 14px; + border-bottom: 1px solid #f4f2ec; +} +.sy-desk-note-list li:last-child { + border-bottom: 0; +} +.sy-desk-note-time { + padding-top: 2px; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 10.5px; + color: #3f4cc4; + white-space: nowrap; +} +.sy-desk-note-time:hover { + text-decoration: underline; +} +.sy-desk-note-list p { + font-size: 12.5px; + line-height: 1.6; + color: #3a3c35; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.sy-desk-note-del { + padding: 2px; + color: #c2bfb3; +} +.sy-desk-note-del:hover { + color: #a3324a; +} + +/* Lessons */ +.sy-desk-lessons ol { + list-style: none; + margin: 0; + padding: 0; +} +.sy-desk-lessons li + li { + border-top: 1px solid #f4f2ec; +} +.sy-desk-lessons button { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 8px; + align-items: center; + gap: 9px; + width: 100%; + padding: 9px 14px; + text-align: left; + font-size: 12.5px; + line-height: 1.5; + color: #5d5f57; +} +.sy-desk-lessons button:hover { + background: #fbfaf7; + color: #1e2025; +} +.sy-desk-lessons button[aria-current="step"] { + background: #f6f5ef; + color: #1e2025; + font-weight: 550; +} +.sy-desk-lessons button .sy-desk-mono { + font-size: 10px; + color: #a8aa9f; +} +.sy-desk-pip { + justify-self: end; + width: 6px; + height: 6px; + border: 1px solid #cfccc0; + border-radius: 50%; +} +.sy-desk-pip[data-done] { + background: #3f4cc4; + border-color: #3f4cc4; +} + +/* Pomodoro, detuned to fit a card */ +.sy-desk-pomodoro-card { + padding: 14px 14px 16px; +} +.sy-desk-pomodoro-card .sy-pomodoro { + padding: 0; + margin: 0; + border: 0; +} +.sy-desk-pomodoro-card .sy-timer-clock { + font-size: 30px; + letter-spacing: -0.8px; + margin: 8px 0 12px; +} +.sy-desk-pomodoro-card .sy-timer-actions { + gap: 8px; +} +.sy-desk .sy-timer-settings input { + background: #fbfaf7; + color: #1e2025; + border-color: #dcd9cf; +} + +/* Lesson claim */ +.sy-desk-panel { + min-width: 0; +} +.sy-desk-claim { + padding: 4px 2px 0; +} +.sy-desk-claim-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} +.sy-desk-eyebrow { + margin: 0; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #8a8c84; +} +.sy-desk-done { + display: inline-flex; + align-items: center; + gap: 7px; + flex: none; + font-size: 11.5px; + color: #7a7c73; +} +.sy-desk-done input { + width: 15px; + height: 15px; + margin: 0; + accent-color: #3f4cc4; +} +.sy-desk-done:has(input:checked) { + color: #1e2025; +} +.sy-desk-claim h1 { + margin-top: 10px; + font-size: 34px; + font-weight: 560; + letter-spacing: -1.1px; + line-height: 1.14; + text-wrap: balance; +} +.sy-desk-claim-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin-top: 12px; + font-size: 12px; + color: #7a7c73; +} +.sy-desk-claim-meta span + span::before { + content: "·"; + margin-right: 10px; + color: #c9c6ba; +} + +/* Tabs */ +.sy-desk-tabs { + position: sticky; + top: 54px; + z-index: 20; + display: flex; + align-items: center; + gap: 20px; + margin-top: 20px; + padding: 0 2px; + background: #f7f6f2; + border-bottom: 1px solid #e0ded5; +} +.sy-desk-tabs button { + display: inline-flex; + align-items: center; + gap: 7px; + margin-bottom: -1px; + padding: 10px 1px 11px; + border-bottom: 2px solid transparent; + background: none; + font-size: 13px; + font-weight: 500; + color: #7a7c73; + white-space: nowrap; +} +.sy-desk-tabs button:hover { + color: #1e2025; +} +.sy-desk-tabs button[aria-selected="true"] { + color: #1e2025; + border-bottom-color: #1e2025; +} +.sy-desk-tab-count { + font-family: var(--font-mono); + font-size: 10px; + line-height: 1; + color: #5d5f57; + background: #eae7dd; + border-radius: 20px; + padding: 2px 6px; +} + +/* Reading surface */ +.sy-desk-surface { + background: #fff; + border: 1px solid #e0ded5; + border-top: 0; + border-radius: 0 0 8px 8px; +} +.sy-desk-tab { + padding: 30px 38px 44px; +} +.sy-desk-tab[hidden] { + display: none; +} +.sy-desk-copy { + max-width: 700px; +} +.sy-desk-lede { + font-size: 17px; + line-height: 1.68; + color: #2f312c; + letter-spacing: -0.1px; +} +.sy-desk-block { + margin-top: 36px; +} +.sy-desk-block > h2 { + margin-bottom: 14px; + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.11em; + text-transform: uppercase; + color: #7a7c73; +} +.sy-desk-block .sy-desk-hint { + margin-top: -4px; + margin-bottom: 14px; +} +.sy-desk-prose p { + font-size: 15px; + line-height: 1.78; + color: #33352f; +} +.sy-desk-prose p + p { + margin-top: 14px; +} +.sy-desk-prose b { + font-weight: 600; + color: #1e2025; +} +.sy-desk-objectives { + list-style: none; + margin: 18px 0 0; + padding: 0; + border-top: 1px solid #eae7dd; +} +.sy-desk-objectives li { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + gap: 10px; + padding: 11px 0; + border-bottom: 1px solid #eae7dd; + font-size: 14px; + line-height: 1.6; + color: #3a3c35; +} +.sy-desk-objectives .sy-desk-mono { + padding-top: 3px; + font-size: 10.5px; + color: #a8aa9f; +} + +/* Sources */ +.sy-desk-source-list { + display: grid; + gap: 8px; +} +.sy-desk-source { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid #e0ded5; + border-radius: 7px; + background: #fbfaf7; + color: inherit; + transition: + border-color 0.12s ease, + background 0.12s ease; +} +.sy-desk-source:hover { + border-color: #c9c6ba; + background: #fff; +} +.sy-desk-source-type { + font-size: 9.5px; + letter-spacing: 0.07em; + text-transform: uppercase; + color: #7a7c73; + background: #fff; + border: 1px solid #e0ded5; + border-radius: 4px; + padding: 3px 6px; +} +.sy-desk-source-body { + min-width: 0; +} +.sy-desk-source-body b { + display: block; + font-size: 13.5px; + font-weight: 550; + line-height: 1.45; + overflow-wrap: anywhere; +} +.sy-desk-source-body small { + display: block; + margin-top: 2px; + font-size: 11.5px; + line-height: 1.55; + color: #7a7c73; +} +.sy-desk-source > svg { + color: #a8aa9f; + flex: none; +} + +/* Check tab */ +.sy-desk-tab-head h2 { + font-size: 21px; + font-weight: 550; + letter-spacing: -0.4px; + line-height: 1.25; +} +.sy-desk-tab-head p { + margin-top: 8px; + font-size: 14px; + line-height: 1.7; + color: #7a7c73; +} +.sy-desk-subtasks { + list-style: none; + margin: 22px 0 0; + padding: 0; + border-top: 1px solid #eae7dd; +} +.sy-desk-subtasks li { + border-bottom: 1px solid #eae7dd; +} +.sy-desk-subtasks label { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 13px 0; + font-size: 14.5px; + line-height: 1.55; + color: #2f312c; + cursor: pointer; +} +.sy-desk-subtasks input { + flex: none; + width: 16px; + height: 16px; + margin: 3px 0 0; + accent-color: #3f4cc4; +} +.sy-desk-subtasks label:has(input:checked) span { + color: #8a8c84; + text-decoration: line-through; + text-decoration-color: #c9c6ba; +} + +/* Apply tab */ +.sy-desk-field { + display: grid; + gap: 8px; + margin-top: 22px; +} +.sy-desk-field > span { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: #7a7c73; +} +.sy-desk .sy-textarea { + min-height: 150px; + padding: 13px 14px; + background: #fff; + color: #1e2025; + border-color: #dcd9cf; + font-size: 14.5px; + line-height: 1.7; +} +.sy-desk .sy-textarea::placeholder { + color: #a3a59b; +} +.sy-desk .sy-textarea:focus-visible { + outline-offset: 0; + border-color: #3f4cc4; +} +.sy-desk-actions { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + margin-top: 16px; +} +.sy-desk-review > .sy-button.quiet { + margin-top: 2px; + padding: 8px 14px; + background: #fff; + color: #1e2025; + border: 1px solid #c9c6ba; + border-radius: 6px; +} +.sy-desk-review > .sy-button.quiet:hover { + background: #fbfaf7; + border-color: #9a978a; +} +.sy-desk-review > .sy-form { + padding: 18px; + background: #fbfaf7; + border: 1px solid #e0ded5; + border-radius: 8px; +} +.sy-desk-saved { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: #5d5f57; +} +.sy-desk-hint { + font-size: 13px; + line-height: 1.7; + color: #7a7c73; +} +.sy-desk-next { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-top: 40px; + padding-top: 22px; + border-top: 1px solid #eae7dd; +} +.sy-desk-next > div { + min-width: 0; +} +.sy-desk-next p { + margin-top: 5px; + font-size: 15px; + font-weight: 550; + line-height: 1.45; + overflow-wrap: anywhere; +} +.sy-desk-next .sy-button { + flex: none; +} + +/* Shared controls, re-tuned for paper */ +.sy-desk .sy-button.primary { + background: #1e2025; + color: #fff; +} +.sy-desk .sy-button.primary:hover:not(:disabled) { + background: #000; +} +.sy-desk .sy-button.primary:disabled { + opacity: 1; + background: #eceae3; + color: #9a9c93; +} +.sy-desk .sy-button.secondary { + background: #fff; + color: #1e2025; + border: 1px solid #c9c6ba; +} +.sy-desk .sy-button.secondary:hover:not(:disabled) { + border-color: #9a978a; + background: #fbfaf7; +} +.sy-desk .sy-button.quiet { + color: #5d5f57; +} +.sy-desk .sy-button.quiet:hover { + color: #1e2025; + background: #eae7dd; +} +.sy-desk .sy-button.icon { + background: #f1efe8; + color: #1e2025; +} +.sy-desk .sy-input { + background: #fff; + color: #1e2025; + border-color: #dcd9cf; +} +.sy-desk .sy-form label { + color: #3a3c35; +} +.sy-desk .sy-form-hint { + color: #7a7c73; +} +.sy-desk .sy-card-actions { + margin-top: 18px; +} +.sy-desk .sy-error { + color: #a3324a; + background: #fdf5f6; + border-color: #e3c4ca; +} +.sy-desk .sy-empty { + color: #7a7c73; +} +.sy-desk-empty { + padding: 12px 0; + font-size: 13.5px; + line-height: 1.7; + color: #7a7c73; +} + +/* Loading and empty states */ +.sy-desk-simple { + display: grid; + place-items: center; + padding: 80px 24px; + text-align: center; +} +.sy-desk-simple-inner { + max-width: 460px; +} +.sy-desk-simple h1 { + margin-top: 10px; + font-size: 27px; + letter-spacing: -0.7px; + line-height: 1.25; +} +.sy-desk-simple .sy-desk-lede { + margin-top: 12px; + font-size: 14.5px; + color: #5d5f57; +} +.sy-desk-simple .sy-desk-actions { + justify-content: center; + margin-top: 22px; +} + +/* ---- Responsive ---- */ +@media (max-width: 1240px) { + .sy-desk-grid { + grid-template-columns: 304px minmax(0, 1fr); + gap: 24px; + padding-inline: 24px; + } + .sy-desk-tab { + padding: 26px 30px 40px; + } +} +@media (max-width: 1100px) { + .sy-desk-grid { + grid-template-columns: 280px minmax(0, 1fr); + gap: 22px; + } + .sy-desk-claim h1 { + font-size: 30px; + } +} +@media (max-width: 1024px) { + /* The mobile nav is sticky here, so the page bar must scroll away. */ + .sy-desk-top, + .sy-desk-tabs { + position: static; + } + .sy-desk-alert { + padding: 16px 24px 0; + } + .sy-desk-claim h1 { + font-size: 32px; + } +} +@media (max-width: 980px) { + .sy-desk-grid { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + padding: 18px 24px 80px; + } + .sy-desk-work { + display: contents; + } + .sy-desk-video { + order: 1; + } + .sy-desk-chapters { + order: 2; + } + .sy-desk-panel { + order: 3; + } + .sy-desk-notes { + order: 4; + } + .sy-desk-lessons { + order: 5; + } + .sy-desk-pomodoro-card { + order: 6; + } + .sy-desk-tabs { + margin-top: 18px; + } + .sy-desk-note-form textarea { + min-height: 40px; + } + .sy-desk-pip { + width: 7px; + height: 7px; + } +} +@media (max-width: 760px) { + .sy-desk-top { + height: auto; + min-height: 48px; + padding: 8px 20px; + } + .sy-desk-claim h1 { + font-size: 27px; + letter-spacing: -0.8px; + } + .sy-desk-grid { + padding: 14px 20px 72px; + } + .sy-desk-tab { + padding: 22px 22px 32px; + } + .sy-desk-lede { + font-size: 15.5px; + } + .sy-desk-prose p { + font-size: 14.5px; + } + .sy-desk-tab-head h2 { + font-size: 19px; + } + .sy-desk-claim-meta { + font-size: 11.5px; + } + .sy-desk-alert { + padding: 14px 20px 0; + } +} +@media (max-width: 600px) { + .sy-desk-top { + padding: 8px 16px; + gap: 10px; + } + .sy-desk-crumb-topic, + .sy-desk-crumb-sep, + .sy-desk-count { + display: none; + } + .sy-desk-edit-label { + display: none; + } + .sy-desk-top .sy-button { + min-height: 30px; + padding: 4px 6px; + } + .sy-desk-claim { + padding: 2px 0 0; + } + .sy-desk-claim h1 { + font-size: 24px; + letter-spacing: -0.6px; + } + .sy-desk-claim-top { + gap: 10px; + } + .sy-desk-tabs { + gap: 18px; + overflow-x: auto; + scrollbar-width: none; + } + .sy-desk-tabs::-webkit-scrollbar { + display: none; + } + .sy-desk-grid { + padding: 12px 16px 64px; + } + .sy-desk-tab { + padding: 20px 16px 28px; + } + .sy-desk-surface { + border-radius: 0 0 6px 6px; + } + .sy-desk-source { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 10px; + padding: 11px 12px; + } + .sy-desk-source-type { + grid-column: 1 / -1; + justify-self: start; + } + .sy-desk-objectives li { + grid-template-columns: 24px minmax(0, 1fr); + } + .sy-desk-next { + gap: 12px; + margin-top: 30px; + } + .sy-desk-next .sy-button { + width: 100%; + } + .sy-desk-actions .sy-button { + width: 100%; + } + .sy-desk-simple { + padding: 56px 20px; + } + .sy-desk-simple h1 { + font-size: 23px; + } + .sy-desk-alert { + padding: 12px 16px 0; + } +} diff --git a/apps/web/src/views/studio/learn.tsx b/apps/web/src/views/studio/learn.tsx index a960eed..4bf5ee0 100644 --- a/apps/web/src/views/studio/learn.tsx +++ b/apps/web/src/views/studio/learn.tsx @@ -1,26 +1,45 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; import { Link, useNavigate } from "@tanstack/react-router"; -import { ArrowRight, Check, ExternalLink } from "lucide-react"; +import { + ArrowRight, + Check, + ExternalLink, + Plus, + Trash2, +} from "lucide-react"; import { useLearningRoadmaps } from "@/hooks/useLearningRoadmaps"; import { getLearningTasks, + learningVideo, nextLearningTask, safeResourceUrl, + setLearningSubtaskDone, setLearningTaskDone, taskVideo, } from "@/lib/learning"; import { getVideoStartSeconds } from "@/lib/video-utils"; -import { formatTimestamp } from "@/lib/scout/format"; +import { formatTimestamp, parseTimestamp } from "@/lib/scout/format"; import { getPersonalSnapshot, personalEntries, putPersonalEntry, + removePersonalEntry, usePersonalData, } from "@/lib/personal/store"; import StudyVideo from "@/components/learning/StudyVideo"; import { Pomodoro } from "@/components/learning/Pomodoro"; import ReviewComposer from "@/components/learning/ReviewComposer"; +type TabKey = "read" | "check" | "apply"; +const TAB_ORDER: TabKey[] = ["read", "check", "apply"]; + export default function LearnPage({ roadmapId, taskId, @@ -40,59 +59,92 @@ export default function LearnPage({ () => (roadmap ? getLearningTasks(roadmap) : []), [roadmap], ); - const item = + const candidate = tasks.find((entry) => entry.task.id === taskId) ?? (roadmap ? (nextLearningTask(roadmap) ?? tasks[0]) : null); - const video = item ? taskVideo(item) : null; + // Keep the open lesson steady when completion updates the roadmap. + const openTaskId = useRef(null); + if (openTaskId.current === null) + openTaskId.current = candidate?.task.id ?? null; + const item = + tasks.find((entry) => entry.task.id === openTaskId.current) ?? + candidate; + const videoResource = item ? taskVideo(item) : null; + const video = useMemo( + () => (roadmap && videoResource ? learningVideo(roadmap, videoResource.url) : null), + [roadmap, videoResource], + ); + const activeUrl = + videoResource?.url ?? item?.resources[0]?.url ?? ""; + const activeId = item?.task.id; + const activeRoadmap = roadmap?.id; + const practiceId = + activeRoadmap && activeId + ? `annotation:practice:${activeRoadmap}:${activeId}` + : ""; + const studyId = + activeRoadmap && activeId ? `study:${activeRoadmap}:${activeId}` : ""; + const [draft, setDraft] = useState(""); - const [tab, setTab] = useState<"notes" | "roadmap">("notes"); + const [tab, setTab] = useState("read"); const [seekTo, setSeekTo] = useState(null); - const [composerOpen, setComposerOpen] = useState(false); const [position, setPosition] = useState(0); const positionRef = useRef(0); - const startRef = useRef(0); const [manualPosition, setManualPosition] = useState(""); const [error, setError] = useState(null); - const activeId = item?.task.id; - const activeRoadmap = roadmap?.id; - const activeUrl = video?.url ?? item?.resources[0]?.url ?? ""; - const notes = personalEntries(records, "annotation") - .filter( - (entry) => - entry.data.anchor.kind === "lesson" && - entry.data.anchor.roadmapId === roadmap?.id && - entry.data.anchor.taskId === item?.task.id, - ) - .sort((a, b) => b.updatedAt - a.updatedAt); + const [practice, setPractice] = useState(""); + const tabRefs = useRef>({ + read: null, + check: null, + apply: null, + }); + const uid = useId().replace(/:/g, ""); + const tabId = (key: TabKey) => `${uid}-tab-${key}`; + const panelId = (key: TabKey) => `${uid}-panel-${key}`; + useEffect(() => { if (!activeId || !activeRoadmap) return; - const stored = - getPersonalSnapshot()[`study:${activeRoadmap}:${activeId}`]; + const stored = getPersonalSnapshot()[`study:${activeRoadmap}:${activeId}`]; const seconds = targetSeconds ?? (stored?.kind === "study" && stored.data.resourceUrl === activeUrl ? stored.data.seconds : getVideoStartSeconds(activeUrl)); - startRef.current = seconds; positionRef.current = seconds; setPosition(seconds); setSeekTo(null); setDraft(""); setError(null); - putPersonalEntry({ - kind: "study", - id: `study:${activeRoadmap}:${activeId}`, - updatedAt: Date.now(), - deleted: false, - data: { - roadmapId: activeRoadmap, - taskId: activeId, - resourceUrl: activeUrl, - seconds, - lastOpenedAt: new Date().toISOString(), - }, - }); - }, [activeId, activeRoadmap, activeUrl, targetSeconds]); + setTab("read"); + const savedPractice = practiceId + ? getPersonalSnapshot()[practiceId] + : undefined; + setPractice( + savedPractice?.kind === "annotation" + ? savedPractice.data.text + : "", + ); + try { + putPersonalEntry({ + kind: "study", + id: `study:${activeRoadmap}:${activeId}`, + updatedAt: Date.now(), + deleted: false, + data: { + roadmapId: activeRoadmap, + taskId: activeId, + resourceUrl: activeUrl, + seconds, + lastOpenedAt: new Date().toISOString(), + }, + }); + } catch { + setError( + "Your playback position could not be saved. Free some browser storage and try again.", + ); + } + }, [activeId, activeRoadmap, activeUrl, targetSeconds, practiceId]); + function recordPosition(seconds: number) { if (!activeRoadmap || !activeId) return; positionRef.current = seconds; @@ -117,6 +169,14 @@ export default function LearnPage({ ); } } + + function seek(seconds: number) { + const safe = Math.max(0, seconds); + recordPosition(safe); + setSeekTo(null); + requestAnimationFrame(() => setSeekTo(safe)); + } + function saveNote() { if (!draft.trim() || !roadmap || !item) return; try { @@ -140,388 +200,875 @@ export default function LearnPage({ }, }); setDraft(""); + setError(null); } catch { setError( "Your note could not be saved. Free some browser storage and try again.", ); } } + + function savePractice() { + if (!practice.trim() || !roadmap || !item || !practiceId) return; + try { + putPersonalEntry({ + kind: "annotation", + id: practiceId, + updatedAt: Date.now(), + deleted: false, + data: { + text: practice.trim(), + quote: "", + color: "#b7c9e8", + createdAt: new Date().toISOString(), + anchor: { + kind: "lesson", + roadmapId: roadmap.id, + taskId: item.task.id, + url: activeUrl, + seconds: 0, + }, + }, + }); + setError(null); + } catch { + setError( + "Your practice note could not be saved. Free some browser storage and try again.", + ); + } + } + + function onTabKey(event: KeyboardEvent, index: number) { + let next = index; + if (event.key === "ArrowRight") next = (index + 1) % TAB_ORDER.length; + else if (event.key === "ArrowLeft") + next = (index - 1 + TAB_ORDER.length) % TAB_ORDER.length; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = TAB_ORDER.length - 1; + else return; + event.preventDefault(); + const key = TAB_ORDER[next]; + setTab(key); + tabRefs.current[key]?.focus(); + } + if (!loaded) return ( -
- Loading your learning topic… +
+

Loading your learning topic…

); if (!roadmap) return ( -
-

Learn

-
-

+

+
+

Learn

+

{roadmapId - ? "This learning topic could not be found on this device." + ? "This learning topic is not on this device" + : "Start a learning topic"} +

+

+ {roadmapId + ? "It may have been deleted here, or it only exists on another device." : "Start with a goal, or organize resources you already have."}

- - Open Scout - - - - Add your own roadmap - +
+ + Open Scout + + + + Add your own roadmap + +
); if (!item) return ( -
-

{roadmap.title}

-
-

- This roadmap has no task list yet. Open its full - workspace to add tasks or explore its resources. +

+
+

{roadmap.title}

+

This roadmap has no lessons yet

+

+ Open its workspace to add tasks and resources, or + explore what it already holds.

- - Open full workspace - - +
+ + Open full workspace + + +
); - const source = safeResourceUrl(activeUrl); - const external = source ? new URL(source) : null; - if (external && video) - external.searchParams.set("t", `${Math.floor(position)}s`); - const saved = records[`study:${roadmap.id}:${item.task.id}`]; + + const index = tasks.findIndex( + (entry) => entry.task.id === item.task.id, + ); + const title = item.task.title || item.task.text || "Untitled lesson"; + const done = item.task.completed || !!item.task.done; + const next = tasks[index + 1] ?? null; + const practiceEntry = practiceId ? records[practiceId] : undefined; + const practiceSaved = + practiceEntry?.kind === "annotation" && + practiceEntry.data.text === practice.trim() && + practice.trim() !== ""; + const subtasks = item.task.subtasks; + const doneSubtasks = subtasks.filter((subtask) => subtask.completed).length; + const section = roadmap.sections.find( + (entry) => entry.id === item.sectionId, + ); + const moduleData = section?.type === "module" ? section.data : null; + const milestone = + section?.type === "milestones" + ? section.data.find((entry) => entry.title === item.sectionTitle) + : undefined; + const contextDescription = + moduleData?.description || milestone?.description || ""; + const objectives = moduleData?.objectives ?? []; + const concepts = moduleData?.concepts ?? ""; + const sectionNotes = moduleData?.notes ?? ""; + const brief = item.task.description || item.task.text || ""; + const savedStudy = studyId ? records[studyId] : undefined; const startSeconds = targetSeconds ?? - (saved?.kind === "study" - ? saved.data.seconds + (savedStudy?.kind === "study" && + savedStudy.data.resourceUrl === activeUrl + ? savedStudy.data.seconds : getVideoStartSeconds(activeUrl)); + const externalSource = safeResourceUrl(activeUrl); + const external = + externalSource && video ? new URL(externalSource) : null; + if (external) external.searchParams.set("t", `${Math.floor(position)}s`); + const chapters = (video?.timestamps ?? []).flatMap((stamp) => { + const seconds = parseTimestamp(stamp.time); + return seconds === null + ? [] + : [ + { + seconds, + label: stamp.label || stamp.time, + time: stamp.time, + }, + ]; + }); + const activeChapter = chapters.reduce( + (current, chapter) => + position + 0.5 >= chapter.seconds ? chapter.seconds : current, + null, + ); + const notes = personalEntries(records, "annotation") + .flatMap((entry) => { + const anchor = entry.data.anchor; + if (anchor.kind !== "lesson") return []; + if ( + anchor.roadmapId !== roadmap.id || + anchor.taskId !== item.task.id + ) + return []; + if (entry.id.startsWith("annotation:practice:")) return []; + return [ + { + id: entry.id, + text: entry.data.text, + seconds: anchor.seconds, + updatedAt: entry.updatedAt, + }, + ]; + }) + .sort((a, b) => b.updatedAt - a.updatedAt); + const sources = item.resources.filter( + (resource) => safeResourceUrl(resource.url) !== null, + ); + return ( -
-
- Your learning topics - / - {roadmap.title} -
-
-
-

{item.task.title || item.task.text}

-

{item.sectionTitle}

+
+
+ +
+ + LESSON {index + 1} / {tasks.length} + + + + Edit roadmap + + +
- - Edit roadmap - -
+ {error && ( -

- {error} -

+
+

+ {error} +

+
)} -
-
- {video ? ( - <> - -
- - Saved at {formatTimestamp(position)} + +
+