Yōten: A social tracker for your language learning journey built on the atproto.
at master 589 lines 20 kB view raw
1package views 2 3import ( 4 "fmt" 5 6 "strconv" 7 "yoten.app/internal/server/views/layouts" 8 "yoten.app/internal/server/views/partials" 9) 10 11templ NewStudySessionPage(params NewStudySessionPageParams) { 12 {{ 13 var initialLangCode = "" 14 15 if len(params.Profile.Languages) == 1 { 16 initialLangCode = string(params.Profile.Languages[0].Code) 17 } 18 19 activityName := "Select an activity" 20 if params.QueryParams.ActivityId != "" { 21 activityId, err := strconv.ParseInt(params.QueryParams.ActivityId, 10, 64) 22 if err == nil { 23 for _, a := range params.Activities { 24 if int64(a.ID) == activityId { 25 activityName = a.Name 26 } 27 } 28 } 29 } 30 }} 31 @layouts.Base(layouts.BaseParams{Title: "new study session"}) { 32 @partials.Header(partials.HeaderProps{User: params.User}) 33 <div class="container mx-auto px-4 py-8 max-w-2xl"> 34 <form 35 x-data="studySessionForm()" 36 x-effect="filteredActivities; $nextTick(() => { lucide.createIcons() })" 37 class="card group" 38 hx-post="/session/new" 39 hx-swap="none" 40 hx-vals="js:{timezone: Intl.DateTimeFormat().resolvedOptions().timeZone}" 41 hx-disabled-elt="#save-button,#cancel-button,#start-timer-button,#pause-timer-button,#reset-timer-button,#stop-timer-button" 42 > 43 <h1 class="text-3xl font-bold">Log New Study Session</h1> 44 <div class="flex p-1"> 45 <button 46 type="button" 47 @click="mode = 'manual'" 48 :class="mode === 'manual' ? 'border-b-primary text-text font-semibold' : 'border-b-white text-text-muted'" 49 class="cursor-pointer w-1/2 py-2 px-4 border-b-2 transition-all" 50 >Manual Entry</button> 51 <button 52 type="button" 53 @click="mode = 'timer'" 54 :class="mode === 'timer' ? 'border-b-primary text-text font-semibold' : 'border-b-white text-text-muted'" 55 class="cursor-pointer w-1/2 py-2 px-4 border-b-2 transition-all" 56 >Use Timer</button> 57 </div> 58 <div class="flex flex-col gap-4"> 59 <div x-show="mode === 'timer'" x-transition class="text-center py-4"> 60 <p class="text-6xl font-mono tabular-nums" x-text="formattedTime"></p> 61 <p class="text-sm text-text-muted mt-2">HH:MM:SS</p> 62 <p 63 x-show="!canStart() && timerState === 'stopped'" 64 x-transition 65 class="text-sm text-text-muted mt-6" 66 > 67 Please select an activity and language to start the timer. 68 </p> 69 <div class="flex justify-center gap-4 mt-4"> 70 <button 71 type="button" 72 x-show="timerState !== 'running'" 73 @click="start()" 74 :disabled="!canStart()" 75 id="start-timer-button" 76 class="btn btn-primary disabled:opacity-50" 77 > 78 <i data-lucide="play" class="w-4 h-4"></i> 79 <span x-text="timerState === 'paused' ? 'Resume' : 'Start'"></span> 80 </button> 81 <button 82 type="button" 83 x-show="timerState === 'running'" 84 @click="pause()" 85 id="pause-timer-button" 86 class="btn btn-secondary" 87 > 88 <i data-lucide="pause" class="w-4 h-4"></i> 89 <span>Pause</span> 90 </button> 91 <button 92 type="button" 93 x-show="timerState === 'paused' || (timerState === 'stopped' && elapsedSeconds > 0)" 94 @click="reset()" 95 id="reset-timer-button" 96 class="btn btn-secondary" 97 > 98 <i data-lucide="rotate-ccw" class="w-4 h-4"></i> 99 <span>Reset</span> 100 </button> 101 <button 102 type="button" 103 @click="stopAndLog()" 104 :disabled="timerState === 'stopped'" 105 id="stop-timer-button" 106 class="btn btn-dangerous disabled:opacity-50" 107 > 108 <i data-lucide="send" class="w-4 h-4"></i> 109 <span>Stop & Log</span> 110 </button> 111 </div> 112 </div> 113 <div class="flex flex-col gap-2"> 114 <label for="activity-button" class="font-medium text-sm">Activity</label> 115 <div 116 class="relative" 117 id="activity-select" 118 @click.outside="activitiesIsOpen = false" 119 > 120 <button 121 type="button" 122 id="activity-button" 123 class="input relative w-full cursor-default text-left" 124 @click="activitiesIsOpen = !activitiesIsOpen" 125 :aria-expanded="activitiesIsOpen" 126 > 127 <span 128 x-text="selectedActivityName" 129 class="block truncate" 130 ></span> 131 <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> 132 <i class="w-4 h-4" data-lucide="chevrons-up-down"></i> 133 </span> 134 </button> 135 <input type="hidden" name="activity_id" x-model="selectedActivityId"/> 136 <ul 137 x-show="activitiesIsOpen" 138 x-transition 139 id="activities-list" 140 class="absolute z-10 mt-1 max-h-80 w-full overflow-auto rounded-md shadow-lg bg-bg-light" 141 > 142 <div class="z-20 p-2 sticky top-0 bg-bg-light"> 143 <input 144 type="text" 145 x-ref="searchInput" 146 x-model.debounce.200ms="activitiesSearchQuery" 147 placeholder="Search activities..." 148 class="input w-full" 149 /> 150 </div> 151 <template x-for="activity in filteredActivities" :key="activity.ID"> 152 <li 153 class="relative cursor-default select-none p-4 hover:bg-bg" 154 @click="selectActivity(activity)" 155 > 156 <div class="flex items-center justify-between"> 157 <div class="flex flex-col"> 158 <div class="flex gap-2 items-center"> 159 <template x-if="activity.Did"><i class="w-4 h-4" data-lucide="user"></i></template> 160 <span class="font-semibold block truncate" x-text="activity.Name"></span> 161 <template x-if="activity.Status == 1"> 162 <span class="ml-2 font-normal text-text-muted">(deleted)</span> 163 </template> 164 </div> 165 <span class="text-sm text-text-muted mt-1" x-text="activity.Description"></span> 166 <div class="mt-2 flex flex-wrap gap-2"> 167 <template x-for="category in activity.Categories" :key="category.ID"> 168 <span class="inline-flex pill pill-primary" x-text="category.Name"></span> 169 </template> 170 </div> 171 </div> 172 <i 173 x-show="selectedActivityId == activity.ID" 174 class="w-4 h-4 text-primary ml-3" 175 data-lucide="check" 176 ></i> 177 </div> 178 </li> 179 </template> 180 <template x-if="filteredActivities.length === 0"> 181 <li class="p-4 text-center text-text-muted">No activities found.</li> 182 </template> 183 </ul> 184 </div> 185 <div class="flex items-center justify-between text-xs text-text-muted"> 186 <span>Don't see what you're looking for?</span> 187 <a href="/activity/new" class="font-medium hover:underline"> 188 Create custom activity 189 </a> 190 </div> 191 </div> 192 <div class="flex flex-col gap-2"> 193 <label for="resource-button" class="font-medium text-sm">Resource (optional)</label> 194 <div 195 class="relative" 196 id="resource-select" 197 @click.outside="resourcesIsOpen = false" 198 > 199 <button 200 type="button" 201 id="resource-button" 202 class="input relative w-full cursor-default text-left" 203 @click="resourcesIsOpen = !resourcesIsOpen" 204 :aria-expanded="resourcesIsOpen" 205 > 206 <span 207 x-text="selectedResourceTitle" 208 class="block truncate" 209 ></span> 210 <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> 211 <i class="w-4 h-4" data-lucide="chevrons-up-down"></i> 212 </span> 213 </button> 214 <input type="hidden" name="resource_id" x-model="selectedResourceId"/> 215 <ul 216 x-show="resourcesIsOpen" 217 x-transition 218 id="resource-list" 219 class="absolute z-10 mt-1 max-h-80 w-full overflow-auto rounded-md shadow-lg bg-bg-light" 220 > 221 <div class="z-20 p-2 sticky top-0 bg-bg-light"> 222 <input 223 type="text" 224 x-ref="searchInput" 225 x-model.debounce.200ms="resourcesSearchQuery" 226 placeholder="Search resources..." 227 class="input w-full" 228 /> 229 </div> 230 <template x-for="resource in filteredResources" :key="resource.ID"> 231 <li 232 class="relative cursor-default select-none p-4 hover:bg-bg" 233 @click="selectResource(resource)" 234 > 235 <div class="flex items-center justify-between"> 236 <div class="flex flex-col"> 237 <div class="flex gap-2 items-center"> 238 <span class="font-semibold block truncate" x-text="resource.Title"></span> 239 <template x-if="resource.Status == 1"> 240 <span class="ml-2 font-normal text-text-muted">(deleted)</span> 241 </template> 242 </div> 243 <span class="text-sm text-text-muted" x-text="resource.Description"></span> 244 <span x-show="resource.Link" class="text-sm pill pill-primary w-fit mt-2" x-text="resource.Link"></span> 245 </div> 246 <i 247 x-show="selectedResourceId == resource.ID" 248 class="w-4 h-4 text-primary ml-3" 249 data-lucide="check" 250 ></i> 251 </div> 252 </li> 253 </template> 254 <template x-if="filteredResources.length === 0"> 255 <li class="p-4 text-center text-text-muted">No resources found.</li> 256 </template> 257 </ul> 258 </div> 259 <div class="flex items-center justify-between text-xs text-text-muted"> 260 <span>Don't see what you're looking for?</span> 261 <a href="/resource/new" class="font-medium hover:underline"> 262 Create custom resource 263 </a> 264 </div> 265 </div> 266 <div class="flex flex-col gap-2"> 267 <label for="language-code" class="font-medium text-sm">Language</label> 268 <select name="language_code" id="language-code" x-model="selectedLanguage" class="input"> 269 <option 270 value="0" 271 disabled="true" 272 :selected="selectedLanguage.length === 0" 273 > 274 Select a language... 275 </option> 276 for _, l := range params.SortedLanguages { 277 <option value={ l.Code }> 278 { l.Name } 279 if l.NativeName != nil { 280 ({ *l.NativeName }) 281 } 282 </option> 283 } 284 </select> 285 <div class="flex items-center justify-between text-xs text-text-muted"> 286 <span>Only showing languages from your profile</span> 287 <a href="/profile/edit" class="font-medium hover:underline"> 288 Add more languages 289 </a> 290 </div> 291 </div> 292 <div x-show="mode === 'manual'" x-transition class="flex flex-col sm:flex-row gap-4"> 293 <div class="flex flex-col gap-2"> 294 <p class="font-medium text-sm">Duration</p> 295 <div class="flex gap-2"> 296 <div class="flex flex-col gap-1 w-full"> 297 {{ 298 hoursStr := params.QueryParams.DurationHours 299 hours := "0" 300 if hoursStr != "" { 301 hours = hoursStr 302 } 303 }} 304 <div x-data={ fmt.Sprintf("{ hours: %s, maxHours: 60 }", hours) }> 305 <input 306 type="number" 307 id="duration-hours" 308 name="duration_hours" 309 x-model.number="hours" 310 class="input w-full" 311 :class="{ 'border-red-500': hours > maxHours || hours < 0 }" 312 /> 313 <p x-show="hours > maxHours || hours < 0" class="text-red-500 text-sm mt-1"> 314 Duration must be between 0 and 24 hours. 315 </p> 316 </div> 317 <label for="duration-minutes" class="text-xs text-text-muted">Hours</label> 318 </div> 319 <div class="flex flex-col gap-1 w-full"> 320 {{ 321 minutesStr := params.QueryParams.DurationMinutes 322 minutes := "0" 323 if minutesStr != "" { 324 minutes = minutesStr 325 } 326 }} 327 <div x-data={ fmt.Sprintf("{ minutes: %s, maxMinutes: 60 }", minutes) }> 328 <input 329 type="number" 330 id="duration-minutes" 331 name="duration_minutes" 332 x-model.number="minutes" 333 class="input w-full" 334 :class="{ 'border-red-500': minutes > maxMinutes || minutes < 0 }" 335 /> 336 <p x-show="minutes > maxMinutes || minutes < 0" class="text-red-500 text-sm mt-1"> 337 Duration must be between 0 and 60 minutes. 338 </p> 339 </div> 340 <label for="duration-minutes" class="text-xs text-text-muted">Minutes</label> 341 </div> 342 <div class="flex flex-col gap-1 w-full"> 343 {{ 344 secondsStr := params.QueryParams.DurationSeconds 345 seconds := "0" 346 if secondsStr != "" { 347 seconds = secondsStr 348 } 349 }} 350 <div x-data={ fmt.Sprintf("{ seconds: %s, maxSeconds: 60 }", seconds) }> 351 <input 352 type="number" 353 id="duration-seconds" 354 name="duration_seconds" 355 x-model.number="seconds" 356 class="input w-full" 357 :class="{ 'border-red-500': seconds > maxSeconds || seconds < 0 }" 358 /> 359 <p 360 x-show="seconds > maxSeconds || seconds < 0" 361 class="text-red-500 text-sm mt-1" 362 > 363 Duration must be between 0 and 60 seconds. 364 </p> 365 </div> 366 <label for="duration-seconds" class="text-xs text-text-muted">Seconds</label> 367 </div> 368 </div> 369 </div> 370 </div> 371 <div 372 x-data="{ 373 today: () => { 374 let date = new Date(); 375 return new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0,10); 376 }, 377 oneYearAgo: () => { 378 let date = new Date(new Date().setFullYear(new Date().getFullYear() - 1)); 379 return new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0,10); 380 }, 381 }" 382 x-show="mode === 'manual'" 383 x-transition 384 class="flex flex-col gap-2" 385 > 386 <label for="date" class="font-medium text-sm">Date</label> 387 <input 388 type="date" 389 name="date" 390 id="date" 391 class="input w-full" 392 x-model="today" 393 :min="oneYearAgo" 394 :max="today" 395 /> 396 </div> 397 </div> 398 <div class="flex flex-col gap-2"> 399 <label 400 for="description" 401 class="font-medium text-sm" 402 > 403 Description (optional) 404 </label> 405 <div x-data={ fmt.Sprintf("{ text: '%s' }", templ.EscapeString(params.QueryParams.Description)) }> 406 <textarea 407 x-model="text" 408 id="description" 409 name="description" 410 placeholder="Write a description" 411 class="input w-full" 412 maxLength="256" 413 rows="3" 414 ></textarea> 415 <div class="text-right text-sm text-text-muted mt-1"> 416 <span x-text="text.length"></span> / 256 417 </div> 418 </div> 419 </div> 420 <div 421 x-show="mode === 'manual'" 422 class="flex flex-col sm:flex-row gap-4 mt-4" 423 > 424 <button id="save-button" type="submit" class="btn btn-primary"> 425 <i class="w-4 h-4" data-lucide="save"></i> 426 Save 427 <i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i> 428 </button> 429 <button 430 id="cancel-button" 431 onclick="window.location.href = '/'" 432 type="button" 433 class="btn btn-secondary" 434 > 435 Cancel 436 </button> 437 </div> 438 </form> 439 </div> 440 <script> 441 function studySessionForm() { 442 return { 443 mode: 'manual', // 'manual' or 'timer' 444 timerState: 'stopped', // 'stopped', 'paused', 'running' 445 elapsedSeconds: 0, 446 timerInterval: null, 447 pausedTime: 0, 448 startTime: null, 449 450 activitiesSearchQuery: '', 451 activities: {{ params.Activities }} || [], 452 selectedActivityName: {{ activityName }}, 453 selectedActivityId: {{ params.QueryParams.ActivityId }}, 454 activitiesIsOpen: false, 455 456 resourcesSearchQuery: '', 457 resources: {{ params.Resources }} || [], 458 selectedResourceTitle: 'Select a resource', 459 selectedResourceId: '', 460 resourcesIsOpen: false, 461 462 selectedLanguage: {{ params.QueryParams.LanguageCode }} || {{ initialLangCode }} || '', 463 464 get filteredActivities() { 465 if (this.activitiesSearchQuery === '') { 466 return this.activities.filter(a => a.Status !== 1); 467 } 468 return this.activities.filter(activity => 469 activity.Status !== 1 && 470 activity.Name.toLowerCase().includes(this.activitiesSearchQuery.toLowerCase()) 471 ); 472 }, 473 474 selectActivity(activity) { 475 this.selectedActivityId = activity.ID; 476 this.selectedActivityName = activity.Name; 477 this.activitiesIsOpen = false; 478 this.activitiesSearchQuery = ''; 479 }, 480 481 get filteredResources() { 482 if (this.resourcesSearchQuery === '') { 483 return this.resources.filter(a => a.Status !== 1); 484 } 485 return this.resources.filter(resource => 486 resource.Status !== 1 && 487 resource.Title.toLowerCase().includes(this.resourcesSearchQuery.toLowerCase()) 488 ); 489 }, 490 491 selectResource(resource) { 492 if (this.selectedResourceId === resource.ID) { 493 this.selectedResourceId = null; 494 this.selectedResourceTitle = "Select a resource"; 495 this.resourcesIsOpen = true; 496 } else { 497 this.selectedResourceId = resource.ID; 498 this.selectedResourceTitle = resource.Title; 499 this.resourcesIsOpen = false; 500 this.resourcesSearchQuery = ''; 501 } 502 }, 503 504 get formattedTime() { 505 const hours = Math.floor(this.elapsedSeconds / 3600).toString().padStart(2, '0'); 506 const minutes = Math.floor((this.elapsedSeconds % 3600) / 60).toString().padStart(2, '0'); 507 const seconds = (this.elapsedSeconds % 60).toString().padStart(2, '0'); 508 return `${hours}:${minutes}:${seconds}`; 509 }, 510 511 canStart() { 512 return this.selectedActivityId !== '' && this.selectedLanguage !== ''; 513 }, 514 515 start() { 516 if (this.timerState === 'running') return; 517 this.timerState = 'running'; 518 this.startTime = Date.now(); 519 this.timerInterval = setInterval(() => { 520 const currentTime = Date.now(); 521 const currentIntervalElapsed = (currentTime - this.startTime) / 1000; 522 this.elapsedSeconds = Math.round(this.pausedTime + currentIntervalElapsed); 523 }, 1000); 524 }, 525 526 pause() { 527 this.timerState = 'paused'; 528 this.pausedTime = this.elapsedSeconds; 529 clearInterval(this.timerInterval); 530 }, 531 532 reset() { 533 this.startTime = null; 534 this.timerState = 'stopped'; 535 this.elapsedSeconds = 0; 536 this.pausedTime = 0; 537 clearInterval(this.timerInterval); 538 }, 539 540 stopAndLog() { 541 this.pause(); 542 const form = this.$root; 543 this.timerState = 'stopped'; 544 545 let durationSeconds = form.querySelector('input[name="duration_seconds"]'); 546 if (!durationSeconds) { 547 durationSeconds = document.createElement('input'); 548 durationSeconds.type = 'hidden'; 549 durationSeconds.name = 'duration_seconds'; 550 form.appendChild(durationSeconds); 551 } 552 553 let durationMinutes = form.querySelector('input[name="duration_minutes"]'); 554 if (!durationMinutes) { 555 durationMinutes = document.createElement('input'); 556 durationMinutes.type = 'hidden'; 557 durationMinutes.name = 'duration_minutes'; 558 form.appendChild(durationMinutes); 559 } 560 561 let durationHours = form.querySelector('input[name="duration_hours"]'); 562 if (!durationHours) { 563 durationHours = document.createElement('input'); 564 durationHours.type = 'hidden'; 565 durationHours.name = 'duration_hours'; 566 form.appendChild(durationHours); 567 } 568 569 durationHours.value = Math.floor(this.elapsedSeconds / 3600); 570 durationMinutes.value = Math.floor((this.elapsedSeconds % 3600) / 60); 571 durationSeconds.value = this.elapsedSeconds % 60; 572 573 let dateInput = form.querySelector('input[name="date"]'); 574 if (!dateInput) { 575 dateInput = document.createElement('input'); 576 dateInput.type = 'hidden'; 577 dateInput.name = 'date'; 578 form.appendChild(dateInput); 579 } 580 581 dateInput.value = new Date().toISOString().split('T')[0]; 582 583 htmx.trigger(form, 'submit'); 584 } 585 } 586 } 587 </script> 588 } 589}