Yōten: A social tracker for your language learning journey built on the atproto.
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}