feat(web): sleep timer upgrades — undo toast, chain mode, clock stop, wind-down dim (plan §17)
- Undo: a genuine sleep fire now toasts with a Resume action (toast.ts grows an optional action button; acting dismisses). - Chain: 'End of chapter, then 15 min' — leaving the chapter starts a 15-min countdown instead of fading (SleepState.followUpMinutes). - Clock: the popover gains a time input + Arm — armClockTime resolves HH:MM to its next occurrence and reuses the minutes machinery. - Dim: the ticker eases a pointer-events-none overlay toward 35% black across a countdown's final 30s; stop/finish resets it. (Fade-out and countdown labels already existed — §3 built them.)
This commit is contained in:
1 parent
4769e832c4
commit
f8de17723a
6 files changed
+218
-21
No files matched your search
@@ -44,6 +44,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
||||
>
|
||||
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
|
||||
black across a countdown's final 30s; pointer-events-none
|
||||
keeps the page usable, stop/finish resets it. -->
|
||||
<div class="fixed inset-0 pointer-events-none z-40"
|
||||
:style="'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'"
|
||||
></div>
|
||||
<div class="h-full flex flex-col items-center justify-center px-4 gap-6 select-none">
|
||||
<!-- Header: back to the detail page (reader-style exit) and the
|
||||
chapter launcher. Floating drawer, never in-flow (A2b). -->
|
||||
@@ -482,6 +488,23 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
x-text="opt.label"
|
||||
></button>
|
||||
</template>
|
||||
<!-- §17 clock mode: stop at a wall-clock time (its next
|
||||
occurrence). The input drives chooseSleepClock(). -->
|
||||
<div class="flex items-center gap-1.5 px-2 pt-1.5 mt-1 border-t" style="border-color: var(--border);">
|
||||
<input
|
||||
type="time"
|
||||
x-model="sleepClockTime"
|
||||
class="text-xs px-1.5 py-1 rounded-md flex-1 min-w-0"
|
||||
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
|
||||
aria-label="Stop at time"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="chooseSleepClock(); sleepOpen = false"
|
||||
class="text-xs px-2.5 py-1 rounded-md font-semibold"
|
||||
:style="sleepArmedChoiceKey === 'clock' ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'background-color: var(--surface-hover); color: var(--text-secondary);'"
|
||||
>Arm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative" @click.outside="volumeOpen = false">
|
||||
|
||||
@@ -140,7 +140,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 162, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 168, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -165,7 +165,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("Number(defaultSpeed) === %g", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 163, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -178,7 +178,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 164, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -201,7 +201,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 178, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 184, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -214,7 +214,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 179, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -237,13 +237,13 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 188, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 194, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button typeLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button typeLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SLEEP_CHOICES,
|
||||
SLEEP_OFF,
|
||||
armClockTime,
|
||||
armEndOfChapter,
|
||||
armMinutes,
|
||||
fadeVolume,
|
||||
@@ -77,13 +78,69 @@ describe("formatRemaining / sleepLabel", () => {
|
||||
it("labels every mode", () => {
|
||||
expect(sleepLabel(SLEEP_OFF, 0)).toBe("Off");
|
||||
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
|
||||
expect(sleepLabel({ mode: "fading", endsAtMs: null, chapterIndex: null }, 0)).toBe("Fading");
|
||||
expect(
|
||||
sleepLabel({ mode: "fading", endsAtMs: null, chapterIndex: null, followUpMinutes: null }, 0),
|
||||
).toBe("Fading");
|
||||
expect(sleepLabel(armMinutes(1000, 5), 1000 + 65_000)).toBe("3:55");
|
||||
});
|
||||
});
|
||||
|
||||
describe("armClockTime (§17 stop-at-clock)", () => {
|
||||
it("arms a minutes-state deadline at the next occurrence of HH:MM", () => {
|
||||
// 10:00 local → 23:00 same day.
|
||||
const tenAm = new Date();
|
||||
tenAm.setHours(10, 0, 0, 0);
|
||||
const state = armClockTime(tenAm.getTime(), "23:00");
|
||||
expect(state?.mode).toBe("minutes");
|
||||
const expected = new Date(tenAm);
|
||||
expected.setHours(23, 0, 0, 0);
|
||||
expect(state?.endsAtMs).toBe(expected.getTime());
|
||||
// Label is the ordinary countdown machinery (12h59m → 779:00).
|
||||
expect(sleepLabel(state!, tenAm.getTime() + 60_000)).toBe("779:00");
|
||||
});
|
||||
|
||||
it("wraps to tomorrow when the time already passed", () => {
|
||||
const noon = new Date();
|
||||
noon.setHours(12, 0, 0, 0);
|
||||
const state = armClockTime(noon.getTime(), "09:15");
|
||||
const expected = new Date(noon);
|
||||
expected.setDate(expected.getDate() + 1);
|
||||
expected.setHours(9, 15, 0, 0);
|
||||
expect(state?.endsAtMs).toBe(expected.getTime());
|
||||
});
|
||||
|
||||
it("rejects unparseable and out-of-range input", () => {
|
||||
const now = Date.now();
|
||||
expect(armClockTime(now, "nope")).toBeNull();
|
||||
expect(armClockTime(now, "24:00")).toBeNull();
|
||||
expect(armClockTime(now, "10:61")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("§17 chain: end-of-chapter with a follow-up countdown", () => {
|
||||
it("arms the follow-up on the state; the chapter fire contract is unchanged", () => {
|
||||
const state = armEndOfChapter(2, 15);
|
||||
expect(state.followUpMinutes).toBe(15);
|
||||
// The sync contract (any chapter change fires) is the same — the
|
||||
// component converts to armMinutes instead of fading.
|
||||
expect(sleepChapterFired(state, 3)).toBe(true);
|
||||
expect(sleepChapterFired(state, 1)).toBe(true);
|
||||
expect(sleepChapterFired(state, 2)).toBe(false);
|
||||
});
|
||||
|
||||
it("labels the chapter phase with the follow-up length", () => {
|
||||
expect(sleepLabel(armEndOfChapter(2, 15), 0)).toBe("Ch + 15m");
|
||||
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
|
||||
});
|
||||
|
||||
it("offers the chain row in the dropdown vocabulary", () => {
|
||||
const chain = SLEEP_CHOICES.find((c) => c.key === "eoch15");
|
||||
expect(chain).toMatchObject({ mode: "end-of-chapter", minutes: 15, chain: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("SLEEP_CHOICES", () => {
|
||||
it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter", () => {
|
||||
it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter, chain", () => {
|
||||
expect(SLEEP_CHOICES.map((c) => c.key)).toEqual([
|
||||
"off",
|
||||
"5",
|
||||
@@ -93,6 +150,7 @@ describe("SLEEP_CHOICES", () => {
|
||||
"45",
|
||||
"60",
|
||||
"eoch",
|
||||
"eoch15",
|
||||
]);
|
||||
expect(SLEEP_CHOICES.filter((c) => c.mode === "minutes").map((c) => c.minutes)).toEqual([
|
||||
5, 10, 15, 30, 45, 60,
|
||||
|
||||
+48
-5
@@ -12,16 +12,49 @@ export interface SleepState {
|
||||
endsAtMs: number | null;
|
||||
// Chapter index the end-of-chapter timer was armed at; null otherwise.
|
||||
chapterIndex: number | null;
|
||||
// §17 chain: when set on an end-of-chapter arm, leaving the chapter
|
||||
// does NOT fade — it starts a minutes countdown of this length
|
||||
// ("end of chapter, then 15 min").
|
||||
followUpMinutes: number | null;
|
||||
}
|
||||
|
||||
export const SLEEP_OFF: SleepState = { mode: "off", endsAtMs: null, chapterIndex: null };
|
||||
export const SLEEP_OFF: SleepState = {
|
||||
mode: "off",
|
||||
endsAtMs: null,
|
||||
chapterIndex: null,
|
||||
followUpMinutes: null,
|
||||
};
|
||||
|
||||
export function armMinutes(nowMs: number, minutes: number): SleepState {
|
||||
return { mode: "minutes", endsAtMs: nowMs + minutes * 60_000, chapterIndex: null };
|
||||
return {
|
||||
mode: "minutes",
|
||||
endsAtMs: nowMs + minutes * 60_000,
|
||||
chapterIndex: null,
|
||||
followUpMinutes: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function armEndOfChapter(chapterIndex: number): SleepState {
|
||||
return { mode: "end-of-chapter", endsAtMs: null, chapterIndex };
|
||||
export function armEndOfChapter(
|
||||
chapterIndex: number,
|
||||
followUpMinutes: number | null = null,
|
||||
): SleepState {
|
||||
return { mode: "end-of-chapter", endsAtMs: null, chapterIndex, followUpMinutes };
|
||||
}
|
||||
|
||||
// armClockTime (§17): "stop at 23:00" — resolves HH:MM to its NEXT
|
||||
// occurrence (today if still ahead, otherwise tomorrow) and arms the
|
||||
// ordinary minutes machinery against that epoch deadline. Returns null
|
||||
// for unparseable strings; the caller keeps the old state then.
|
||||
export function armClockTime(nowMs: number, hhmm: string): SleepState | null {
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
if (h > 23 || min > 59) return null;
|
||||
const target = new Date(nowMs);
|
||||
target.setHours(h, min, 0, 0);
|
||||
if (target.getTime() <= nowMs) target.setDate(target.getDate() + 1);
|
||||
return { mode: "minutes", endsAtMs: target.getTime(), chapterIndex: null, followUpMinutes: null };
|
||||
}
|
||||
|
||||
// sleepDue is true when a minutes-mode deadline has passed.
|
||||
@@ -66,7 +99,7 @@ export function sleepLabel(state: SleepState, nowMs: number): string {
|
||||
case "off":
|
||||
return "Off";
|
||||
case "end-of-chapter":
|
||||
return "End of chapter";
|
||||
return state.followUpMinutes !== null ? `Ch + ${state.followUpMinutes}m` : "End of chapter";
|
||||
case "fading":
|
||||
return "Fading";
|
||||
case "minutes": {
|
||||
@@ -84,6 +117,9 @@ export interface SleepChoice {
|
||||
label: string;
|
||||
mode: "off" | "minutes" | "end-of-chapter";
|
||||
minutes?: number;
|
||||
// chain: this end-of-chapter row arms a follow-up minutes countdown
|
||||
// ("end of chapter, then N min") instead of fading straight away.
|
||||
chain?: boolean;
|
||||
}
|
||||
|
||||
export const SLEEP_CHOICES: readonly SleepChoice[] = [
|
||||
@@ -95,4 +131,11 @@ export const SLEEP_CHOICES: readonly SleepChoice[] = [
|
||||
{ key: "45", label: "45 minutes", mode: "minutes", minutes: 45 },
|
||||
{ key: "60", label: "60 minutes", mode: "minutes", minutes: 60 },
|
||||
{ key: "eoch", label: "End of chapter", mode: "end-of-chapter" },
|
||||
{
|
||||
key: "eoch15",
|
||||
label: "End of chapter, then 15 min",
|
||||
mode: "end-of-chapter",
|
||||
minutes: 15,
|
||||
chain: true,
|
||||
},
|
||||
];
|
||||
+55
-3
@@ -25,12 +25,14 @@ import {
|
||||
import {
|
||||
SLEEP_CHOICES,
|
||||
SLEEP_OFF,
|
||||
armClockTime,
|
||||
armEndOfChapter,
|
||||
armMinutes,
|
||||
fadeVolume,
|
||||
sleepChapterFired,
|
||||
sleepDue,
|
||||
sleepLabel,
|
||||
sleepRemainingMs,
|
||||
type SleepChoice,
|
||||
type SleepState,
|
||||
} from "./audio-sleep";
|
||||
@@ -284,6 +286,11 @@ Alpine.data("audioReader", () => ({
|
||||
sleepArmedChoiceKey: "off",
|
||||
sleepTicker: null as ReturnType<typeof setInterval> | null,
|
||||
sleepFadeTimer: null as ReturnType<typeof setInterval> | null,
|
||||
// §17 clock mode: the popover's time input (armed via chooseSleepClock).
|
||||
sleepClockTime: "23:00",
|
||||
// §17 wind-down dim: 0..0.35 black overlay opacity across the final
|
||||
// 30s of a countdown. Reset by stop/finish.
|
||||
sleepDim: 0,
|
||||
|
||||
// Settings popover (plan §4): header gear → panel. Autoplay-on-open
|
||||
// is the first option; the panel is structured for more.
|
||||
@@ -579,7 +586,7 @@ Alpine.data("audioReader", () => ({
|
||||
this.currentChapterIdx = i;
|
||||
this.updateMediaSessionMetadata();
|
||||
// Natural chapter crossing: fire an end-of-chapter sleep.
|
||||
if (sleepChapterFired(this.sleep, i)) this.beginSleepFade();
|
||||
if (sleepChapterFired(this.sleep, i)) this.onChapterCrossing(i);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -637,7 +644,7 @@ Alpine.data("audioReader", () => ({
|
||||
this.currentChapterIdx = index;
|
||||
// Manual jump: leaving the armed chapter fires an end-of-chapter
|
||||
// sleep the same as a natural crossing.
|
||||
if (sleepChapterFired(this.sleep, index)) this.beginSleepFade();
|
||||
this.onChapterCrossing(index);
|
||||
},
|
||||
|
||||
setSpeed(rate: number) {
|
||||
@@ -1139,7 +1146,12 @@ Alpine.data("audioReader", () => ({
|
||||
}
|
||||
if (choice.mode === "end-of-chapter") {
|
||||
this.stopSleepTicker();
|
||||
this.sleep = armEndOfChapter(this.currentChapterIdx);
|
||||
// §17 chain: "end of chapter, then N min" — leaving the chapter
|
||||
// starts the countdown instead of fading straight away.
|
||||
this.sleep = armEndOfChapter(
|
||||
this.currentChapterIdx,
|
||||
choice.chain ? (choice.minutes ?? 15) : null,
|
||||
);
|
||||
} else {
|
||||
this.stopSleepTicker();
|
||||
this.sleep = armMinutes(Date.now(), choice.minutes ?? 30);
|
||||
@@ -1148,6 +1160,32 @@ Alpine.data("audioReader", () => ({
|
||||
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
|
||||
},
|
||||
|
||||
// §17 clock mode: arm against the next occurrence of the popover's
|
||||
// HH:MM input. Unparseable input keeps the current state.
|
||||
chooseSleepClock() {
|
||||
const armed = armClockTime(Date.now(), this.sleepClockTime);
|
||||
if (!armed) return;
|
||||
this.stopSleepTicker();
|
||||
this.sleep = armed;
|
||||
this.sleepArmedChoiceKey = "clock";
|
||||
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
|
||||
this.startSleepTicker();
|
||||
},
|
||||
|
||||
// A chapter crossing fires an end-of-chapter sleep: straight to the
|
||||
// fade — or (§17 chain arm) convert to the follow-up countdown.
|
||||
onChapterCrossing(index: number) {
|
||||
if (!sleepChapterFired(this.sleep, index)) return;
|
||||
const follow = this.sleep.followUpMinutes;
|
||||
if (follow !== null) {
|
||||
this.sleep = armMinutes(Date.now(), follow);
|
||||
this.startSleepTicker();
|
||||
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
|
||||
} else {
|
||||
this.beginSleepFade();
|
||||
}
|
||||
},
|
||||
|
||||
startSleepTicker() {
|
||||
this.stopSleepTicker();
|
||||
this.sleepTicker = setInterval(() => {
|
||||
@@ -1159,6 +1197,10 @@ Alpine.data("audioReader", () => ({
|
||||
return;
|
||||
}
|
||||
this.sleepLabelStr = sleepLabel(this.sleep, now);
|
||||
// §17 wind-down dim: ease toward 35% black across the final 30s.
|
||||
const remaining = sleepRemainingMs(this.sleep, now);
|
||||
this.sleepDim =
|
||||
remaining !== null && remaining < 30_000 ? ((30_000 - remaining) / 30_000) * 0.35 : 0;
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
@@ -1208,6 +1250,15 @@ Alpine.data("audioReader", () => ({
|
||||
this.sleep = SLEEP_OFF;
|
||||
this.sleepArmedChoiceKey = "off";
|
||||
this.sleepLabelStr = "Off";
|
||||
this.sleepDim = 0;
|
||||
// §17: a genuine fire can catch you still awake — one-click undo.
|
||||
showToast("Sleep timer: playback paused", "info", 8000, {
|
||||
label: "Resume",
|
||||
onClick: () => {
|
||||
const a = this.audio();
|
||||
if (a) a.play().catch(() => {});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Manual off / play-abort: undo any partial fade.
|
||||
@@ -1222,6 +1273,7 @@ Alpine.data("audioReader", () => ({
|
||||
this.sleep = SLEEP_OFF;
|
||||
this.sleepArmedChoiceKey = "off";
|
||||
this.sleepLabelStr = "Off";
|
||||
this.sleepDim = 0;
|
||||
},
|
||||
|
||||
// ---- settings popover (plan §4) ----
|
||||
|
||||
+24
-3
@@ -48,15 +48,23 @@ const getToastConfig = (type: ToastType) => {
|
||||
};
|
||||
|
||||
// Create a toast element
|
||||
const createToastElement = (message: string, type: ToastType): HTMLElement => {
|
||||
const createToastElement = (
|
||||
message: string,
|
||||
type: ToastType,
|
||||
action?: { label: string; onClick: () => void },
|
||||
): HTMLElement => {
|
||||
const toast = document.createElement("div");
|
||||
const config = getToastConfig(type);
|
||||
|
||||
toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`;
|
||||
|
||||
const actionHtml = action
|
||||
? `<button class="toast-action bg-transparent border border-white/40 text-white cursor-pointer text-xs font-semibold px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 hover:bg-white/10 transition-colors">${toastEscapeHtml(action.label)}</button>`
|
||||
: "";
|
||||
toast.innerHTML = `
|
||||
<span class="text-xl flex-shrink-0">${config.icon}</span>
|
||||
<span class="flex-1 break-words">${toastEscapeHtml(message)}</span>
|
||||
${actionHtml}
|
||||
<button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity">
|
||||
×
|
||||
</button>
|
||||
@@ -68,6 +76,16 @@ const createToastElement = (message: string, type: ToastType): HTMLElement => {
|
||||
closeBtn.onclick = () => removeToast(toast);
|
||||
}
|
||||
|
||||
// Action button: run the handler and dismiss (an action consumes the
|
||||
// toast — leaving it up after acting invites double-fires).
|
||||
const actionBtn = toast.querySelector(".toast-action") as HTMLElement | null;
|
||||
if (actionBtn && action) {
|
||||
actionBtn.onclick = () => {
|
||||
removeToast(toast);
|
||||
action.onClick();
|
||||
};
|
||||
}
|
||||
|
||||
return toast;
|
||||
};
|
||||
|
||||
@@ -90,14 +108,17 @@ const removeToast = (toast: HTMLElement): void => {
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Show toast notification
|
||||
// Show toast notification. `action` (optional): a labeled button for
|
||||
// one-click follow-ups (e.g. the sleep timer's "Resume"); acting
|
||||
// dismisses the toast.
|
||||
const showToast = (
|
||||
message: string,
|
||||
type: ToastType,
|
||||
duration: number = TOAST_DEFAULT_DURATION,
|
||||
action?: { label: string; onClick: () => void },
|
||||
): void => {
|
||||
const container = createToastContainer();
|
||||
const toast = createToastElement(message, type);
|
||||
const toast = createToastElement(message, type, action);
|
||||
container.appendChild(toast);
|
||||
animateToastIn(toast);
|
||||
|
||||
|
||||
Reference in new issue
Block a user