Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/CustomAudioPlayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export default function CustomAudioPlayer({
title = "Audio Lesson",
artist = "Zeeguu",
autoPlay = false,
children,
}) {
const getStoredSpeed = () => {
if (!language) return 1.0;
Expand Down Expand Up @@ -804,6 +805,8 @@ export default function CustomAudioPlayer({
{formatTime(duration, true)}
</div>
</div>

{children}
</div>
);
}
9 changes: 7 additions & 2 deletions src/components/DailyAudioNotificationDot.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ const Dot = styled.div`
`;

export default function DailyAudioNotificationDot({ status, isActive, sidebar }) {
// Only show for generating (spinner) or ready (new lesson waiting)
if (!status || status === AUDIO_STATUS.COMPLETED || status === AUDIO_STATUS.IN_PROGRESS) return null;
// Show the dot only when there's something actionable: a fresh unlistened
// lesson waiting (ready) or one being generated (spinner). Everything else —
// no lesson yet ("available", the backend default before generation),
// already listened (completed), or mid-listen (in_progress) — shows nothing.
// Listing the two positive cases (rather than excluding a few) keeps stray
// backend statuses like "available" from leaking through the default branch.
if (status !== AUDIO_STATUS.GENERATING && status !== AUDIO_STATUS.READY) return null;

return <Dot $status={status} $isActive={isActive} $sidebar={sidebar} />;
}
4 changes: 3 additions & 1 deletion src/components/DialogWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import * as RadixDialog from "@radix-ui/react-dialog";
import styled from "styled-components";

const Overlay = styled(RadixDialog.Overlay)`
background: hsla(0, 0%, 0%, 0.33);
background: hsla(0, 0%, 0%, 0.6);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
position: fixed;
top: 0;
right: 0;
Expand Down
138 changes: 138 additions & 0 deletions src/dailyAudio/DailyLessonSettingsDialog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React, { useEffect, useState } from "react";
import { Dialog } from "../components/DialogWrapper";
import SuggestionSelector, { pillToBackend, backendToPill } from "./SuggestionSelector";
import { SubtleTextButton } from "./LessonView.sc";
import { BannerButton } from "./SharedLessonView.sc";
import { zeeguuOrange } from "../components/colors";

/**
* Setup / "Change daily topic" dialog. Hosts the lesson-type selector and
* persists the per-language daily-lesson preference. The dialog never
* generates audio itself — it hands the chosen (type, suggestion) back to the
* parent via onSubmit, which owns the generation state machine and progress UI.
*
* onSubmit(backendType, verbatimSuggestion, regenerate):
* regenerate=true → apply now (parent regenerates today's lesson)
* regenerate=false → save for tomorrow only (keeps today's existing lesson)
*/
export default function DailyLessonSettingsDialog({
api,
initialType,
initialSuggestion,
todaysLessonExists,
onSubmit,
onDismiss,
}) {
const [pillType, setPillType] = useState(initialType ? backendToPill(initialType) : "topic");
// Per-type subject so switching pills — or to Vocabulary and back — never
// clobbers what was typed for Topic vs Situation.
const [subjectByType, setSubjectByType] = useState(() => ({
topic: initialType === "topic" ? initialSuggestion || "" : "",
situation: initialType === "situation" ? initialSuggestion || "" : "",
}));
const [autoDisabled, setAutoDisabled] = useState(false);
const [confirmRegen, setConfirmRegen] = useState(false);

// Changing the type/subject after the regenerate-or-tomorrow prompt has shown
// would leave it describing a stale choice (and could strand the confirm
// buttons if the new subject is empty) — so drop back to "Save settings".
useEffect(() => {
setConfirmRegen(false);
}, [pillType, subjectByType]);

// Vocabulary needs enough study words; disable the pill when there aren't.
useEffect(() => {
api.checkDailyLessonFeasibility(
(data) => {
setAutoDisabled(!data.feasible);
if (!data.feasible) setPillType((t) => (t === "auto" ? "topic" : t));
},
() => setAutoDisabled(false),
);
}, [api]);

const needsSubject = pillType === "topic" || pillType === "situation";
const currentSubject = needsSubject ? subjectByType[pillType] : "";
const setCurrentSubject = (val) => setSubjectByType((prev) => ({ ...prev, [pillType]: val }));
const canSubmit = !needsSubject || currentSubject.trim().length > 0;

const submit = (regenerate) => {
if (!canSubmit) return;
// Store the subject exactly as typed; Vocabulary carries no subject.
onSubmit(pillToBackend(pillType), needsSubject ? currentSubject : "", regenerate);
};

// Did the user actually pick something different from their current setup?
// Save stays disabled until they do.
const initialSubject =
initialType === "topic" || initialType === "situation" ? (initialSuggestion || "").trim() : "";
const changed =
pillToBackend(pillType) !== (initialType || null) ||
(needsSubject ? currentSubject.trim() : "") !== initialSubject;
const canSave = canSubmit && changed;

// Saving a changed setting while today's lesson already exists asks whether to
// apply it now or from tomorrow. Otherwise just save — generating right away
// when there's no lesson for today yet (first run / cron miss).
const handleSaveClick = () => {
if (!canSave) return;
if (todaysLessonExists) setConfirmRegen(true);
else submit(true);
};

const buttonStyle = { width: "100%", padding: "12px", fontSize: "1rem", fontWeight: 600 };

const saveButton = (
<BannerButton
onClick={handleSaveClick}
disabled={!canSave}
style={{ ...buttonStyle, opacity: canSave ? 1 : 0.5, cursor: canSave ? "pointer" : "not-allowed" }}
>
Save settings
</BannerButton>
);

const confirmStep = (
<>
<p style={{ margin: 0, fontSize: "0.9rem", color: "var(--text-secondary)", textAlign: "center" }}>
Apply this to today's lesson, or start from tomorrow?
</p>
<BannerButton onClick={() => submit(true)} style={buttonStyle}>
Regenerate today's lesson
</BannerButton>
<SubtleTextButton onClick={() => submit(false)}>Start from tomorrow</SubtleTextButton>
</>
);

return (
<Dialog
onDismiss={onDismiss}
aria-label="Daily lesson settings"
style={{
background: "var(--card-bg)",
color: "var(--text-primary)",
borderRadius: "16px",
width: "min(86vw, 330px)",
padding: "1.5rem",
border: "1px solid rgba(255, 255, 255, 0.14)",
boxShadow: "0 16px 48px rgba(0, 0, 0, 0.6)",
}}
>
<h2 style={{ margin: "0 0 16px", fontSize: "1.25rem", color: zeeguuOrange }}>
Daily lesson settings
</h2>

<SuggestionSelector
suggestionType={pillType}
setSuggestionType={setPillType}
suggestion={currentSubject}
setSuggestion={setCurrentSubject}
autoDisabled={autoDisabled}
/>

<div style={{ marginTop: "20px", display: "flex", flexDirection: "column", alignItems: "center", gap: "10px" }}>
{confirmRegen ? confirmStep : saveButton}
</div>
</Dialog>
);
}
34 changes: 22 additions & 12 deletions src/dailyAudio/LessonPlaybackView.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,41 @@ import { AUDIO_STATUS } from "./AudioLessonConstants";
import { LessonWrapper, LessonTitle, LessonMetadata, CompletionCheck, SubtleTextButton, LessonActions } from "./LessonView.sc";
import { wordsAsTile } from "./audioUtils";
import { languageNames } from "../utils/languageDetection";
import { shareLessonLink } from "./shareLessonLink";
import ShareLessonButton from "./ShareLessonButton";

export default function LessonPlaybackView({
lessonData,
setLessonData,
words,
error,
api,
userDetails,
setUserDetails,
listeningSession,
currentPlaybackTime,
setCurrentPlaybackTime,
header,
footer,
}) {
const [openFeedback, setOpenFeedback] = useState(false);
const words = lessonData.words || [];

return (
<LessonWrapper>
// Callers (e.g. the daily episode card) can replace the default title block
// with their own header; otherwise we show the plain title + topic line.
const defaultHeader = (
<>
<LessonTitle>
{lessonData.title}
{lessonData.is_completed && <> <CompletionCheck>✓</CompletionCheck></>}
</LessonTitle>
{lessonData.canonical_suggestion && (
<LessonMetadata>{lessonData.lesson_type === "situation" ? "Situation" : "Topic"}: <b>{lessonData.canonical_suggestion}</b></LessonMetadata>
)}
</>
);

return (
<LessonWrapper>
{header || defaultHeader}

{error && <div style={{ color: "red", marginBottom: "20px" }}>{error}</div>}

Expand Down Expand Up @@ -69,6 +79,9 @@ export default function LessonPlaybackView({
setLessonData((prev) => ({
...prev,
is_completed: true,
// One ✓ per listen — bump locally so replays show immediately,
// mirroring the backend's per-completion increment.
listened_count: (prev.listened_count || 0) + 1,
last_completed_at: new Date().toISOString(),
}));
setUserDetails((prev) => ({ ...prev, daily_audio_status: AUDIO_STATUS.COMPLETED }));
Expand All @@ -82,7 +95,9 @@ export default function LessonPlaybackView({
maxWidth: "600px",
margin: "0 auto 20px auto",
}}
/>
>
<ShareLessonButton api={api} lessonId={lessonData.lesson_id} title={lessonData.title} />
</CustomAudioPlayer>

{lessonData.is_completed && (
<div
Expand Down Expand Up @@ -118,14 +133,9 @@ export default function LessonPlaybackView({
</div>
)}

{footer}

<LessonActions>
{lessonData.lesson_id && (
<SubtleTextButton
onClick={() => shareLessonLink(api, lessonData.lesson_id, lessonData.title)}
>
Share
</SubtleTextButton>
)}
<SubtleTextButton onClick={() => setOpenFeedback(true)}>
Feedback
</SubtleTextButton>
Expand Down
Loading