Skip to content

Commit

Permalink
make text-to-speech opt-in for each deck (#78)
Browse files Browse the repository at this point in the history
  • Loading branch information
jxjj authored Dec 6, 2024
1 parent 35c9c36 commit 7dd54e3
Show file tree
Hide file tree
Showing 22 changed files with 213 additions and 47 deletions.
4 changes: 4 additions & 0 deletions app/Http/Controllers/DeckController.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ public function store(Request $request)
$validated = $request->validate([
'name' => 'required|string',
'description' => 'string|nullable',
'is_tts_enabled' => 'boolean|nullable',
]);

$deck = Deck::create([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'is_tts_enabled' => $validated['is_tts_enabled'] ?? false,
]);

$deck->memberships()->create([
Expand Down Expand Up @@ -96,11 +98,13 @@ public function update(Request $request, Deck $deck)
$validated = $request->validate([
'name' => 'string',
'description' => 'string|nullable',
'is_tts_enabled' => 'boolean|nullable',
]);

$deck->update([
'name' => $validated['name'] ?? $deck->name,
'description' => $validated['description'] ?? $deck->description,
'is_tts_enabled' => $validated['is_tts_enabled'] ?? $deck->is_tts_enabled,
]);

return DeckResource::make($deck->fresh());
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/DeckResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public function toArray(Request $request): array
'name' => $this->name,
'description' => $this->description,
'is_public' => $this->is_public,
'is_tts_enabled' => $this->is_tts_enabled,

'cards_count' => $this->when(isset($this->cards_count), $this->cards_count),

Expand Down
2 changes: 2 additions & 0 deletions app/Models/Deck.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ class Deck extends Model implements AuditableContract
'avg_score' => 'float',
'is_public' => 'boolean',
'current_user_xp' => 'integer',
'is_tts_enabled' => 'boolean',
];

protected $fillable = [
'name',
'description',
'is_public',
'is_tts_enabled',
];

public function users()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('decks', function (Blueprint $table) {
$table
->boolean('is_tts_enabled')
->default(false)
->after('is_public');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('decks', function (Blueprint $table) {
$table->dropColumn('is_tts_enabled');
});
}
};
14 changes: 12 additions & 2 deletions resources/client/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,18 @@ export async function getDeckById(deckId: number) {
}

export async function createDeck(
deck: { name: string; description: string },
deck: { name: string; description: string; isTTSEnabled: boolean },
customConfig: T.CustomAxiosRequestConfig = {},
) {
await csrf();
const res = await axios.post<{ data: T.Deck }>(`/decks`, deck, customConfig);
const res = await axios.post<{ data: T.Deck }>(
`/decks`,
{
...deck,
is_tts_enabled: deck.isTTSEnabled,
},
customConfig,
);
return res.data.data;
}

Expand All @@ -88,14 +95,17 @@ export async function updateDeck({
id,
name,
description,
isTTSEnabled,
}: {
id: number;
name: string;
description: string;
isTTSEnabled: boolean;
}) {
const res = await axios.put<{ data: T.Deck }>(`/decks/${id}`, {
name,
description,
is_tts_enabled: isTTSEnabled,
});
return res.data.data;
}
Expand Down
17 changes: 11 additions & 6 deletions resources/client/components/BlockEditor/TextBlockInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
:selectedLanguage="selectedLanguage"
class="top-1 right-1 absolute z-10"
isIdleClass="bg-brand-oatmeal-50"
v-if="featureFlags?.text_to_speech && charCount < MAX_TTS_CHARS"
v-if="isDeckTTSEnabled && charCount < MAX_TTS_CHARS"
/>

<QuillyEditor
Expand All @@ -16,7 +16,10 @@
class="bg-brand-maroon-800/5 rounded-sm focus-within:ring-2 focus-within:ring-offset-1 focus-within:ring-blue-600"
/>

<div class="flex gap-2 items-center justify-end mt-2">
<div
class="flex gap-2 items-center justify-end mt-2"
v-if="isDeckTTSEnabled"
>
<Select
v-if="isSettingCustomLanguage"
:id="`block-${nonce}__language-select`"
Expand Down Expand Up @@ -54,7 +57,7 @@
<script setup lang="ts">
import { QuillyEditor } from "vue-quilly";
import Quill from "quill/quill"; // Core build
import { ref, onMounted, computed, watch } from "vue";
import { ref, onMounted, computed, watch, inject, toRef } from "vue";
import {
Select,
SelectTrigger,
Expand All @@ -70,8 +73,7 @@ import { TextContentBlock } from "@/types";
import { uuid } from "@/lib/utils";
import { IconGlobe } from "../icons";
import Toggle from "@/components/Toggle.vue";
import { MAX_TTS_CHARS } from "@/constants";
import { useAllFeatureFlagsQuery } from "@/queries/featureFlags";
import { IS_DECK_TTS_ENABLED_INJECTION_KEY, MAX_TTS_CHARS } from "@/constants";
import SimpleTTSPlayer from "../SimpleTTSPlayer.vue";
Expand Down Expand Up @@ -136,7 +138,10 @@ const options = computed(() => ({
readOnly: false,
}));
const { data: featureFlags } = useAllFeatureFlagsQuery();
const isDeckTTSEnabled = inject(
IS_DECK_TTS_ENABLED_INJECTION_KEY,
toRef(false),
);
onMounted(() => {
if (!editor.value) {
Expand Down
13 changes: 7 additions & 6 deletions resources/client/components/CardSideView/TextBlockView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,21 @@
/>
<div
class="flex items-center justify-center"
v-if="featureFlags?.text_to_speech"
v-if="isDeckTTSEnabled && charCount < MAX_TTS_CHARS"
>
<SimpleTTSPlayer
:text="block.content"
:selectedLanguage="block.meta?.lang ?? null"
v-if="charCount < MAX_TTS_CHARS"
/>
</div>
</div>
</template>
<script setup lang="ts">
import * as T from "@/types";
import { computed } from "vue";
import { computed, inject, toRef } from "vue";
import { cn } from "@/lib/utils";
import SimpleTTSPlayer from "@/components/SimpleTTSPlayer.vue";
import { MAX_TTS_CHARS } from "@/constants";
import { useAllFeatureFlagsQuery } from "@/queries/featureFlags";
import { MAX_TTS_CHARS, IS_DECK_TTS_ENABLED_INJECTION_KEY } from "@/constants";
const props = defineProps<{
block: T.TextContentBlock;
Expand All @@ -44,7 +42,10 @@ const props = defineProps<{
const wordCount = computed(() => props.block.content.split(/\s+/).length);
const charCount = computed(() => props.block.content.length);
const { data: featureFlags } = useAllFeatureFlagsQuery();
const isDeckTTSEnabled = inject(
IS_DECK_TTS_ENABLED_INJECTION_KEY,
toRef(false),
);
</script>
<style type="post-css">
/**
Expand Down
3 changes: 1 addition & 2 deletions resources/client/components/HintTooltip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<TooltipProvider>
<Tooltip :delayDuration="0">
<TooltipTrigger
class="bg-brand-maroon-800/70 hover:bg-brand-maroon-800 rounded-full size-4 text-brand-oatmeal-100 text-xs inline-flex items-center justify-center"
class="bg-brand-maroon-800/70 hover:bg-brand-maroon-800 rounded-full size-4 text-brand-oatmeal-100 text-xs inline-flex items-center justify-center font-black"
>
?
</TooltipTrigger>
Expand All @@ -13,7 +13,6 @@
</TooltipProvider>
</template>
<script setup lang="ts">
import IconQuestionMark from "./icons/IconQuestionMark.vue";
import {
TooltipProvider,
Tooltip,
Expand Down
4 changes: 3 additions & 1 deletion resources/client/components/SimpleTTSPlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
<Toggle
v-model="isPlaying"
label="Listen"
:disabled="isEmpty"
:class="{
[isIdleClass]: !isPlaying && props.isIdleClass,
'!opacity-25 cursor-not-allowed': isEmpty,
}"
>
<IconSound class="size-5" />
Expand Down Expand Up @@ -34,7 +36,7 @@ const props = defineProps<{
const textRef = computed(() => props.text);
const selectedLanguageRef = computed(() => props.selectedLanguage);
const { isPlaying } = useTextToSpeech(textRef, selectedLanguageRef);
const { isPlaying, isEmpty } = useTextToSpeech(textRef, selectedLanguageRef);
const languages = getTTSLanguageOptions();
const languageName = computed(() => {
Expand Down
1 change: 0 additions & 1 deletion resources/client/components/Toggle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import SimpleTooltip from "@/components/SimpleTooltip.vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
Expand Down
19 changes: 19 additions & 0 deletions resources/client/components/icons/IconSettings.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M10.96 21q-.349 0-.605-.229q-.257-.229-.319-.571l-.263-2.092q-.479-.145-1.036-.454q-.556-.31-.947-.664l-1.915.824q-.317.14-.644.03t-.504-.415L3.648 15.57q-.177-.305-.104-.638t.348-.546l1.672-1.25q-.045-.272-.073-.559q-.03-.288-.03-.559q0-.252.03-.53q.028-.278.073-.626l-1.672-1.25q-.275-.213-.338-.555t.113-.648l1.06-1.8q.177-.287.504-.406t.644.021l1.896.804q.448-.373.97-.673q.52-.3 1.013-.464l.283-2.092q.061-.342.318-.571T10.96 3h2.08q.349 0 .605.229q.257.229.319.571l.263 2.112q.575.202 1.016.463t.909.654l1.992-.804q.318-.14.645-.021t.503.406l1.06 1.819q.177.306.104.638t-.348.547L18.36 10.92q.082.31.092.569t.01.51q0 .233-.02.491q-.019.259-.088.626l1.69 1.27q.275.213.358.546t-.094.638l-1.066 1.839q-.176.306-.513.415q-.337.11-.654-.03l-1.923-.824q-.467.393-.94.673t-.985.445l-.264 2.111q-.061.342-.318.571t-.605.23zm.04-1h1.956l.369-2.708q.756-.2 1.36-.549q.606-.349 1.232-.956l2.495 1.063l.994-1.7l-2.189-1.644q.125-.427.166-.786q.04-.358.04-.72q0-.38-.04-.72t-.166-.747l2.227-1.683l-.994-1.7l-2.552 1.07q-.454-.499-1.193-.935q-.74-.435-1.4-.577L13 4h-1.994l-.312 2.689q-.756.161-1.39.52q-.633.358-1.26.985L5.55 7.15l-.994 1.7l2.169 1.62q-.125.336-.175.73t-.05.82q0 .38.05.755t.156.73l-2.15 1.645l.994 1.7l2.475-1.05q.589.594 1.222.953q.634.359 1.428.559zm.973-5.5q1.046 0 1.773-.727T14.473 12t-.727-1.773t-1.773-.727q-1.052 0-1.776.727T9.473 12t.724 1.773t1.776.727M12 12"
></path>
</svg>
</template>

<script lang="ts">
export default {
name: "MaterialSymbolsLightSettingsOutlineRounded",
};
</script>
1 change: 1 addition & 0 deletions resources/client/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export { default as IconPencil } from "./IconPencil.vue";
export { default as IconPlusFilled } from "./IconPlusFilled.vue";
export { default as IconUser } from "./IconUser.vue";
export { default as IconRefresh } from "./IconRefresh.vue";
export { default as IconSettings } from "./IconSettings.vue";
export { default as IconSpinner } from "./IconSpinner.vue";
export { default as IconSound } from "./IconSound.vue";
export { default as IconTeX } from "./IconTeX.vue";
Expand Down
36 changes: 22 additions & 14 deletions resources/client/components/ui/switch/Switch.vue
Original file line number Diff line number Diff line change
@@ -1,37 +1,45 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
import {
SwitchRoot,
type SwitchRootEmits,
type SwitchRootProps,
SwitchThumb,
useForwardPropsEmits,
} from 'radix-vue'
import { computed, type HTMLAttributes } from 'vue'
} from "radix-vue";
import { computed, type HTMLAttributes } from "vue";
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<
SwitchRootProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<SwitchRootEmits>()
const emits = defineEmits<SwitchRootEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
const { class: _, ...delegated } = props;
return delegated
})
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>

<template>
<SwitchRoot
v-bind="forwarded"
:class="cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-stone-900 data-[state=unchecked]:bg-stone-200 dark:focus-visible:ring-stone-300 dark:focus-visible:ring-offset-stone-950 dark:data-[state=checked]:bg-stone-50 dark:data-[state=unchecked]:bg-stone-800',
props.class,
)"
:class="
cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-umn-maroon-800/10 shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-umn-maroon-800 data-[state=unchecked]:bg-umn-maroon-800/10',
props.class,
)
"
>
<SwitchThumb
:class="cn('pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 dark:bg-stone-950')"
:class="
cn(
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 dark:bg-stone-950',
)
"
/>
</SwitchRoot>
</template>
26 changes: 26 additions & 0 deletions resources/client/composables/useIsDeckTTSEnabled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { computed, MaybeRefOrGetter, provide, toValue, type Ref } from "vue";

import { Deck } from "@/types";
import { useAllFeatureFlagsQuery } from "@/queries/featureFlags";

/*
* Provides the isDeckTTSEnabled ref to the component tree.
* to avoid prop drilling
*/
export const useIsDeckTTSEnabled = (
deck: MaybeRefOrGetter<Deck | null | undefined>,
) => {
const { data: featureFlags } = useAllFeatureFlagsQuery();

const isDeckTTSEnabled = computed(
() =>
// global tts feature flag is enabled
(featureFlags.value?.text_to_speech ?? false) &&
// and deck has tts enabled
(toValue(deck)?.is_tts_enabled ?? false),
);

return {
isDeckTTSEnabled,
};
};
Loading

0 comments on commit 7dd54e3

Please sign in to comment.