Showing preview only (1,741K chars total). Download the full file or copy to clipboard to get everything.
Repository: rgerum/unofficial-duolingo-stories
Branch: main
Commit: 54942c64a17d
Files: 429
Total size: 1.6 MB
Directory structure:
gitextract_re_01zim/
├── .codex/
│ └── environments/
│ └── environment.toml
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── build.yml
│ ├── build_pr.yml
│ └── ci.yaml
├── .gitignore
├── AGENTS.md
├── CLAUDE.md
├── CONTEXT.md
├── README.md
├── biome.json
├── components.json
├── convex/
│ ├── _generated/
│ │ ├── ai/
│ │ │ ├── ai-files.state.json
│ │ │ └── guidelines.md
│ │ ├── api.d.ts
│ │ ├── api.js
│ │ ├── dataModel.d.ts
│ │ ├── server.d.ts
│ │ └── server.js
│ ├── account.ts
│ ├── adminData.ts
│ ├── adminStoryWrite.ts
│ ├── adminWrite.ts
│ ├── audioRead.ts
│ ├── auth.config.ts
│ ├── auth.ts
│ ├── authFunctions.ts
│ ├── authMigration.ts
│ ├── betterAuth/
│ │ ├── _generated/
│ │ │ ├── api.ts
│ │ │ ├── component.ts
│ │ │ ├── dataModel.ts
│ │ │ └── server.ts
│ │ ├── adapter.ts
│ │ ├── auth.ts
│ │ ├── convex.config.ts
│ │ └── schema.ts
│ ├── convex-env.d.ts
│ ├── convex.config.ts
│ ├── convex_rules.md
│ ├── courseContributorBackfill.ts
│ ├── courseWrite.ts
│ ├── discordAvatarSync.ts
│ ├── discordBot.ts
│ ├── discordData.ts
│ ├── discordRoleSync.ts
│ ├── editorRead.ts
│ ├── editorSideEffects.ts
│ ├── http.ts
│ ├── landing.ts
│ ├── languageWrite.ts
│ ├── lib/
│ │ ├── authorization.ts
│ │ ├── courseContributors.ts
│ │ ├── courseCounts.ts
│ │ ├── discordAvatarSync.ts
│ │ ├── phpbb.ts
│ │ └── publicStoryContent.ts
│ ├── localization.ts
│ ├── localizationWrite.ts
│ ├── lookupTables.ts
│ ├── roles.ts
│ ├── schema.ts
│ ├── storyApproval.ts
│ ├── storyDone.ts
│ ├── storyPublicContent.ts
│ ├── storyRead.ts
│ ├── storyTables.ts
│ ├── storyWrite.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── userPreferences.ts
├── database/
│ └── stories/
│ ├── es-en-o/
│ │ ├── 1_1_es-en-buenos-dias.txt
│ │ ├── 1_2_es-en-una-cita.txt
│ │ └── 1_3_es-en-una-cosa.txt
│ ├── nl-en/
│ │ ├── 0_1_es-en-el-pastel-de-dragones.txt
│ │ ├── 0_2_es-en-es-amor.txt
│ │ ├── 0_3_es-en-la-pelea-de-boxeo.txt
│ │ ├── 0_4_es-en-el-neumatico-pinchado.txt
│ │ ├── 1_1_es-buenos-dias.txt
│ │ ├── 1_2_es-una-cita.txt
│ │ ├── 1_3_es-una-cosa.txt
│ │ ├── 1_4_es-en-la-luna-de-miel.txt
│ │ ├── 2_1_es-en-la-chaqueta-roja.txt
│ │ ├── 2_2_es-en-el-pasaporte.txt
│ │ ├── 2_3_es-en-una-familia-muy-grande.txt
│ │ └── 2_4_es-en-el-doctor-eddy.txt
│ └── test-en/
│ ├── 1_1_es-en-buenos-dias.txt
│ └── 1_2_es-en-una-cita.txt
├── discord_roles/
│ ├── CONTEXT.md
│ ├── audio_cleanup.py
│ ├── blame.py
│ ├── combine.py
│ ├── discord_bot.py
│ ├── discord_reacting_bot.py
│ ├── env_utils.py
│ └── requirements.txt
├── docs/
│ └── bulk-audio-editor-spec.md
├── import_tools/
│ ├── README.md
│ ├── app.py
│ └── greasmonkey.js
├── instrumentation-client.ts
├── jsconfig.json
├── knip.json
├── next.config.js
├── package.json
├── postcss.config.mjs
├── process.d.ts
├── public/
│ ├── .well-known/
│ │ └── assetlinks.json
│ ├── darklight.js
│ ├── docs/
│ │ ├── audio-generation/
│ │ │ ├── character-editor.mdx
│ │ │ ├── edit.mdx
│ │ │ ├── engines.mdx
│ │ │ ├── fix-problems.mdx
│ │ │ ├── generate.mdx
│ │ │ └── overview.mdx
│ │ ├── become-contributor/
│ │ │ ├── application.mdx
│ │ │ └── colang.mdx
│ │ ├── docs.json
│ │ ├── introduction.mdx
│ │ ├── search.js
│ │ ├── story-creation/
│ │ │ ├── import.mdx
│ │ │ └── translate.mdx
│ │ ├── story-editing/
│ │ │ ├── exercises.mdx
│ │ │ ├── overview.mdx
│ │ │ └── translation-hints.mdx
│ │ └── story-publishing/
│ │ ├── publishing.mdx
│ │ └── without_tts.mdx
│ ├── linja-pona-4.9.otf
│ ├── linjalipamanka-normal.otf
│ ├── robots.txt
│ └── sw.js
├── scripts/
│ ├── backfill-course-contributors.ts
│ ├── backfill-discord-avatars.ts
│ └── find-missing-story-images.ts
├── skills-lock.json
├── src/
│ ├── app/
│ │ ├── (stories)/
│ │ │ ├── (main)/
│ │ │ │ ├── EditorCommandPaletteClient.tsx
│ │ │ │ ├── [course_id]/
│ │ │ │ │ ├── course_page_client.tsx
│ │ │ │ │ ├── not-found.tsx
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── story_button.tsx
│ │ │ │ ├── course-dropdown.tsx
│ │ │ │ ├── course_list.tsx
│ │ │ │ ├── faq/
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── footer_links.tsx
│ │ │ │ ├── get_course_data.ts
│ │ │ │ ├── header.tsx
│ │ │ │ ├── icons.tsx
│ │ │ │ ├── landing_stats_client.tsx
│ │ │ │ ├── language_button.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── not-found.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── privacy_policy/
│ │ │ │ │ └── page.tsx
│ │ │ │ └── profile/
│ │ │ │ ├── actions.ts
│ │ │ │ ├── data.ts
│ │ │ │ ├── page.tsx
│ │ │ │ └── profile.tsx
│ │ │ ├── learn/
│ │ │ │ ├── page.tsx
│ │ │ │ └── welcome.tsx
│ │ │ └── story/
│ │ │ ├── [story_id]/
│ │ │ │ ├── auto_play/
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── story_wrapper.tsx
│ │ │ │ ├── getStory.ts
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── not-found.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── script/
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── story_wrapper.tsx
│ │ │ │ ├── story_wrapper.tsx
│ │ │ │ └── test/
│ │ │ │ ├── page.tsx
│ │ │ │ └── story_wrapper.tsx
│ │ │ └── layout.tsx
│ │ ├── admin/
│ │ │ ├── AdminDialogTrigger.tsx
│ │ │ ├── AdminHeader.tsx
│ │ │ ├── FlagName.tsx
│ │ │ ├── adminDetailStyles.ts
│ │ │ ├── adminTableStyles.ts
│ │ │ ├── courses/
│ │ │ │ ├── courses.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── page_client.tsx
│ │ │ ├── edit_dialog.tsx
│ │ │ ├── languages/
│ │ │ │ ├── language_list.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── page_client.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx
│ │ │ ├── story/
│ │ │ │ ├── [story_id]/
│ │ │ │ │ ├── actions.ts
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── story_display.tsx
│ │ │ │ └── page.tsx
│ │ │ └── users/
│ │ │ ├── [user_id]/
│ │ │ │ ├── actions.ts
│ │ │ │ ├── page.tsx
│ │ │ │ ├── schema.ts
│ │ │ │ └── user_display.tsx
│ │ │ ├── page.tsx
│ │ │ └── user_list.tsx
│ │ ├── api/
│ │ │ ├── auth/
│ │ │ │ └── [...all]/
│ │ │ │ └── route.ts
│ │ │ ├── og/
│ │ │ │ └── route.tsx
│ │ │ ├── og-course/
│ │ │ │ └── route.tsx
│ │ │ └── og-story/
│ │ │ └── route.tsx
│ │ ├── audio/
│ │ │ ├── _lib/
│ │ │ │ └── audio/
│ │ │ │ ├── azure_tts.ts
│ │ │ │ ├── elevenlabs.ts
│ │ │ │ ├── google.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── polly.ts
│ │ │ │ └── types.ts
│ │ │ ├── create/
│ │ │ │ └── route.ts
│ │ │ ├── elevenlabs_quota/
│ │ │ │ └── page.tsx
│ │ │ ├── upload/
│ │ │ │ └── route.ts
│ │ │ └── voices/
│ │ │ └── route.ts
│ │ ├── auth/
│ │ │ ├── admin/
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── editor/
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── register/
│ │ │ │ ├── page.tsx
│ │ │ │ └── register.tsx
│ │ │ ├── reset_pw/
│ │ │ │ ├── page.tsx
│ │ │ │ └── reset_pw.tsx
│ │ │ └── signin/
│ │ │ ├── login_options.tsx
│ │ │ └── page.tsx
│ │ ├── dev/
│ │ │ └── story-footer-button-test/
│ │ │ ├── page.module.css
│ │ │ └── page.tsx
│ │ ├── docs/
│ │ │ ├── [[...slug]]/
│ │ │ │ ├── doc_data.ts
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ └── loading.tsx
│ │ ├── editor/
│ │ │ ├── (course)/
│ │ │ │ ├── course/
│ │ │ │ │ └── [course_id]/
│ │ │ │ │ ├── import/
│ │ │ │ │ │ └── [from_id]/
│ │ │ │ │ │ ├── import_list.tsx
│ │ │ │ │ │ ├── page.tsx
│ │ │ │ │ │ └── page_client.tsx
│ │ │ │ │ ├── localization/
│ │ │ │ │ │ ├── page.tsx
│ │ │ │ │ │ └── page_client.tsx
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ ├── page_client.tsx
│ │ │ │ │ ├── story/
│ │ │ │ │ │ └── [story]/
│ │ │ │ │ │ ├── audio-cutter/
│ │ │ │ │ │ │ ├── page.tsx
│ │ │ │ │ │ │ └── page_client.tsx
│ │ │ │ │ │ ├── layout.tsx
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ └── voices/
│ │ │ │ │ ├── edit/
│ │ │ │ │ │ ├── page.tsx
│ │ │ │ │ │ └── page_client.tsx
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── page_client.tsx
│ │ │ │ ├── course_list.tsx
│ │ │ │ ├── course_view_memory.ts
│ │ │ │ ├── edit_list.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── layout_client.tsx
│ │ │ │ ├── layout_flag.tsx
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── swipe.tsx
│ │ │ │ └── types.ts
│ │ │ ├── _components/
│ │ │ │ ├── breadcrumbs.tsx
│ │ │ │ ├── editor_command_palette.tsx
│ │ │ │ ├── header_context.tsx
│ │ │ │ ├── header_shell.tsx
│ │ │ │ ├── page_layout.tsx
│ │ │ │ └── story_editor_preferences.tsx
│ │ │ ├── editor_button.tsx
│ │ │ ├── language/
│ │ │ │ └── [language]/
│ │ │ │ ├── language_editor.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── page_client.tsx
│ │ │ │ ├── tts_edit/
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ ├── page_client.tsx
│ │ │ │ │ └── tts_edit.tsx
│ │ │ │ └── types.ts
│ │ │ ├── layout.tsx
│ │ │ ├── localization/
│ │ │ │ └── [language]/
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── localization_editor.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── page_client.tsx
│ │ │ │ └── text_edit.tsx
│ │ │ └── story/
│ │ │ └── [story]/
│ │ │ ├── audio-cutter-dialog.tsx
│ │ │ ├── audio-cutter-storage.ts
│ │ │ ├── bulk-audio-editor.tsx
│ │ │ ├── editor_state.ts
│ │ │ ├── header.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx
│ │ │ ├── page_client.tsx
│ │ │ ├── sound-recorder.tsx
│ │ │ ├── types.ts
│ │ │ └── v2/
│ │ │ ├── editor_v2.tsx
│ │ │ └── use_story_editor_model.ts
│ │ ├── layout.tsx
│ │ ├── manifest.json
│ │ └── not-found.tsx
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ └── index.ts
│ │ ├── CheckButton/
│ │ │ ├── CheckButton.tsx
│ │ │ └── index.ts
│ │ ├── ContributorList.tsx
│ │ ├── Docs/
│ │ │ ├── CustomMDXServer/
│ │ │ │ ├── CustomMDXServer.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── process_mdx.ts
│ │ │ ├── MdxTree/
│ │ │ │ ├── MdxTree.tsx
│ │ │ │ └── index.ts
│ │ │ └── docsClasses.ts
│ │ ├── DocsBreadCrumbNav/
│ │ │ ├── DocsBreadCrumbNav.tsx
│ │ │ └── index.ts
│ │ ├── DocsHeader/
│ │ │ ├── DocsHeader.tsx
│ │ │ └── index.ts
│ │ ├── DocsNavigation/
│ │ │ ├── DocsNavigation.tsx
│ │ │ └── index.ts
│ │ ├── DocsNavigationBackdrop/
│ │ │ ├── DocsNavigationBackdrop.tsx
│ │ │ └── index.ts
│ │ ├── DocsSearchModal/
│ │ │ ├── DocsSearchModal.tsx
│ │ │ └── index.ts
│ │ ├── EditorSSMLDisplay/
│ │ │ ├── EditorSSMLDisplay.tsx
│ │ │ └── index.ts
│ │ ├── FadeGlideIn/
│ │ │ ├── FadeGlideIn.tsx
│ │ │ └── index.ts
│ │ ├── LocalisationProvider/
│ │ │ ├── LocalisationProvider.tsx
│ │ │ ├── LocalisationProviderContext.tsx
│ │ │ └── index.ts
│ │ ├── NavigationModeProvider/
│ │ │ ├── NavigationModeProvider.tsx
│ │ │ └── index.ts
│ │ ├── PlayAudio/
│ │ │ ├── PlayAudio.tsx
│ │ │ └── index.ts
│ │ ├── ProgressBar/
│ │ │ ├── ProgressBar.tsx
│ │ │ └── index.ts
│ │ ├── StoryAutoPlay/
│ │ │ ├── StoryAutoPlay.tsx
│ │ │ └── index.ts
│ │ ├── StoryChallengeArrange/
│ │ │ ├── StoryChallengeArrange.tsx
│ │ │ └── index.ts
│ │ ├── StoryChallengeContinuation/
│ │ │ ├── StoryChallengeContinuation.tsx
│ │ │ └── index.ts
│ │ ├── StoryChallengeMatch/
│ │ │ ├── StoryChallengeMatch.tsx
│ │ │ └── index.ts
│ │ ├── StoryChallengeMultipleChoice/
│ │ │ ├── StoryChallengeMultipleChoice.tsx
│ │ │ └── index.ts
│ │ ├── StoryChallengePointToPhrase/
│ │ │ ├── StoryChallengePointToPhrase.tsx
│ │ │ └── index.ts
│ │ ├── StoryChallengeSelectPhrases/
│ │ │ ├── StoryChallengeSelectPhrases.tsx
│ │ │ └── index.ts
│ │ ├── StoryEditorPreview/
│ │ │ ├── StoryEditorPreview.tsx
│ │ │ └── index.ts
│ │ ├── StoryFinishedScreen/
│ │ │ ├── StoryFinishedScreen.tsx
│ │ │ └── index.ts
│ │ ├── StoryFooter/
│ │ │ ├── StoryFooter.tsx
│ │ │ └── index.ts
│ │ ├── StoryHeader/
│ │ │ ├── StoryHeader.tsx
│ │ │ └── index.ts
│ │ ├── StoryHeaderProgress/
│ │ │ ├── StoryHeaderProgress.tsx
│ │ │ └── index.ts
│ │ ├── StoryLineHints/
│ │ │ ├── StoryLineHints.tsx
│ │ │ └── index.ts
│ │ ├── StoryProgress/
│ │ │ ├── StoryProgress.tsx
│ │ │ └── index.ts
│ │ ├── StoryQuestionArrange/
│ │ │ ├── StoryQuestionArrange.tsx
│ │ │ └── index.ts
│ │ ├── StoryQuestionMatch/
│ │ │ ├── StoryQuestionMatch.tsx
│ │ │ └── index.ts
│ │ ├── StoryQuestionMultipleChoice/
│ │ │ ├── StoryQuestionMultipleChoice.tsx
│ │ │ └── index.ts
│ │ ├── StoryQuestionPointToPhrase/
│ │ │ ├── StoryQuestionPointToPhrase.tsx
│ │ │ └── index.ts
│ │ ├── StoryQuestionPrompt/
│ │ │ ├── StoryQuestionPrompt.tsx
│ │ │ └── index.ts
│ │ ├── StoryQuestionSelectPhrase/
│ │ │ ├── StoryQuestionSelectPhrase.tsx
│ │ │ └── index.ts
│ │ ├── StoryTextLine/
│ │ │ ├── StoryTextLine.tsx
│ │ │ ├── index.ts
│ │ │ └── use-audio.hook.ts
│ │ ├── StoryTextLineSimple/
│ │ │ ├── StoryTextLineSimple.tsx
│ │ │ └── index.ts
│ │ ├── StoryTitlePage/
│ │ │ ├── StoryTitlePage.tsx
│ │ │ └── index.ts
│ │ ├── VisuallyHidden/
│ │ │ ├── VisuallyHidden.tsx
│ │ │ └── index.ts
│ │ ├── WordButton/
│ │ │ ├── WordButton.tsx
│ │ │ └── index.ts
│ │ ├── auth/
│ │ │ └── styles.ts
│ │ ├── editor/
│ │ │ └── story/
│ │ │ ├── cast.tsx
│ │ │ ├── editor-resize.ts
│ │ │ ├── inline_tts.ts
│ │ │ ├── parser.test.ts
│ │ │ ├── parser.ts
│ │ │ ├── scroll_linking.ts
│ │ │ ├── syntax_parser_new.ts
│ │ │ └── syntax_parser_types.ts
│ │ ├── icons.tsx
│ │ ├── layout/
│ │ │ └── legal.tsx
│ │ ├── login/
│ │ │ ├── LoggedInButtonWrappedClient.tsx
│ │ │ └── loggedinbutton.tsx
│ │ ├── providers/
│ │ │ ├── ConvexClientProvider.tsx
│ │ │ └── PostHogUserIdentifier.tsx
│ │ └── ui/
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── dialog.tsx
│ │ ├── flag.tsx
│ │ ├── input.tsx
│ │ ├── kbd.tsx
│ │ ├── language-flag.tsx
│ │ ├── shadcn/
│ │ │ ├── dropdown-menu.tsx
│ │ │ └── index.ts
│ │ ├── sheet.tsx
│ │ ├── spinner.tsx
│ │ └── switch.tsx
│ ├── hooks/
│ │ ├── use-choice-buttons.hook.ts
│ │ ├── use-keypress.hook.ts
│ │ └── use-scroll-into-view.hook.ts
│ ├── instrumentation-client.ts
│ ├── lib/
│ │ ├── audio/
│ │ │ └── client-audio-processing.ts
│ │ ├── auth-client.ts
│ │ ├── auth-server.ts
│ │ ├── editor/
│ │ │ ├── audio/
│ │ │ │ ├── audio_edit_tools.test.ts
│ │ │ │ ├── audio_edit_tools.ts
│ │ │ │ └── text_with_mapping.ts
│ │ │ ├── editorHandlers.ts
│ │ │ └── tts_transcripte.ts
│ │ ├── fetch_post.ts
│ │ ├── getUserId.ts
│ │ ├── get_localisation.ts
│ │ ├── get_localisation_func.tsx
│ │ ├── hooks.ts
│ │ ├── is-typing-target.ts
│ │ ├── lamejs-compat.ts
│ │ ├── posthog-server.ts
│ │ ├── posthog-user.ts
│ │ ├── shuffle.ts
│ │ ├── sound-effects.ts
│ │ ├── story-preferences.ts
│ │ ├── story-search.ts
│ │ ├── userInterface.ts
│ │ └── utils.ts
│ ├── styles/
│ │ └── global.css
│ └── types/
│ ├── lamejs.d.ts
│ └── react-dom.d.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .codex/environments/environment.toml
================================================
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "unofficial-duolingo-stories"
[setup]
script = '''
pnpm i
cp /Users/richard/WebstormProjects/unofficial-duolingo-stories_bugfixes/.env.local .
'''
[[actions]]
name = "Run"
icon = "run"
command = '''
pnpm i
pnpm run dev
'''
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: duostories
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .github/workflows/build.yml
================================================
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Build
on:
push:
branches: ["master"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: 'npm'
node-version: 20
- uses: actions/cache@v3
with:
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
path: |
~/.npm
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- name: ssh tunnel to mysql database
run: |
mkdir -p ~/.ssh/
ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts
eval `ssh-agent -s`
ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}"
ssh -fN -L 3306:127.0.0.1:3306 duostori@ara.uberspace.de
ssh -fN -L 5432:127.0.0.1:5432 duostori@ara.uberspace.de
- name: Install
run: npm install
- run: printf "${{ secrets.ENV_LOCAL }}" >> .env.local
- name: Build
run: npm run build
- name: zip
run: zip -r build.zip .next
- name: Archive production artifacts
uses: actions/upload-artifact@v3
with:
name: build-data
path: build.zip
deploy-beta:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4.1.7
with:
name: build-data
- name: ssh tunnel for upload
run: |
mkdir -p ~/.ssh/
ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts
eval `ssh-agent -s`
ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}"
ssh -fN -L 3306:127.0.0.1:3306 duostori@ara.uberspace.de
ssh -fN -L 5432:127.0.0.1:5432 duostori@ara.uberspace.de
- name: upload
run: |
ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts
eval `ssh-agent -s`
ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}"
scp build.zip duostori@ara.uberspace.de:~/html/HEAD/
- name: unzip
run: |
ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts
eval `ssh-agent -s`
ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}"
ssh duostori@ara.uberspace.de 'rm -rf ~/html/HEAD/.next'
ssh duostori@ara.uberspace.de 'unzip -d ~/html/HEAD/ ~/html/HEAD/build.zip'
- name: restart
run: |
ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts
eval `ssh-agent -s`
ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}"
ssh duostori@ara.uberspace.de 'supervisorctl stop beta'
ssh duostori@ara.uberspace.de '/home/duostori/html/kill_rouge_workers.py'
ssh duostori@ara.uberspace.de '/home/duostori/html/kill_port_users.py beta'
ssh duostori@ara.uberspace.de 'supervisorctl start beta'
================================================
FILE: .github/workflows/build_pr.yml
================================================
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Build Pull Request
on:
pull_request:
branches: ["master"]
jobs:
build_pr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: 20
- name: ssh tunnel to mysql database
run: |
mkdir -p ~/.ssh/
ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts
eval `ssh-agent -s`
ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}"
ssh -fN -L 3306:127.0.0.1:3306 duostori@ara.uberspace.de
- name: Install
run: npm install
- run: printf "${{ secrets.ENV_LOCAL }}" >> .env.local
- name: Build
run: npm run build
- name: zip
run: zip -r build.zip .next
- name: upload
run: |
ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts
eval `ssh-agent -s`
ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}"
ssh duostori@ara.uberspace.de 'cd html && ./create_deployment.py ${{ github.event.number }}'
scp build.zip duostori@ara.uberspace.de:~/html/deploy_${{ github.event.number }}/
ssh duostori@ara.uberspace.de 'rm -rf ~/html/deploy_${{ github.event.number }}/.next'
ssh duostori@ara.uberspace.de 'unzip -d ~/html/deploy_${{ github.event.number }}/ ~/html/deploy_${{ github.event.number }}/build.zip'
ssh duostori@ara.uberspace.de 'supervisorctl stop deploy_${{ github.event.number }}'
ssh duostori@ara.uberspace.de '/home/duostori/html/kill_rouge_workers.py'
ssh duostori@ara.uberspace.de 'supervisorctl start deploy_${{ github.event.number }}'
================================================
FILE: .github/workflows/ci.yaml
================================================
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Copy .env.example files
shell: bash
run: find . -type f -name ".env.example" -exec sh -c 'cp "$1" "${1%.*}"' _ {} \;
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
================================================
FILE: .gitignore
================================================
.idea/
sql_dump/
/audio/rootkey.csv
/audio/tts/rootkey.csv
node_modules/
rootkey.csv
lib_swift/
dist/
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# production
build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/duolingo_data/
token.txt
/import_tools/duolingo_data/
duolingo_data*
sync.sh
sync_test.sh
publishX.sh
/packages/**/cypress/videos/
/packages/**/cypress/**/screenshots/
/src/next-all/test.sql
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# next.js
.next/
out/
tmp/
# misc
.env*
old/
/test.sqlite
.vercel
*storybook.log
next-env.d.ts
tsconfig.tsbuildinfo
discord_roles/.cache/
*/skills/*
skills/*
================================================
FILE: AGENTS.md
================================================
# Agent Notes
## Formatting
- Run `pnpm run format` after code edits in this repository.
- Use `pnpm run format:check` for CI/local validation.
- Biome is the default formatter/linter for this repo.
- Biome checks are intentionally scoped to `src/` and `convex/`.
- Run `pnpm run lint` before finishing when lint-sensitive files changed.
## Type Checking
- Run `pnpm typecheck` after code edits and before finishing.
## Convex
- Follow `./convex/convex_rules.md` when making changes in `convex/`.
- Always deploy Convex after changing files in `convex/` to the dev deployment (for example with `pnpm convex dev --once`), not prod.
<!-- BEGIN:nextjs-agent-rules -->
# Next.js: ALWAYS read docs before coding
Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth.
<!-- END:nextjs-agent-rules -->
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
================================================
FILE: CLAUDE.md
================================================
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Unofficial Duolingo Stories (https://duostories.org) - a community-driven platform that brings Duolingo Stories to new languages through community translation. Built with Next.js 16 (App Router) and React 19, with Convex as the canonical app data layer.
## Development Commands
```bash
pnpm run dev # Development server at http://localhost:3000
pnpm run build # Production build
pnpm run format # Biome formatter on src/ and convex/
pnpm run lint # Biome linter (with format check first)
pnpm run typecheck # TypeScript type checking (tsc --noEmit)
pnpm exec convex codegen # Regenerate Convex bindings after adding/changing Convex functions
```
Note: TypeScript build errors are ignored in `next.config.js` (`ignoreBuildErrors: true`), so `pnpm run build` will succeed even with type errors. Use `pnpm run typecheck` to check types separately.
## Environment Setup
Requires Convex.
Next.js `.env.local` typically includes:
- `NEXT_PUBLIC_CONVEX_URL` and `CONVEX_URL`
- `BETTER_AUTH_SECRET`
- `SITE_URL`
Convex env (`pnpm exec convex env set ...`) typically includes:
- `GITHUB_REPO_TOKEN` - used by `convex/editorSideEffects.ts`
- `POSTHOG_KEY` and `POSTHOG_HOST` - used by `convex/editorSideEffects.ts`
- `RESEND_API_KEY`, `SITE_URL`, `BETTER_AUTH_SECRET`
Test credentials: user/test (normal), editor/test (editor access), admin/test (admin access)
## Architecture
### Directory Structure
- `src/app/` - Next.js App Router pages
- `(stories)/` - Main story browsing (route group, includes story reader, course listing, profile, FAQ)
- `admin/` - Admin dashboard
- `editor/` - Story editor interface
- `auth/` - Authentication (signin, register, password reset)
- `api/` - API routes (auth handler, OG image generation)
- `audio/` - Audio processing endpoints
- `src/components/` - Reusable React components
- `src/lib/` - Server utilities, database helpers, auth
### Key Files
- `src/auth.ts` - Better Auth server configuration (JWT sessions, OAuth providers, email verification)
- `src/lib/auth-client.ts` - Client-side Better Auth client
- `convex/editorSideEffects.ts` - GitHub/PostHog side effects scheduled by write mutations
- `convex/lib/authorization.ts` - shared auth guard helpers for Convex functions
### Authentication
Uses Better Auth with JWT sessions (5-minute cookie cache). Supports email/password and OAuth (GitHub, Google, Facebook, Discord). Custom table names map to legacy schema (e.g., `user_better_auth`, `session_better_auth`). User model has custom `role` and `admin` fields.
### Database Access
Application reads/writes should go through Convex queries/mutations.
### Write-path Rules
- Prefer direct client/server-action calls to Convex mutations for app writes.
- Do not add pass-through Next route handlers for simple reads/writes.
- Use Next route handlers only for server-only concerns (auth entrypoint, file upload, external secrets/integration boundaries).
- Schedule side effects from Convex mutations using `ctx.scheduler.runAfter(..., internal...)`.
- Include `operationKey` for retriable writes.
- Keep side effects non-blocking: DB mutation success should not depend on GitHub/PostHog success.
### Component Pattern
```
/ComponentName
├── ComponentName.tsx # Implementation
├── ComponentName.module.css # CSS Module styles
└── index.ts # Export
```
### Styling
- CSS Modules for scoped styles (primary)
- Styled Components for dynamic styles (compiler enabled in `next.config.js`)
- Global styles in `src/styles/global.css`
### Path Alias
`@/` maps to `src/` (tsconfig baseUrl is `src`, paths `@/*` → `./*`).
## Story Workflow
Stories have a status workflow: draft → feedback → finished. Stories belong to courses, which link a learning language to a base language.
## Audio/TTS
Multiple TTS providers in `src/app/audio/_lib/audio/`: Azure, Google Cloud, AWS Polly, ElevenLabs.
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
================================================
FILE: CONTEXT.md
================================================
# Unofficial Duolingo Stories
This context describes the domain language for publishing and editing community-translated Duolingo-style stories.
## Language
**Story**:
A course-bound learning unit composed of dialogue, prompts, audio, and metadata.
_Avoid_: Lesson, exercise, activity
**Deleted Story**:
A story hidden from normal workflows without permanently removing its record.
_Avoid_: Archived story, permanently deleted story
**Story Title**:
The learner-facing title of a story.
_Avoid_: Story name
**Course**:
A container for stories for one learning-language/from-language pair.
_Avoid_: Language, localization, course language
**Course Slug**:
The compact course identifier used in URLs and course selection.
_Avoid_: Short code, language code
**Language**:
A reusable language record used by courses, voices, localization strings, avatar mappings, and flags.
_Avoid_: Course
**Language Code**:
The compact identifier for a single language.
_Avoid_: Course slug
**Learning Language**:
The language a course teaches.
_Avoid_: Target language
**From Language**:
The language a course assumes the learner already understands.
_Avoid_: Base language, source language
**Localization**:
A translated UI/app text string for a language.
_Avoid_: Story translation, course translation
**Story Content**:
The editable body of a story, represented both as source text and structured content.
_Avoid_: Story metadata
**Story Text**:
The author-facing textual representation of story content.
_Avoid_: Script
**Story Text Syntax**:
The markup and conventions contributors use inside story text.
_Avoid_: Format, parser syntax
**Structured Story Content**:
The parsed representation of story content used by editors and readers.
_Avoid_: Story JSON
**Story Element**:
One editable item inside a story body, such as a dialogue line, prompt, or challenge.
_Avoid_: Line, item
**Line**:
A text-bearing story element that can have speaker text and audio.
_Avoid_: Story element, file line
**Character**:
A visible or narrative persona used in story content.
_Avoid_: Speaker, avatar, voice
**Character Name**:
The display name for a character in a course or language context.
_Avoid_: Avatar
**Cast**:
The set of characters used by a story, with their names, avatars, and voices resolved for the course.
_Avoid_: Course characters
**Avatar**:
A reusable visual asset that can represent a character.
_Avoid_: Character, speaker, voice
**Avatar Mapping**:
A course- or language-specific assignment of an avatar to a character name and voice.
_Avoid_: Speaker mapping
**Voice**:
A text-to-speech voice or configuration used for story audio.
_Avoid_: Speaker, character, avatar
**Audio File**:
A media file used for story playback.
_Avoid_: Audio timing, voice
**Uploaded Audio**:
An audio file provided by a contributor.
_Avoid_: Generated audio
**Generated Audio**:
An audio file produced from text using a voice.
_Avoid_: Uploaded audio
**Audio Timing**:
Timing data that synchronizes story text with audio playback.
_Avoid_: Audio file, voice
**Story Header**:
The opening story element containing a title, illustration, learning-language content, and optional audio.
_Avoid_: Header, page header, app header
**Story Illustration**:
The visual associated with a story or story header.
_Avoid_: Story image, image
**Challenge**:
An interactive story element that asks the learner to respond.
_Avoid_: Question, exercise
**Story Status**:
The editorial lifecycle state of a story.
_Avoid_: Publication status
**Draft**:
A story status for work that is still being prepared.
_Avoid_: Unpublished
**Feedback**:
A story status for work that has received one approval and is ready for another contributor to review.
_Avoid_: Ready for feedback, review
**Finished**:
A story status for work that has completed the editorial workflow.
_Avoid_: Done
**Approval**:
A contributor review signal in the story editorial workflow.
_Avoid_: Publication, finished
**Publication Visibility**:
Whether a story is visible to learners.
_Avoid_: Story status, finished
**Published**:
A publication visibility value meaning a story is visible to learners.
_Avoid_: Finished
**Unpublished**:
A publication visibility value meaning a story is not visible to learners.
_Avoid_: Draft
**Publish**:
To change story visibility to Published.
_Avoid_: Finish, release
**Story Completion**:
A learner-side record that a user completed a story.
_Avoid_: Finished, done
**TODO**:
A marker written in story text by a contributor to flag something for later attention.
_Avoid_: Task, issue
**Hint**:
Supporting text shown to help learners understand a word or phrase.
_Avoid_: Translation
**Pronunciation Hint**:
Supporting text shown to help learners pronounce a word or phrase.
_Avoid_: Hint, pinyin
**Selectable Phrase**:
A phrase segment the learner can choose or arrange inside a challenge.
_Avoid_: Button
**Story Set**:
A group of stories within a course that is usually published together.
_Avoid_: Course, story status
**Set 0**:
An introductory story set outside the main course canon.
_Avoid_: Main story set
**Source Course**:
A course used as the reference when importing stories for translation into another course.
_Avoid_: Main canon
**Official Course**:
A non-public course imported from Duolingo as raw source material.
_Avoid_: Public course, endorsed course
**Public Course**:
A course visible to learners on the site.
_Avoid_: Published story
**Course Page**:
The public learner-facing page for a course and its published stories.
_Avoid_: Editor course story overview
**Target Course**:
A course that receives imported or translated stories.
_Avoid_: Target language
**Story Import**:
The act of creating a target-course story from a source-course story.
_Avoid_: Translation
**Translation**:
The linguistic work of adapting story content into the target course's learning language.
_Avoid_: Story import
**Learner**:
A user who consumes stories.
_Avoid_: Contributor
**Contributor**:
A user with global permission to edit project content.
_Avoid_: Editor
**Admin**:
A user with project-wide management permissions beyond normal contribution.
_Avoid_: Contributor
**Course Contributor**:
A contributor credited for making a minimum contribution to a course.
_Avoid_: Course-scoped editor, course permission
**Editor Area**:
The authenticated contributor-facing part of the site under `/editor`.
_Avoid_: Story editor, contributor
**Story Editor**:
The editor workspace for one specific story.
_Avoid_: Editor area, contributor
**Bulk Audio Editor**:
A story-level workspace for assigning many audio files and audio timings before applying them to a story.
_Avoid_: Audio cutter, voice editor
**Audio Cutter**:
A tool or workflow for preparing audio segments from longer audio.
_Avoid_: Bulk audio editor
**Story Page**:
The learner-facing page for reading or playing one published story.
_Avoid_: Story editor
**Editor Course Story Overview**:
The editor-area screen for viewing and working through one course's stories.
_Avoid_: Course editor, course story overview
**Character Voice Editor**:
The editor-area screen for assigning character names and voices in a course context.
_Avoid_: Voice editor, speaker editor
**Voice Catalog Editor**:
The editor-area screen for managing available voices.
_Avoid_: Character voice editor, speaker editor
**Course Localization Editor**:
The editor-area screen for editing localization strings in a course context.
_Avoid_: Story translation editor
## Relationships
- A **Course** owns zero or more **Stories**.
- A **Course** owns zero or more **Story Sets**.
- A **Course** has one **Course Slug** when it is addressable in the editor or site.
- A **Course** can be a **Public Course** or non-public.
- A **Public Course** can have one **Course Page**.
- A **Course** can act as a **Source Course** for story imports.
- A **Course** can act as a **Target Course** for story imports.
- An **Official Course** can act as raw material for **Story Imports**.
- A **Source Course** provides stories to one or more **Target Courses**.
- A **Story Import** copies a **Story** from a **Source Course** into a **Target Course**.
- **Translation** can happen after a **Story Import**.
- A **Learner** can create **Story Completions**.
- A **Contributor** can edit project content across courses.
- A **Contributor** can use the **Editor Area**.
- An **Editor Course Story Overview** belongs to one **Course**.
- An **Editor Course Story Overview** is part of the **Editor Area**.
- A **Character Voice Editor** works in one **Course** context.
- A **Character Voice Editor** is part of the **Editor Area**.
- A **Voice Catalog Editor** is part of the **Editor Area**.
- A **Voice Catalog Editor** manages **Voices**.
- A **Course Localization Editor** belongs to one **Course**.
- A **Course Localization Editor** edits **Localization** entries.
- A **Course Localization Editor** is part of the **Editor Area**.
- A **Story Editor** edits one **Story**.
- A **Bulk Audio Editor** belongs to one **Story Editor**.
- A **Bulk Audio Editor** edits **Audio Files** and **Audio Timing** for a **Story**.
- An **Audio Cutter** can produce **Audio Files** for a **Story**.
- A **Story Page** presents one **Published** **Story** to **Learners**.
- An **Admin** can manage content and settings beyond a normal **Contributor**'s scope.
- A **Course Contributor** is credited on public and internal course pages.
- A **Story Set** contains one or more **Stories**.
- A **Course** has exactly one learning **Language**.
- A **Course** has exactly one from **Language**.
- A **Language** has one **Language Code**.
- A **Localization** belongs to exactly one **Language**.
- A **Story** belongs to exactly one **Course**.
- A **Story** belongs to exactly one **Story Set**.
- A **Story** has exactly one **Story Title**.
- A **Story** has exactly one **Story Status**.
- A **Story** can be a **Deleted Story**.
- A **Story** can receive zero or more **Approval** signals.
- A **Story** becomes **Feedback** when it receives its first **Approval**.
- A **Story** becomes **Finished** when it receives two **Approval** signals.
- A **Story** has one **Publication Visibility**.
- A **Story** can have zero or more **Story Completion** records.
- A **Story Completion** belongs to one **Learner**.
- A **Story Text** can contain zero or more **TODO** markers.
- **Story Content** can include **Hint** entries for learner-facing text.
- **Story Content** can include **Pronunciation Hint** entries for learner-facing text.
- A **Challenge** can contain one or more **Selectable Phrase** entries.
- A regular **Story Set** is published when all four of its **Stories** are **Finished**.
- An **Approval** can indirectly trigger publication when it causes the final **Story** in a regular **Story Set** to become **Finished**.
- **Publish** changes one or more **Stories** from **Unpublished** to **Published**.
- **Set 0** can contain fewer than four introductory **Stories**.
- A **Story** has exactly one **Story Content** body.
- **Story Content** has one **Story Text** representation.
- **Story Text** uses **Story Text Syntax**.
- **Story Content** has one **Structured Story Content** representation.
- **Story Content** contains one or more **Story Elements**.
- A **Story Header** is a kind of **Story Element**.
- A **Line** is a kind of **Story Element**.
- A **Challenge** is a kind of **Story Element**.
- A **Story** can have one **Story Illustration**.
- A **Story Header** can show one **Story Illustration**.
- A **Story Header** can have one **Audio File**.
- A **Story Header** can have **Audio Timing** for its **Audio File**.
- A **Line** can be associated with one **Character**.
- A **Character** can have one **Character Name** in a course or language context.
- A **Character** can use one **Avatar** for display.
- A **Character** can use one **Voice** for generated audio.
- A **Story** has one **Cast**.
- A **Cast** contains one or more **Character** entries.
- An **Avatar Mapping** connects an **Avatar** to a **Character Name** and **Voice**.
- **Uploaded Audio** is a kind of **Audio File**.
- **Generated Audio** is a kind of **Audio File**.
- A **Line** can have one **Audio File**.
- A **Line** can have **Audio Timing** for its **Audio File**.
## Example dialogue
> **Dev:** "Can we create a **Story** before choosing where it belongs?"
> **Domain expert:** "No — every **Story** belongs to a **Course**."
## Flagged ambiguities
- "language" is often used informally to mean **Course**, but should be reserved for **Language** unless the learning/from-language pair is irrelevant.
- "line" can mean a story **Line** or a file line number; use **Line** only for story content.
- "header" can mean a **Story Header** or a UI layout header; use **Story Header** for story content.
- "speaker" is overloaded in code and UI; use **Voice** for text-to-speech configuration and **Character** for the story persona.
- "main canon" is not established project language; when referring to import/source guidance, discuss the source course explicitly.
- "editor" can mean the **Editor Area**, the **Story Editor**, or the person doing edits; use **Contributor** for the person.
- "course contributor" is attribution, not authorization; contributors currently have global edit access rather than per-course edit permissions.
- "done" appears in learner completion data; use **Story Completion** for learner progress and **Finished** for editorial status.
- "official" means imported from Duolingo as source material; it does not mean this project is endorsed by Duolingo.
- The first **Approval** often represents an author saying a story is ready for feedback, but the system does not require that approval to come from an author.
- **Approval** signals currently persist across story edits; edits do not reset or stale existing approvals.
================================================
FILE: README.md
================================================
# Unofficial Duolingo Stories
[](https://cloud.cypress.io/projects/cvszgh/runs)
[](https://discord.com/invite/4NGVScARR3)
This project brings the official Duolingo Stories to new languages, translated by a community effort.
It is _not_ an official product of Duolingo, nor is there any plan to integrate it into their platform or app.
It is hosted at https://duostories.org and reproduces the story experience from the official Duolingo stories.
The app is built with Next.js and React.
## Architecture snapshot
- App/UI: Next.js 16 + React 19 (`src/app`, `src/components`)
- Canonical app data access: Convex queries/mutations (`convex/*`)
- Write-side side effects (GitHub/PostHog): Convex internal actions in `convex/editorSideEffects.ts`
- Remaining Next route handlers are intentionally server-only:
- Auth entrypoint (`src/app/api/auth/[...all]/route.ts`)
- Audio endpoints (`src/app/audio/*/route.ts`)
### Write flow
Client component -> Convex mutation -> schedule internal actions:
- `editorSideEffects.*` for GitHub/PostHog side effects
This keeps write authorization, mutation semantics, and side effects centralized in Convex.
## How to run locally
Now create `.env.local` in the project root.
Minimum local values:
```
NEXT_PUBLIC_CONVEX_URL=<your_convex_dev_url>
CONVEX_URL=<your_convex_dev_url>
BETTER_AUTH_SECRET=<your_secret>
SITE_URL=http://localhost:3000
```
Convex runtime env (set via `pnpm exec convex env set ...`) should include:
```
GITHUB_REPO_TOKEN=<optional_for_side_effect_sync>
POSTHOG_KEY=<optional_for_server_tracking>
POSTHOG_HOST=<optional_for_server_tracking>
RESEND_API_KEY=<optional_for_email_flows>
SITE_URL=http://localhost:3000
BETTER_AUTH_SECRET=<must_match_auth_setup>
```
Install dependencies
```
pnpm install
```
To develop you can then run and visit http://localhost:3000
```
pnpm run dev
```
Recommended checks:
```
pnpm run typecheck
pnpm run lint
```
## How to contribute
To contribute to the project you should open an issue to discuss your proposed change.
You can assign the issue to yourself to show that you want to work on that.
If there is a consensus that this bug should be fixed or this feature should be implemented,
then follow the following steps:
- create a fork of the repository
- clone it to your computer
- create a branch for your feature
- make the changes to the code
- commit and push the changes to GitHub
- create a pull request
Please make sure to only commit changes to files that are necessary to the issue.
Try to not commit accidentally other changes, e.g. package-lock.json files.
This makes it harder to review and merge the pull request.
### Contribution rules for new backend work
- New app writes should be direct Convex mutations from the client or server action.
- Avoid adding pass-through Next route handlers for simple reads/writes.
- Server side effects should be scheduled from Convex mutations via internal actions.
- Include an `operationKey` for mutation calls that can be retried.
If everything is fine, I will accept the pull request and I will soon upload it to the website.
================================================
FILE: biome.json
================================================
{
"$schema": "https://biomejs.dev/schemas/2.4.9/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": [
"src/**",
"convex/**",
"!convex/_generated/**",
"!convex/**/_generated/**"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"a11y": {
"noAriaUnsupportedElements": "warn",
"useAltText": "warn",
"useAriaPropsForRole": "warn",
"useAriaPropsSupportedByRole": "warn",
"useValidAriaProps": "warn",
"useValidAriaValues": "warn"
},
"correctness": {
"noChildrenProp": "error",
"noNextAsyncClientComponent": "warn",
"useExhaustiveDependencies": "warn",
"useHookAtTopLevel": "error",
"useJsxKeyInIterable": "error"
},
"performance": {
"noImgElement": "off",
"noUnwantedPolyfillio": "warn",
"useGoogleFontPreconnect": "warn"
},
"security": {
"noDangerouslySetInnerHtmlWithChildren": "error"
},
"style": {
"noHeadElement": "warn"
},
"suspicious": {
"noCommentText": "error",
"noDocumentImportInPage": "error",
"noDuplicateJsxProps": "error",
"noHeadImportInDocument": "error",
"useGoogleFontDisplay": "warn"
}
},
"includes": [
"src/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}",
"convex/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}",
"!convex/_generated/**",
"!convex/**/_generated/**"
]
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
================================================
FILE: components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
================================================
FILE: convex/_generated/ai/ai-files.state.json
================================================
{
"guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57",
"agentsMdSectionHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2",
"claudeMdHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2",
"agentSkillsSha": "d0fa8085af313029add5740f67198aa42ca60c8d",
"installedSkillNames": [
"convex",
"convex-create-component",
"convex-migration-helper",
"convex-performance-audit",
"convex-quickstart",
"convex-setup-auth"
]
}
================================================
FILE: convex/_generated/ai/guidelines.md
================================================
# Convex guidelines
## Function guidelines
### Http endpoint syntax
- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:
```typescript
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/echo",
method: "POST",
handler: httpAction(async (ctx, req) => {
const body = await req.bytes();
return new Response(body, { status: 200 });
}),
});
```
- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`.
### Validators
- Below is an example of an array validator:
```typescript
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export default mutation({
args: {
simpleArray: v.array(v.union(v.string(), v.number())),
},
handler: async (ctx, args) => {
//...
},
});
```
- Below is an example of a schema with validators that codify a discriminated union type:
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
results: defineTable(
v.union(
v.object({
kind: v.literal("error"),
errorMessage: v.string(),
}),
v.object({
kind: v.literal("success"),
value: v.number(),
}),
),
),
});
```
- Here are the valid Convex types along with their respective validators:
Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Id | string | `doc._id` | `v.id(tableName)` | |
| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. |
| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |
| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. |
| Boolean | boolean | `true` | `v.boolean()` |
| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. |
| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. |
| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. |
| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". |
| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". |
### Function registration
- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`.
- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private.
- You CANNOT register a function through the `api` or `internal` objects.
- ALWAYS include argument validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`.
### Function calling
- Use `ctx.runQuery` to call a query from a query, mutation, or action.
- Use `ctx.runMutation` to call a mutation from a mutation or action.
- Use `ctx.runAction` to call an action from an action.
- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead.
- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions.
- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.
- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example,
```
export const f = query({
args: { name: v.string() },
handler: async (ctx, args) => {
return "Hello " + args.name;
},
});
export const g = query({
args: {},
handler: async (ctx, args) => {
const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
return null;
},
});
```
### Function references
- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.
- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`.
- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.
- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.
- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`.
### Pagination
- Define pagination using the following syntax:
```ts
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
export const listWithExtraArg = query({
args: { paginationOpts: paginationOptsValidator, author: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_author", (q) => q.eq("author", args.author))
.order("desc")
.paginate(args.paginationOpts);
},
});
```
Note: `paginationOpts` is an object with the following properties:
- `numItems`: the maximum number of documents to return (the validator is `v.number()`)
- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)
- A query that ends in `.paginate()` returns an object that has the following properties:
- page (contains an array of documents that you fetches)
- isDone (a boolean that represents whether or not this is the last page of documents)
- continueCursor (a string that represents the cursor to use to fetch the next page of documents)
## Schema guidelines
- Always define your schema in `convex/schema.ts`.
- Always import the schema definition functions from `convex/server`.
- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`.
- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2".
- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes.
- Do not store unbounded lists as an array field inside a document (e.g. `v.array(v.object({...}))`). As the array grows it will hit the 1MB document size limit, and every update rewrites the entire document. Instead, create a separate table for the child items with a foreign key back to the parent.
- Separate high-churn operational data (e.g. heartbeats, online status, typing indicators) from stable profile data. Storing frequently updated fields on a shared document forces every write to contend with reads of the entire document. Instead, create a dedicated table for the high-churn data with a foreign key back to the parent record.
## Authentication guidelines
- Convex supports JWT-based authentication through `convex/auth.config.ts`. ALWAYS create this file when using authentication. Without it, `ctx.auth.getUserIdentity()` will always return `null`.
- Example `convex/auth.config.ts`:
```typescript
export default {
providers: [
{
domain: "https://your-auth-provider.com",
applicationID: "convex",
},
],
};
```
The `domain` must be the issuer URL of the JWT provider. Convex fetches `{domain}/.well-known/openid-configuration` to discover the JWKS endpoint. The `applicationID` is checked against the JWT `aud` (audience) claim.
- Use `ctx.auth.getUserIdentity()` to get the authenticated user's identity in any query, mutation, or action. This returns `null` if the user is not authenticated, or a `UserIdentity` object with fields like `subject`, `issuer`, `name`, `email`, etc. The `subject` field is the unique user identifier.
- In Convex `UserIdentity`, `tokenIdentifier` is guaranteed and is the canonical stable identifier for the authenticated identity. For any auth-linked database lookup or ownership check, prefer `identity.tokenIdentifier` over `identity.subject`. Do NOT use `identity.subject` alone as a global identity key.
- NEVER accept a `userId` or any user identifier as a function argument for authorization purposes. Always derive the user identity server-side via `ctx.auth.getUserIdentity()`.
- When using an external auth provider with Convex on the client, use `ConvexProviderWithAuth` instead of `ConvexProvider`:
```tsx
import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
function App({ children }: { children: React.ReactNode }) {
return (
<ConvexProviderWithAuth client={convex} useAuth={useYourAuthHook}>
{children}
</ConvexProviderWithAuth>
);
}
```
The `useAuth` prop must return `{ isLoading, isAuthenticated, fetchAccessToken }`. Do NOT use plain `ConvexProvider` when authentication is needed — it will not send tokens with requests.
## Typescript guidelines
- You can use the helper typescript type `Id` imported from './\_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table.
- Use `Doc<"tableName">` from `./_generated/dataModel` to get the full document type for a table.
- Use `QueryCtx`, `MutationCtx`, `ActionCtx` from `./_generated/server` for typing function contexts. NEVER use `any` for ctx parameters — always use the proper context type.
- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record<Id<'users'>, string>`. Below is an example of using `Record` with an `Id` type in a query:
```ts
import { query } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel";
export const exampleQuery = query({
args: { userIds: v.array(v.id("users")) },
handler: async (ctx, args) => {
const idToUsername: Record<Id<"users">, string> = {};
for (const userId of args.userIds) {
const user = await ctx.db.get("users", userId);
if (user) {
idToUsername[user._id] = user.username;
}
}
return idToUsername;
},
});
```
- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`.
## Full text search guidelines
- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like:
const messages = await ctx.db
.query("messages")
.withSearchIndex("search_body", (q) =>
q.search("body", "hello hi").eq("channel", "#general"),
)
.take(10);
## Query guidelines
- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead.
- If the user does not explicitly tell you to return all results from a query you should ALWAYS return a bounded collection instead. So that is instead of using `.collect()` you should use `.take()` or paginate on database queries. This prevents future performance issues when tables grow in an unbounded way.
- Never use `.collect().length` to count rows. Convex has no built-in count operator, so if you need a count that stays efficient at scale, maintain a denormalized counter in a separate document and update it in your mutations.
- Convex queries do NOT support `.delete()`. If you need to delete all documents matching a query, use `.take(n)` to read them in batches, iterate over each batch calling `ctx.db.delete(row._id)`, and repeat until no more results are returned.
- Convex mutations are transactions with limits on the number of documents read and written. If a mutation needs to process more documents than fit in a single transaction (e.g. bulk deletion on a large table), process a batch with `.take(n)` and then call `ctx.scheduler.runAfter(0, api.myModule.myMutation, args)` to schedule itself to continue. This way each invocation stays within transaction limits.
- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query.
- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax.
### Ordering
- By default Convex always returns documents in ascending `_creationTime` order.
- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending.
- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.
## Mutation guidelines
- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })`
- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })`
## Action guidelines
- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules.
- Never add `"use node";` to a file that also exports queries or mutations. Only actions can run in the Node.js runtime; queries and mutations must stay in the default Convex runtime. If you need Node.js built-ins alongside queries or mutations, put the action in a separate file.
- `fetch()` is available in the default Convex runtime. You do NOT need `"use node";` just to use `fetch()`.
- Never use `ctx.db` inside of an action. Actions don't have access to the database.
- Below is an example of the syntax for an action:
```ts
import { action } from "./_generated/server";
export const exampleAction = action({
args: {},
handler: async (ctx, args) => {
console.log("This action does not return anything");
return null;
},
});
```
## Scheduling guidelines
### Cron guidelines
- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers.
- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods.
- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example,
```ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
import { internalAction } from "./_generated/server";
const empty = internalAction({
args: {},
handler: async (ctx, args) => {
console.log("empty");
},
});
const crons = cronJobs();
// Run `internal.crons.empty` every two hours.
crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {});
export default crons;
```
- You can register Convex functions within `crons.ts` just like any other file.
- If a cron calls an internal function, always import the `internal` object from '\_generated/api', even if the internal function is registered in the same file.
## Testing guidelines
- Use `convex-test` with `vitest` and `@edge-runtime/vm` to test Convex functions. Always install the latest versions of these packages. Configure vitest with `environment: "edge-runtime"` in `vitest.config.ts`.
Test files go inside the `convex/` directory. You must pass a module map from `import.meta.glob` to `convexTest`:
```typescript
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import { expect, test } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
test("some behavior", async () => {
const t = convexTest(schema, modules);
await t.mutation(api.messages.send, { body: "Hi!", author: "Sarah" });
const messages = await t.query(api.messages.list);
expect(messages).toMatchObject([{ body: "Hi!", author: "Sarah" }]);
});
```
The `modules` argument is required so convex-test can discover and load function files. The `/// <reference types="vite/client" />` directive is needed for TypeScript to recognize `import.meta.glob`.
## File storage guidelines
- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist.
- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata.
Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`.
```
import { query } from "./_generated/server";
import { Id } from "./_generated/dataModel";
type FileMetadata = {
_id: Id<"_storage">;
_creationTime: number;
contentType?: string;
sha256: string;
size: number;
}
export const exampleQuery = query({
args: { fileId: v.id("_storage") },
handler: async (ctx, args) => {
const metadata: FileMetadata | null = await ctx.db.system.get("_storage", args.fileId);
console.log(metadata);
return null;
},
});
```
- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage.
================================================
FILE: convex/_generated/api.d.ts
================================================
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as account from "../account.js";
import type * as adminData from "../adminData.js";
import type * as adminStoryWrite from "../adminStoryWrite.js";
import type * as adminWrite from "../adminWrite.js";
import type * as audioRead from "../audioRead.js";
import type * as auth from "../auth.js";
import type * as authFunctions from "../authFunctions.js";
import type * as authMigration from "../authMigration.js";
import type * as courseContributorBackfill from "../courseContributorBackfill.js";
import type * as courseWrite from "../courseWrite.js";
import type * as discordAvatarSync from "../discordAvatarSync.js";
import type * as discordBot from "../discordBot.js";
import type * as discordData from "../discordData.js";
import type * as discordRoleSync from "../discordRoleSync.js";
import type * as editorRead from "../editorRead.js";
import type * as editorSideEffects from "../editorSideEffects.js";
import type * as http from "../http.js";
import type * as landing from "../landing.js";
import type * as languageWrite from "../languageWrite.js";
import type * as lib_authorization from "../lib/authorization.js";
import type * as lib_courseContributors from "../lib/courseContributors.js";
import type * as lib_courseCounts from "../lib/courseCounts.js";
import type * as lib_discordAvatarSync from "../lib/discordAvatarSync.js";
import type * as lib_phpbb from "../lib/phpbb.js";
import type * as lib_publicStoryContent from "../lib/publicStoryContent.js";
import type * as localization from "../localization.js";
import type * as localizationWrite from "../localizationWrite.js";
import type * as lookupTables from "../lookupTables.js";
import type * as roles from "../roles.js";
import type * as storyApproval from "../storyApproval.js";
import type * as storyDone from "../storyDone.js";
import type * as storyPublicContent from "../storyPublicContent.js";
import type * as storyRead from "../storyRead.js";
import type * as storyTables from "../storyTables.js";
import type * as storyWrite from "../storyWrite.js";
import type * as userPreferences from "../userPreferences.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
account: typeof account;
adminData: typeof adminData;
adminStoryWrite: typeof adminStoryWrite;
adminWrite: typeof adminWrite;
audioRead: typeof audioRead;
auth: typeof auth;
authFunctions: typeof authFunctions;
authMigration: typeof authMigration;
courseContributorBackfill: typeof courseContributorBackfill;
courseWrite: typeof courseWrite;
discordAvatarSync: typeof discordAvatarSync;
discordBot: typeof discordBot;
discordData: typeof discordData;
discordRoleSync: typeof discordRoleSync;
editorRead: typeof editorRead;
editorSideEffects: typeof editorSideEffects;
http: typeof http;
landing: typeof landing;
languageWrite: typeof languageWrite;
"lib/authorization": typeof lib_authorization;
"lib/courseContributors": typeof lib_courseContributors;
"lib/courseCounts": typeof lib_courseCounts;
"lib/discordAvatarSync": typeof lib_discordAvatarSync;
"lib/phpbb": typeof lib_phpbb;
"lib/publicStoryContent": typeof lib_publicStoryContent;
localization: typeof localization;
localizationWrite: typeof localizationWrite;
lookupTables: typeof lookupTables;
roles: typeof roles;
storyApproval: typeof storyApproval;
storyDone: typeof storyDone;
storyPublicContent: typeof storyPublicContent;
storyRead: typeof storyRead;
storyTables: typeof storyTables;
storyWrite: typeof storyWrite;
userPreferences: typeof userPreferences;
}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {
betterAuth: import("../betterAuth/_generated/component.js").ComponentApi<"betterAuth">;
};
================================================
FILE: convex/_generated/api.js
================================================
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();
================================================
FILE: convex/_generated/dataModel.d.ts
================================================
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
================================================
FILE: convex/_generated/server.d.ts
================================================
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
================================================
FILE: convex/_generated/server.js
================================================
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction = httpActionGeneric;
================================================
FILE: convex/account.ts
================================================
import { components } from "./_generated/api";
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { requireSessionLegacyUserId } from "./lib/authorization";
export const deleteCurrentUser = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const legacyUserId = await requireSessionLegacyUserId(ctx);
const user = (await ctx.runQuery(components.betterAuth.adapter.findOne, {
model: "user",
where: [{ field: "userId", operator: "eq", value: String(legacyUserId) }],
})) as { _id?: string | null } | null;
if (!user?._id) {
throw new Error("Account not found.");
}
await ctx.runMutation(components.betterAuth.adapter.deleteOne, {
input: {
model: "user",
where: [{ field: "_id", value: user._id }],
},
});
return null;
},
});
================================================
FILE: convex/adminData.ts
================================================
import {
mutation,
query,
type MutationCtx,
type QueryCtx,
} from "./_generated/server";
import { v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { components } from "./_generated/api";
type AuthCtx = MutationCtx | QueryCtx;
async function isAdmin(ctx: AuthCtx) {
const identity = (await ctx.auth.getUserIdentity()) as {
role?: string | null;
} | null;
return identity?.role === "admin";
}
const adminLanguageValidator = v.object({
id: v.number(),
name: v.string(),
short: v.string(),
flag: v.number(),
flag_file: v.string(),
speaker: v.string(),
default_text: v.string(),
tts_replace: v.string(),
public: v.boolean(),
rtl: v.boolean(),
});
const adminCourseValidator = v.object({
id: v.number(),
learning_language: v.number(),
from_language: v.number(),
public: v.boolean(),
official: v.boolean(),
name: v.union(v.string(), v.null()),
about: v.union(v.string(), v.null()),
conlang: v.boolean(),
short: v.union(v.string(), v.null()),
tags: v.array(v.string()),
});
const adminApprovalValidator = v.object({
id: v.number(),
date: v.number(),
name: v.string(),
});
const adminStoryValidator = v.object({
id: v.number(),
name: v.string(),
image: v.string(),
public: v.boolean(),
short: v.string(),
approvals: v.array(adminApprovalValidator),
});
const yesNoAllFilterValidator = v.union(
v.literal("all"),
v.literal("yes"),
v.literal("no"),
);
const roleFilterValidator = v.union(
v.literal("all"),
v.literal("user"),
v.literal("contributor"),
v.literal("admin"),
);
const adminUserValidator = v.object({
rowKey: v.string(),
id: v.number(),
name: v.string(),
email: v.string(),
image: v.union(v.string(), v.null()),
regdate: v.union(v.number(), v.null()),
activated: v.boolean(),
role: v.boolean(),
admin: v.boolean(),
discordLinked: v.boolean(),
discordAccountId: v.union(v.string(), v.null()),
discordStoriesRole: v.union(v.string(), v.null()),
discordStoriesSyncStatus: v.union(
v.literal("assigned"),
v.literal("up_to_date"),
v.literal("no_milestone"),
v.literal("not_linked"),
v.literal("member_not_found"),
v.literal("error"),
v.null(),
),
discordStoriesLastSyncedAt: v.union(v.number(), v.null()),
});
async function findAuthUserByLegacyId(ctx: AuthCtx, legacyId: number) {
return (await ctx.runQuery(components.betterAuth.adapter.findOne, {
model: "user",
where: [{ field: "userId", operator: "eq", value: String(legacyId) }],
})) as {
_id: string;
userId?: string | null;
name?: string | null;
email?: string | null;
image?: string | null;
createdAt?: number | null;
role?: string | null;
emailVerified?: boolean | null;
} | null;
}
function toAdminUser(
user: {
_id?: string;
userId?: string | null;
name?: string | null;
email?: string | null;
image?: string | null;
createdAt?: number | null;
role?: string | null;
emailVerified?: unknown;
},
discordAccountId?: string | null,
storiesRoleSnapshot?: {
assignedStoriesCount?: number | null;
syncStatus?:
| "assigned"
| "up_to_date"
| "no_milestone"
| "not_linked"
| "member_not_found"
| "error"
| null;
lastSyncedAt?: number | null;
} | null,
) {
const numericId = Number.parseInt(user.userId ?? "", 10);
const role = user.role ?? null;
const assignedStoriesCount =
typeof storiesRoleSnapshot?.assignedStoriesCount === "number"
? storiesRoleSnapshot.assignedStoriesCount
: null;
return {
rowKey:
user._id ??
`${user.userId ?? ""}-${user.email ?? ""}-${user.createdAt ?? 0}`,
id: Number.isFinite(numericId) ? numericId : 0,
name: user.name ?? "",
email: user.email ?? "",
image:
typeof user.image === "string" && user.image.length > 0
? user.image
: null,
regdate: typeof user.createdAt === "number" ? user.createdAt : null,
activated: Boolean(user.emailVerified),
role: role === "contributor" || role === "admin",
admin: role === "admin",
discordLinked:
typeof discordAccountId === "string" && discordAccountId.length > 0,
discordAccountId:
typeof discordAccountId === "string" && discordAccountId.length > 0
? discordAccountId
: null,
discordStoriesRole:
typeof assignedStoriesCount === "number" && assignedStoriesCount > 0
? `${assignedStoriesCount} Stories`
: null,
discordStoriesSyncStatus: storiesRoleSnapshot?.syncStatus ?? null,
discordStoriesLastSyncedAt:
typeof storiesRoleSnapshot?.lastSyncedAt === "number"
? storiesRoleSnapshot.lastSyncedAt
: null,
};
}
async function getDiscordAccountIdsByAuthUserIds(
ctx: AuthCtx,
authUserIds: string[],
) {
const uniqueAuthUserIds = Array.from(
new Set(authUserIds.filter((value) => value.length > 0)),
);
if (uniqueAuthUserIds.length === 0) return new Map<string, string>();
const response = await ctx.runQuery(components.betterAuth.adapter.findMany, {
model: "account",
where: [
{ field: "providerId", operator: "eq", value: "discord" },
{ field: "userId", operator: "in", value: uniqueAuthUserIds },
],
paginationOpts: { cursor: null, numItems: uniqueAuthUserIds.length + 20 },
});
const discordAccountIdByAuthUserId = new Map<string, string>();
for (const account of response.page as Array<{
userId?: string | null;
accountId?: string | null;
}>) {
if (!account.userId || !account.accountId) continue;
discordAccountIdByAuthUserId.set(account.userId, account.accountId);
}
return discordAccountIdByAuthUserId;
}
async function getStoriesRoleSnapshotsByLegacyUserIds(
ctx: AuthCtx,
legacyUserIds: number[],
) {
const wantedIds = new Set(
legacyUserIds.filter((legacyUserId) => Number.isFinite(legacyUserId)),
);
if (wantedIds.size === 0) {
return new Map<
number,
{
assignedStoriesCount?: number | null;
syncStatus?:
| "assigned"
| "up_to_date"
| "no_milestone"
| "not_linked"
| "member_not_found"
| "error"
| null;
lastSyncedAt?: number | null;
}
>();
}
const snapshotByLegacyUserId = new Map<
number,
{
assignedStoriesCount?: number | null;
syncStatus?:
| "assigned"
| "up_to_date"
| "no_milestone"
| "not_linked"
| "member_not_found"
| "error"
| null;
lastSyncedAt?: number | null;
}
>();
const rows = await Promise.all(
Array.from(wantedIds).map((legacyUserId) =>
ctx.db
.query("discord_stories_role_sync")
.withIndex("by_legacy_user_id", (q) =>
q.eq("legacyUserId", legacyUserId),
)
.unique(),
),
);
for (const row of rows) {
if (!row) continue;
if (!wantedIds.has(row.legacyUserId)) continue;
snapshotByLegacyUserId.set(row.legacyUserId, row);
}
return snapshotByLegacyUserId;
}
export const getAdminUsersPage = query({
args: {
query: v.string(),
limit: v.number(),
activatedFilter: yesNoAllFilterValidator,
roleFilter: roleFilterValidator,
},
returns: v.object({
users: v.array(adminUserValidator),
hasMore: v.boolean(),
}),
handler: async (ctx, args) => {
if (!(await isAdmin(ctx))) {
return { users: [], hasMore: false };
}
const limit = Math.max(1, Math.min(500, Math.floor(args.limit)));
const queryLimit = limit + 1;
const searchTerm = args.query.trim();
const searchLower = searchTerm.toLowerCase();
const searchMode =
searchTerm.length === 0
? "none"
: /^\d+$/.test(searchLower)
? "id"
: searchTerm.includes("@")
? "email"
: "username";
const matchedUsers =
searchMode === "id"
? await ctx.runQuery(components.betterAuth.adapter.searchUsersById, {
activatedFilter: args.activatedFilter,
roleFilter: args.roleFilter,
id: searchTerm,
})
: searchMode === "email"
? await ctx.runQuery(
components.betterAuth.adapter.searchUsersByEmailPrefix,
{
activatedFilter: args.activatedFilter,
roleFilter: args.roleFilter,
prefix: searchTerm,
limit: queryLimit,
},
)
: searchMode === "username"
? await ctx.runQuery(
components.betterAuth.adapter.searchUsersByUsernamePrefix,
{
activatedFilter: args.activatedFilter,
roleFilter: args.roleFilter,
prefix: searchTerm,
limit: queryLimit,
},
)
: await ctx.runQuery(components.betterAuth.adapter.searchUsersAll, {
activatedFilter: args.activatedFilter,
roleFilter: args.roleFilter,
limit: queryLimit,
});
const pageUsers = matchedUsers.slice(0, limit) as Array<{
_id?: string;
userId?: string | null;
name?: string | null;
email?: string | null;
image?: string | null;
createdAt?: number | null;
role?: string | null;
emailVerified?: unknown;
}>;
const discordAccountIdByAuthUserId =
await getDiscordAccountIdsByAuthUserIds(
ctx,
pageUsers
.map((user) => user._id)
.filter((value): value is string => typeof value === "string"),
);
const storiesRoleSnapshotByLegacyUserId =
await getStoriesRoleSnapshotsByLegacyUserIds(
ctx,
pageUsers
.map((user) => Number.parseInt(user.userId ?? "", 10))
.filter((value) => Number.isFinite(value)),
);
const users = pageUsers.map((user) =>
toAdminUser(
user,
typeof user._id === "string"
? (discordAccountIdByAuthUserId.get(user._id) ?? null)
: null,
(() => {
const legacyUserId = Number.parseInt(user.userId ?? "", 10);
return Number.isFinite(legacyUserId)
? (storiesRoleSnapshotByLegacyUserId.get(legacyUserId) ?? null)
: null;
})(),
),
);
return { users, hasMore: matchedUsers.length > limit };
},
});
export const getAdminUserByLegacyId = query({
args: {
id: v.number(),
},
returns: v.union(adminUserValidator, v.null()),
handler: async (ctx, args) => {
if (!(await isAdmin(ctx))) return null;
const user = await findAuthUserByLegacyId(ctx, args.id);
if (!user?._id) return null;
const discordAccountIdByAuthUserId =
await getDiscordAccountIdsByAuthUserIds(ctx, [user._id]);
const storiesRoleSnapshotByLegacyUserId =
await getStoriesRoleSnapshotsByLegacyUserIds(ctx, [args.id]);
return toAdminUser(
user,
discordAccountIdByAuthUserId.get(user._id) ?? null,
storiesRoleSnapshotByLegacyUserId.get(args.id) ?? null,
);
},
});
export const setAdminUserActivated = mutation({
args: {
id: v.number(),
activated: v.boolean(),
},
returns: v.null(),
handler: async (ctx, args) => {
if (!(await isAdmin(ctx))) return null;
const user = await findAuthUserByLegacyId(ctx, args.id);
if (!user?._id) return null;
await ctx.runMutation(components.betterAuth.adapter.updateOne, {
input: {
model: "user",
where: [{ field: "_id", value: user._id }],
update: { emailVerified: args.activated },
},
});
return null;
},
});
export const setAdminUserWrite = mutation({
args: {
id: v.number(),
write: v.boolean(),
},
returns: v.null(),
handler: async (ctx, args) => {
if (!(await isAdmin(ctx))) return null;
const user = await findAuthUserByLegacyId(ctx, args.id);
if (!user?._id) return null;
await ctx.runMutation(components.betterAuth.adapter.updateOne, {
input: {
model: "user",
where: [{ field: "_id", value: user._id }],
update: { role: args.write ? "contributor" : "user" },
},
});
return null;
},
});
export const setAdminUserDelete = mutation({
args: {
id: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
if (!(await isAdmin(ctx))) return null;
const user = await findAuthUserByLegacyId(ctx, args.id);
if (!user?._id) return null;
await ctx.runMutation(components.betterAuth.adapter.deleteOne, {
input: {
model: "user",
where: [{ field: "_id", value: user._id }],
},
});
return null;
},
});
export const getAdminLanguages = query({
args: {},
returns: v.array(adminLanguageValidator),
handler: async (ctx) => {
if (!(await isAdmin(ctx))) return [];
const rows = await ctx.db.query("languages").collect();
return rows
.map((row) => ({
id: row.legacyId,
name: row.name,
short: row.short,
flag:
typeof row.flag === "number"
? row.flag
: Number.isFinite(Number(row.flag))
? Number(row.flag)
: 0,
flag_file: row.flag_file ?? "",
speaker: row.speaker ?? "",
default_text: row.default_text ?? "",
tts_replace: row.tts_replace ?? "",
public: row.public,
rtl: row.rtl,
}))
.sort((a, b) => a.id - b.id);
},
});
export const getAdminCourses = query({
args: {},
returns: v.object({
courses: v.array(adminCourseValidator),
languages: v.array(adminLanguageValidator),
}),
handler: async (ctx) => {
if (!(await isAdmin(ctx))) {
return {
courses: [],
languages: [],
};
}
const [courseRows, languageRows] = await Promise.all([
ctx.db.query("courses").collect(),
ctx.db.query("languages").collect(),
]);
const languageIdToLegacy = new Map<Id<"languages">, number>();
for (const language of languageRows) {
languageIdToLegacy.set(language._id, language.legacyId);
}
const courses = courseRows
.map((row) => ({
id: row.legacyId,
learning_language: languageIdToLegacy.get(row.learningLanguageId) ?? 0,
from_language: languageIdToLegacy.get(row.fromLanguageId) ?? 0,
public: row.public,
official: row.official,
name: row.name ?? null,
about: row.about ?? null,
conlang: row.conlang ?? false,
short: row.short ?? null,
tags: row.tags ?? [],
}))
.sort((a, b) => a.id - b.id);
const languages = languageRows
.map((row) => ({
id: row.legacyId,
name: row.name,
short: row.short,
flag:
typeof row.flag === "number"
? row.flag
: Number.isFinite(Number(row.flag))
? Number(row.flag)
: 0,
flag_file: row.flag_file ?? "",
speaker: row.speaker ?? "",
default_text: row.default_text ?? "",
tts_replace: row.tts_replace ?? "",
public: row.public,
rtl: row.rtl,
}))
.sort((a, b) => a.id - b.id);
return { courses, languages };
},
});
export const getAdminStoryByLegacyId = query({
args: {
legacyStoryId: v.number(),
},
returns: v.union(adminStoryValidator, v.null()),
handler: async (ctx, args) => {
if (!(await isAdmin(ctx))) return null;
const story = await ctx.db
.query("stories")
.withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId))
.unique();
if (!story || typeof story.legacyId !== "number") return null;
const course = await ctx.db.get(story.courseId);
if (!course || !course.short) return null;
const approvals = await ctx.db
.query("story_approval")
.withIndex("by_story", (q) => q.eq("storyId", story._id))
.collect();
const legacyIds = approvals
.map((approval) => approval.legacyUserId)
.filter((id): id is number => typeof id === "number");
const authUsers = await ctx.runQuery(
components.betterAuth.adapter.findMany,
{
model: "user",
where: [
{ field: "userId", operator: "in", value: legacyIds.map(String) },
],
paginationOpts: { cursor: null, numItems: legacyIds.length + 10 },
},
);
const userNameByLegacyId = new Map<number, string>();
for (const user of authUsers.page as Array<{
userId?: string | null;
name?: string | null;
}>) {
const legacyId = Number.parseInt(user.userId ?? "", 10);
if (!Number.isFinite(legacyId) || !user.name) continue;
userNameByLegacyId.set(legacyId, user.name);
}
return {
id: story.legacyId,
name: story.name,
image: story.imageId
? ((await ctx.db.get(story.imageId))?.legacyId ?? "")
: "",
public: story.public,
short: course.short,
approvals: approvals
.map((approval) => ({
id: approval.legacyId ?? 0,
date: approval.date,
name:
typeof approval.legacyUserId === "number"
? (userNameByLegacyId.get(approval.legacyUserId) ?? "Unknown")
: "Unknown",
}))
.filter((approval) => approval.id > 0),
};
},
});
================================================
FILE: convex/adminStoryWrite.ts
================================================
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { requireAdmin } from "./lib/authorization";
import { recomputeCoursePublishedCount } from "./lib/courseCounts";
export const togglePublished = mutation({
args: {
legacyStoryId: v.number(),
operationKey: v.optional(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
await requireAdmin(ctx);
const story = await ctx.db
.query("stories")
.withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId))
.unique();
if (!story || typeof story.legacyId !== "number") {
throw new Error(`Story ${args.legacyStoryId} not found`);
}
const course = await ctx.db.get(story.courseId);
if (!course || typeof course.legacyId !== "number") {
throw new Error(`Course missing for story ${args.legacyStoryId}`);
}
const nextPublic = !story.public;
await ctx.db.patch(story._id, { public: nextPublic });
await recomputeCoursePublishedCount(ctx, course._id);
return null;
},
});
export const removeApproval = mutation({
args: {
legacyStoryId: v.number(),
legacyApprovalId: v.number(),
operationKey: v.optional(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
await requireAdmin(ctx);
const story = await ctx.db
.query("stories")
.withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId))
.unique();
if (!story || typeof story.legacyId !== "number") {
throw new Error(`Story ${args.legacyStoryId} not found`);
}
const approval = await ctx.db
.query("story_approval")
.withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyApprovalId))
.unique();
if (!approval) {
return null;
}
if (approval.storyId !== story._id) {
throw new Error("Approval does not belong to story");
}
await ctx.db.delete(approval._id);
const approvals = await ctx.db
.query("story_approval")
.withIndex("by_story", (q) => q.eq("storyId", story._id))
.collect();
const approvalCount = approvals.length;
const storyStatus: "draft" | "feedback" | "finished" =
approvalCount === 0
? "draft"
: approvalCount === 1
? "feedback"
: "finished";
await ctx.db.patch(story._id, {
status: storyStatus,
approvalCount,
});
return null;
},
});
================================================
FILE: convex/adminWrite.ts
================================================
import { mutation } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { v } from "convex/values";
import { requireAdmin } from "./lib/authorization";
function toLegacyLanguageResponse(row: {
legacyId: number;
name: string;
short: string;
flag?: number | string;
flag_file?: string;
speaker?: string;
default_text?: string;
tts_replace?: string;
public: boolean;
rtl: boolean;
}) {
return {
id: row.legacyId,
name: row.name,
short: row.short,
flag:
typeof row.flag === "number"
? row.flag
: Number.isFinite(Number(row.flag))
? Number(row.flag)
: 0,
flag_file: row.flag_file ?? "",
speaker: row.speaker ?? "",
default_text: row.default_text ?? "",
tts_replace: row.tts_replace ?? "",
public: row.public,
rtl: row.rtl,
};
}
async function getNextLegacyId(
ctx: MutationCtx,
table: "languages" | "courses",
) {
const rows = await ctx.db.query(table).collect();
const current = rows.reduce((max, row) => {
const legacyId = Number(row.legacyId ?? 0);
return legacyId > max ? legacyId : max;
}, 0);
return Math.max(1, current + 1);
}
async function getNextUnusedLegacyId(
ctx: MutationCtx,
table: "languages" | "courses",
) {
let candidate = await getNextLegacyId(ctx, table);
for (let attempts = 0; attempts < 1000; attempts += 1) {
const existing = await ctx.db
.query(table)
.withIndex("by_id_value", (q) => q.eq("legacyId", candidate))
.unique();
if (!existing) return candidate;
candidate += 1;
}
throw new Error(`Failed to allocate unique ${table} legacy ID`);
}
async function getLanguageByLegacyId(ctx: MutationCtx, legacyId: number) {
return await ctx.db
.query("languages")
.withIndex("by_id_value", (q) => q.eq("legacyId", legacyId))
.unique();
}
export const updateAdminLanguage = mutation({
args: {
id: v.number(),
name: v.string(),
short: v.string(),
flag: v.number(),
flag_file: v.string(),
speaker: v.string(),
rtl: v.boolean(),
operationKey: v.optional(v.string()),
},
returns: v.object({
id: v.number(),
name: v.string(),
short: v.string(),
flag: v.number(),
flag_file: v.string(),
speaker: v.string(),
default_text: v.string(),
tts_replace: v.string(),
public: v.boolean(),
rtl: v.boolean(),
}),
handler: async (ctx, args) => {
await requireAdmin(ctx);
const language = await getLanguageByLegacyId(ctx, args.id);
if (!language) {
throw new Error(`Language ${args.id} not found`);
}
const operationKey =
args.operationKey ?? `language:${args.id}:admin_set:${Date.now()}`;
await ctx.db.patch(language._id, {
name: args.name,
short: args.short,
flag: args.flag,
flag_file: args.flag_file,
speaker: args.speaker,
rtl: args.rtl,
mirrorUpdatedAt: Date.now(),
lastOperationKey: operationKey,
});
return toLegacyLanguageResponse({
...language,
name: args.name,
short: args.short,
flag: args.flag,
flag_file: args.flag_file,
speaker: args.speaker,
rtl: args.rtl,
});
},
});
export const createAdminLanguage = mutation({
args: {
name: v.string(),
short: v.string(),
flag: v.number(),
flag_file: v.string(),
speaker: v.string(),
rtl: v.boolean(),
operationKey: v.optional(v.string()),
},
returns: v.object({
id: v.number(),
name: v.string(),
short: v.string(),
flag: v.number(),
flag_file: v.string(),
speaker: v.string(),
default_text: v.string(),
tts_replace: v.string(),
public: v.boolean(),
rtl: v.boolean(),
}),
handler: async (ctx, args) => {
await requireAdmin(ctx);
const legacyId = await getNextUnusedLegacyId(ctx, "languages");
const operationKey =
args.operationKey ?? `language:${legacyId}:admin_create:${Date.now()}`;
await ctx.db.insert("languages", {
legacyId,
name: args.name,
short: args.short,
flag: args.flag,
flag_file: args.flag_file,
speaker: args.speaker,
default_text: "",
tts_replace: "",
public: false,
rtl: args.rtl,
mirrorUpdatedAt: Date.now(),
lastOperationKey: operationKey,
});
return {
id: legacyId,
name: args.name,
short: args.short,
flag: args.flag,
flag_file: args.flag_file,
speaker: args.speaker,
default_text: "",
tts_replace: "",
public: false,
rtl: args.rtl,
};
},
});
export const updateAdminCourse = mutation({
args: {
id: v.number(),
learning_language: v.number(),
from_language: v.number(),
public: v.optional(v.boolean()),
name: v.optional(v.string()),
conlang: v.optional(v.boolean()),
tags: v.optional(v.array(v.string())),
about: v.optional(v.string()),
operationKey: v.optional(v.string()),
},
returns: v.object({
id: v.number(),
learning_language: v.number(),
from_language: v.number(),
public: v.boolean(),
official: v.boolean(),
name: v.union(v.string(), v.null()),
about: v.union(v.string(), v.null()),
conlang: v.boolean(),
short: v.union(v.string(), v.null()),
tags: v.array(v.string()),
}),
handler: async (ctx, args) => {
await requireAdmin(ctx);
const [course, learningLanguage, fromLanguage] = await Promise.all([
ctx.db
.query("courses")
.withIndex("by_id_value", (q) => q.eq("legacyId", args.id))
.unique(),
getLanguageByLegacyId(ctx, args.learning_language),
getLanguageByLegacyId(ctx, args.from_language),
]);
if (!course) throw new Error(`Course ${args.id} not found`);
if (!learningLanguage || !fromLanguage) {
throw new Error("Course languages not found");
}
const short = `${learningLanguage.short}-${fromLanguage.short}`;
const operationKey =
args.operationKey ?? `course:${args.id}:admin_set:${Date.now()}`;
const nextPublic = args.public ?? course.public;
const nextName = args.name ?? course.name ?? null;
const nextConlang = args.conlang ?? course.conlang ?? false;
const nextTags = args.tags ?? course.tags ?? [];
const nextAbout = args.about ?? course.about ?? null;
const patchData: {
learningLanguageId: typeof learningLanguage._id;
fromLanguageId: typeof fromLanguage._id;
learning_language_name: string;
from_language_name: string;
short: string;
public: boolean;
mirrorUpdatedAt: number;
lastOperationKey: string;
name?: string;
conlang?: boolean;
tags?: string[];
about?: string;
} = {
learningLanguageId: learningLanguage._id,
fromLanguageId: fromLanguage._id,
learning_language_name: learningLanguage.name,
from_language_name: fromLanguage.name,
short,
public: nextPublic,
mirrorUpdatedAt: Date.now(),
lastOperationKey: operationKey,
};
if (nextName !== null) patchData.name = nextName;
if (nextAbout !== null) patchData.about = nextAbout;
patchData.conlang = nextConlang;
patchData.tags = nextTags;
await ctx.db.patch(course._id, patchData);
return {
id: args.id,
learning_language: args.learning_language,
from_language: args.from_language,
public: nextPublic,
official: course.official,
name: nextName,
about: nextAbout,
conlang: nextConlang,
short,
tags: nextTags,
};
},
});
export const createAdminCourse = mutation({
args: {
learning_language: v.number(),
from_language: v.number(),
public: v.optional(v.boolean()),
name: v.optional(v.string()),
official: v.optional(v.number()),
conlang: v.optional(v.boolean()),
tags: v.optional(v.array(v.string())),
about: v.optional(v.string()),
operationKey: v.optional(v.string()),
},
returns: v.object({
id: v.number(),
learning_language: v.number(),
from_language: v.number(),
public: v.boolean(),
official: v.boolean(),
name: v.union(v.string(), v.null()),
about: v.union(v.string(), v.null()),
conlang: v.boolean(),
short: v.union(v.string(), v.null()),
tags: v.array(v.string()),
}),
handler: async (ctx, args) => {
await requireAdmin(ctx);
const [learningLanguage, fromLanguage] = await Promise.all([
getLanguageByLegacyId(ctx, args.learning_language),
getLanguageByLegacyId(ctx, args.from_language),
]);
if (!learningLanguage || !fromLanguage) {
throw new Error("Course languages not found");
}
const legacyId = await getNextUnusedLegacyId(ctx, "courses");
const short = `${learningLanguage.short}-${fromLanguage.short}`;
const nextPublic = args.public ?? false;
const nextOfficial = args.official ?? 0;
const nextName = args.name ?? null;
const nextConlang = args.conlang ?? false;
const nextTags = args.tags ?? [];
const nextAbout = args.about ?? null;
const operationKey =
args.operationKey ?? `course:${legacyId}:admin_create:${Date.now()}`;
await ctx.db.insert("courses", {
legacyId,
short,
learningLanguageId: learningLanguage._id,
fromLanguageId: fromLanguage._id,
public: nextPublic,
official: nextOfficial !== 0,
name: nextName ?? undefined,
about: nextAbout ?? undefined,
conlang: nextConlang,
tags: nextTags,
learning_language_name: learningLanguage.name,
from_language_name: fromLanguage.name,
mirrorUpdatedAt: Date.now(),
lastOperationKey: operationKey,
});
return {
id: legacyId,
learning_language: args.learning_language,
from_language: args.from_language,
public: nextPublic,
official: nextOfficial !== 0,
name: nextName,
about: nextAbout,
conlang: nextConlang,
short,
tags: nextTags,
};
},
});
================================================
FILE: convex/audioRead.ts
================================================
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getSpeakerByName = query({
args: {
speaker: v.string(),
},
returns: v.union(
v.object({
id: v.number(),
speaker: v.string(),
type: v.string(),
gender: v.optional(v.string()),
service: v.optional(v.string()),
}),
v.null(),
),
handler: async (ctx, args) => {
const row = await ctx.db
.query("speakers")
.withIndex("by_speaker", (q) => q.eq("speaker", args.speaker))
.unique();
if (!row) return null;
return {
id: row.legacyId ?? 0,
speaker: row.speaker,
type: row.type,
gender: row.gender,
service: row.service,
};
},
});
================================================
FILE: convex/auth.config.ts
================================================
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig;
================================================
FILE: convex/auth.ts
================================================
import { authComponent } from "./betterAuth/auth";
import { query } from "./_generated/server";
import { v } from "convex/values";
import { components } from "./_generated/api";
import { requireContributorOrAdmin } from "./lib/authorization";
const authClientApi = authComponent.clientApi();
export const getAuthUser = authClientApi.getAuthUser;
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return {
...identity,
role: identity.role ?? "user",
};
},
});
export const getLinkedProvidersForCurrentUser = query({
args: {},
handler: async (ctx) => {
const identity = (await ctx.auth.getUserIdentity()) as {
email?: string | null;
} | null;
const email = identity?.email?.toLowerCase();
if (!email) return [] as string[];
const authUser = await ctx.runQuery(components.betterAuth.adapter.findOne, {
model: "user",
where: [{ field: "email", value: email }],
});
if (!authUser?._id) return [] as string[];
const accounts = await ctx.runQuery(
components.betterAuth.adapter.findMany,
{
model: "account",
where: [{ field: "userId", value: authUser._id }],
paginationOpts: { cursor: null, numItems: 100 },
},
);
const providers = (accounts.page as Array<{ providerId?: string | null }>)
.map((account) => account.providerId)
.filter((provider): provider is string => Boolean(provider));
return Array.from(new Set(providers));
},
});
export const getUserNamesByLegacyIds = query({
args: {
legacyIds: v.array(v.number()),
},
handler: async (ctx, args) => {
await requireContributorOrAdmin(ctx);
const uniqueIds = Array.from(new Set(args.legacyIds));
if (!uniqueIds.length)
return [] as Array<{ legacyId: number; name: string }>;
const userIds = uniqueIds.map((id) => String(id));
const users = await ctx.runQuery(components.betterAuth.adapter.findMany, {
model: "user",
where: [{ field: "userId", operator: "in", value: userIds }],
paginationOpts: { cursor: null, numItems: userIds.length + 10 },
});
return (
users.page as Array<{ userId?: string | null; name?: string | null }>
)
.map((user) => {
const legacyId = Number.parseInt(user.userId ?? "", 10);
if (!Number.isFinite(legacyId) || !user.name) return null;
return { legacyId, name: user.name };
})
.filter((row): row is { legacyId: number; name: string } => row !== null);
},
});
================================================
FILE: convex/authFunctions.ts
================================================
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { components } from "./_generated/api";
export const onCreate = internalMutation({
args: {
model: v.string(),
doc: v.any(),
},
handler: async (ctx, args) => {
if (args.model !== "user") return;
const doc = args.doc as { _id: string; userId?: string | null };
if (doc.userId) return;
let maxId = 0;
let cursor: string | null = null;
do {
const page = (await ctx.runQuery(
components.betterAuth.adapter.findMany as any,
{
model: "user",
where: [],
paginationOpts: { cursor, numItems: 1000 },
},
)) as any;
for (const user of page.page as Array<{ userId?: string | null }>) {
const parsed = Number.parseInt(user.userId ?? "", 10);
if (!Number.isNaN(parsed) && parsed > maxId) maxId = parsed;
}
cursor = page.isDone ? null : (page.continueCursor ?? null);
} while (cursor);
const nextId = maxId + 1;
await ctx.runMutation(components.betterAuth.adapter.updateOne, {
input: {
model: "user",
where: [{ field: "_id", value: doc._id }],
update: { userId: String(nextId) },
},
});
},
});
================================================
FILE: convex/authMigration.ts
================================================
import { action, query } from "./_generated/server";
import { v } from "convex/values";
import { api, components } from "./_generated/api";
const PAGE_SIZE = 200;
export const listLegacyUsersForRoleSync = query({
args: {
cursor: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async () => {
// Legacy users table has been removed from app schema.
return {
page: [],
isDone: true,
continueCursor: null,
};
},
});
export const syncBetterAuthRoles = action({
args: {
dryRun: v.optional(v.boolean()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const dryRun = args.dryRun ?? false;
const limit = args.limit ?? PAGE_SIZE;
let cursor: string | undefined = undefined;
let total = 0;
let updated = 0;
let skipped = 0;
let missing = 0;
while (true) {
const pageResult = (await ctx.runQuery(
api.authMigration.listLegacyUsersForRoleSync,
{ cursor: cursor ?? undefined, limit },
)) as {
page: Array<{ email?: string; admin?: boolean; role?: boolean }>;
isDone: boolean;
continueCursor?: string | null;
};
for (const legacyUser of pageResult.page) {
total += 1;
if (!legacyUser.email) {
skipped += 1;
continue;
}
let desiredRole: string | null = null;
if (legacyUser.admin) {
desiredRole = "admin";
} else if (legacyUser.role) {
desiredRole = "contributor";
}
if (!desiredRole) {
skipped += 1;
continue;
}
const authUser = await ctx.runQuery(
components.betterAuth.adapter.findOne,
{
model: "user",
where: [
{
field: "email",
value: legacyUser.email.toLowerCase(),
},
],
},
);
if (!authUser) {
missing += 1;
continue;
}
const authRole = Array.isArray(authUser.role)
? authUser.role[0]
: authUser.role;
if (authRole === desiredRole) {
skipped += 1;
continue;
}
if (!dryRun) {
await ctx.runMutation(components.betterAuth.adapter.updateOne, {
input: {
model: "user",
where: [
{
field: "_id",
value: (authUser as { _id: string })._id,
},
],
update: { role: desiredRole } as any,
},
});
}
updated += 1;
}
if (pageResult.isDone) {
break;
}
cursor = pageResult.continueCursor ?? undefined;
}
return {
dryRun,
total,
updated,
skipped,
missing,
};
},
});
function normalizeUsername(input: string): string {
const lower = input.trim().toLowerCase();
const cleaned = lower.replace(/[^a-z0-9_-]/g, "_");
if (cleaned.length >= 3) return cleaned;
return `${cleaned || "user"}_${Math.random().toString(36).slice(2, 6)}`;
}
export const migrateLegacyUsersToBetterAuth = action({
args: {
dryRun: v.optional(v.boolean()),
limit: v.optional(v.number()),
cursor: v.optional(v.string()),
},
handler: async (ctx, args) => {
const dryRun = args.dryRun ?? false;
const limit = args.limit ?? PAGE_SIZE;
const cursor = args.cursor ?? undefined;
let total = 0;
let created = 0;
let skipped = 0;
let missingPassword = 0;
let accountsCreated = 0;
let accountsFailed = 0;
const errors: Array<{
email?: string;
legacyId?: number;
step: string;
message: string;
}> = [];
const pageResult = (await ctx.runQuery(
api.authMigration.listLegacyUsersForRoleSync,
{ cursor, limit },
)) as {
page: Array<{
legacyId?: number;
name?: string;
email?: string;
image?: string;
emailVerified?: number;
password?: string;
regdate?: number;
}>;
isDone: boolean;
continueCursor?: string | null;
};
for (const legacyUser of pageResult.page) {
total += 1;
if (!legacyUser.email) {
skipped += 1;
continue;
}
const email = legacyUser.email.toLowerCase();
const existing = await ctx.runQuery(
components.betterAuth.adapter.findOne,
{
model: "user",
where: [
{
field: "email",
value: email,
},
],
},
);
if (existing) {
skipped += 1;
continue;
}
const createdAt = (() => {
if (typeof legacyUser.regdate !== "number") {
return Date.now();
}
return legacyUser.regdate > 1_000_000_000_000
? legacyUser.regdate
: legacyUser.regdate * 1000;
})();
const displayUsername = legacyUser.name || email.split("@")[0] || email;
const baseUsername = normalizeUsername(displayUsername);
const suffix = legacyUser.legacyId
? `_${legacyUser.legacyId}`
: `_${Math.random().toString(36).slice(2, 10)}`;
const username = `${baseUsername}${suffix}`;
if (!dryRun) {
let newUser: { _id: string } | null | undefined = undefined;
try {
newUser = await ctx.runMutation(
components.betterAuth.adapter.create,
{
input: {
model: "user",
data: {
createdAt,
updatedAt: createdAt,
email,
emailVerified: Boolean(legacyUser.emailVerified),
name: displayUsername,
image: legacyUser.image ?? null,
username,
displayUsername,
},
},
},
);
} catch (error: any) {
errors.push({
email,
legacyId: legacyUser.legacyId,
step: "createUser",
message: String(error?.message || error),
});
}
const newUserId =
(newUser as { _id?: string } | null | undefined)?._id ??
(
await ctx.runQuery(components.betterAuth.adapter.findOne, {
model: "user",
where: [{ field: "email", value: email }],
})
)?._id;
if (!newUserId) {
skipped += 1;
continue;
}
if (legacyUser.password) {
try {
await ctx.runMutation(components.betterAuth.adapter.create, {
input: {
model: "account",
data: {
accountId: newUserId,
providerId: "credential",
password: legacyUser.password,
userId: newUserId,
createdAt,
updatedAt: createdAt,
},
},
});
} catch (error: any) {
errors.push({
email,
legacyId: legacyUser.legacyId,
step: "createAccount",
message: String(error?.message || error),
});
}
} else {
missingPassword += 1;
}
} else if (!legacyUser.password) {
missingPassword += 1;
}
created += 1;
}
return {
dryRun,
total,
created,
skipped,
missingPassword,
errors,
continueCursor: pageResult.continueCursor ?? undefined,
isDone: pageResult.isDone,
};
},
});
const betterAuthUserInput = v.object({
legacyId: v.number(),
email: v.string(),
name: v.string(),
username: v.string(),
displayUsername: v.string(),
image: v.optional(v.union(v.null(), v.string())),
emailVerified: v.optional(v.boolean()),
createdAt: v.number(),
password: v.optional(v.string()),
});
const betterAuthAccountInput = v.object({
legacyAccountId: v.number(),
legacyUserId: v.number(),
providerId: v.string(),
providerAccountId: v.optional(v.union(v.null(), v.string())),
accessToken: v.optional(v.string()),
refreshToken: v.optional(v.string()),
expiresAt: v.optional(v.number()),
tokenType: v.optional(v.string()),
scope: v.optional(v.string()),
idToken: v.optional(v.string()),
sessionState: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
accountType: v.optional(v.string()),
});
export const importBetterAuthUsersBatch = action({
args: {
users: v.array(betterAuthUserInput),
verbose: v.optional(v.boolean()),
fastPath: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const users = args.users;
const verbose = args.verbose ?? false;
const fastPath = args.fastPath ?? false;
if (verbose) {
console.log(
`[importBetterAuthUsersBatch] received ${users.length} users`,
users[0]?.email ? `first=${users[0].email}` : "",
);
}
let created = 0;
let skipped = 0;
let missingPassword = 0;
let accountsCreated = 0;
let accountsFailed = 0;
const errors: Array<{
email: string;
legacyId: number;
step: string;
message: string;
}> = [];
const existingEmails = new Set<string>();
for (const user of users) {
const email = user.email.toLowerCase();
if (existingEmails.has(email)) {
skipped += 1;
continue;
}
existingEmails.add(email);
let newUserId: string | undefined;
let didCreate = false;
try {
const newUser = await ctx.runMutation(
components.betterAuth.adapter.create,
{
input: {
model: "user",
data: {
createdAt: user.createdAt,
updatedAt: user.createdAt,
email,
emailVerified: Boolean(user.emailVerified),
name: user.name,
image: user.image ?? null,
username: user.username,
displayUsername: user.displayUsername,
userId: String(user.legacyId),
},
},
},
);
if (verbose) {
console.log(
`[importBetterAuthUsersBatch] create user email=${email} id=${(newUser as { _id?: string } | null)?._id ?? "null"}`,
);
}
newUserId = (newUser as { _id?: string } | null)?._id ?? undefined;
didCreate = Boolean(newUserId);
} catch (error: any) {
const message = String(error?.message || error);
if (verbose) {
console.log(
`[importBetterAuthUsersBatch] create user error email=${email} message=${message}`,
);
}
if (message.includes("email already exists")) {
skipped += 1;
if (fastPath) {
continue;
}
let existing = await ctx.runQuery(
components.betterAuth.adapter.findOne,
{
model: "user",
where: [
{
field: "email",
value: email,
},
],
},
);
if (!existing?._id) {
existing = await ctx.runQuery(
components.betterAuth.adapter.findOne,
{
model: "user",
where: [
{
field: "userId",
value: String(user.legacyId),
},
],
},
);
}
if (existing?._id) {
newUserId = existing._id;
const existingUserId =
(existing as { userId?: string | null }).userId ?? null;
if (!existingUserId) {
try {
await ctx.runMutation(components.betterAuth.adapter.updateOne, {
input: {
model: "user",
where: [
{
field: "_id",
value: existing._id,
},
],
update: { userId: String(user.legacyId) } as any,
},
});
} catch (updateError: any) {
errors.push({
email,
legacyId: user.legacyId,
step: "updateExistingUserId",
message: String(updateError?.message || updateError),
});
}
}
}
if (!newUserId) {
errors.push({
email,
legacyId: user.legacyId,
step: "findExistingUser",
message: "User exists but could not be fetched by email/userId.",
});
}
} else {
errors.push({
email,
legacyId: user.legacyId,
step: "createUser",
message,
});
}
if (!newUserId) {
continue;
}
}
if (!newUserId) {
continue;
}
if (user.password) {
try {
if (!fastPath) {
const existingAccount = await ctx.runQuery(
components.betterAuth.adapter.findOne,
{
model: "account",
where: [
{
field: "userId",
value: newUserId,
},
{
field: "providerId",
value: "credential",
},
],
},
);
if (existingAccount) {
continue;
}
}
await ctx.runMutation(components.betterAuth.adapter.create, {
input: {
model: "account",
data: {
accountId: newUserId,
providerId: "credential",
password: user.password,
userId: newUserId,
createdAt: user.createdAt,
updatedAt: user.createdAt,
},
},
});
accountsCreated += 1;
} catch (error: any) {
const message = String(error?.message || error);
if (fastPath && message.includes("already exists")) {
continue;
}
accountsFailed += 1;
errors.push({
email,
legacyId: user.legacyId,
step: "createAccount",
message,
});
}
} else {
missingPassword += 1;
}
if (didCreate) {
created += 1;
}
}
return {
created,
skipped,
missingPassword,
accountsCreated,
accountsFailed,
errors,
};
},
});
export const importBetterAuthAccountsBatch = action({
args: {
accounts: v.array(betterAuthAccountInput),
verbose: v.optional(v.boolean()),
fastPath: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const accounts = args.accounts;
const verbose = args.verbose ?? false;
const fastPath = args.fastPath ?? false;
if (verbose) {
console.log(
`[importBetterAuthAccountsBatch] received ${accounts.length} accounts`,
);
}
let created = 0;
let skipped = 0;
let missingUser = 0;
const errors: Array<{
providerId: string;
legacyUserId: number;
legacyAccountId: number;
step: string;
message: string;
}> = [];
for (const account of accounts) {
if (!account.providerAccountId) {
skipped += 1;
continue;
}
let authUser = await ctx.runQuery(components.betterAuth.adapter.findOne, {
model: "user",
where: [
{
field: "userId",
value: String(account.legacyUserId),
},
],
});
if (!authUser?._id) {
missingUser += 1;
continue;
}
const authUserId = (authUser as { _id: string })._id;
if (!fastPath) {
const existingAccount = await ctx.runQuery(
components.betterAuth.adapter.findOne,
{
model: "account",
where: [
{
field: "userId",
value: authUserId,
},
{
field: "providerId",
value: account.providerId,
},
...(account.providerAccountId
? [
{
field: "accountId",
value: account.providerAccountId,
},
]
: []),
],
},
);
if (existingAccount) {
skipped += 1;
continue;
}
}
try {
await ctx.runMutation(components.betterAuth.adapter.create, {
input: {
model: "account",
data: {
userId: authUserId,
providerId: account.providerId,
accountId: account.providerAccountId,
accessToken: account.accessToken,
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.expiresAt,
scope: account.scope,
idToken: account.idToken,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
} as any,
},
});
created += 1;
} catch (error: any) {
const message = String(error?.message || error);
if (fastPath && message.includes("already exists")) {
skipped += 1;
continue;
}
errors.push({
providerId: account.providerId,
legacyUserId: account.legacyUserId,
legacyAccountId: account.legacyAccountId,
step: "createAccount",
message,
});
}
}
return { created, skipped, missingUser, errors };
},
});
export const clearBetterAuthData = action({
args: {
confirm: v.boolean(),
},
handler: async (ctx, args) => {
if (!args.confirm) {
throw new Error("clearBetterAuthData requires confirm=true");
}
const models: Array<
"session" | "account" | "verification" | "jwks" | "user"
> = ["session", "account", "verification", "jwks", "user"];
for (const model of models) {
while (true) {
const result = (await ctx.runMutation(
components.betterAuth.adapter.deleteMany,
{
input: { model },
paginationOpts: {
cursor: null,
numItems: 1000,
},
},
)) as { count?: number };
if (!result?.count) {
break;
}
}
}
return { ok: true };
},
});
export const debugBetterAuthAccount = action({
args: {
email: v.string(),
},
handler: async (ctx, args) => {
const email = args.email.toLowerCase();
const user = await ctx.runQuery(components.betterAuth.adapter.findOne, {
model: "user",
where: [{ field: "email", value: email }],
});
if (!user?._id) {
return { foundUser: false, foundAccount: false };
}
const account = await ctx.runQuery(components.betterAuth.adapter.findOne, {
model: "account",
where: [
{ field: "userId", value: user._id },
{ field: "providerId", value: "credential" },
],
});
const hash =
(account as { password?: string | null } | null)?.password ?? null;
return {
foundUser: true,
foundAccount: Boolean(account),
hashPrefix: hash ? hash.slice(0, 4) : null,
hashLength: hash ? hash.length : null,
};
},
});
================================================
FILE: convex/betterAuth/_generated/api.ts
================================================
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as adapter from "../adapter.js";
import type * as auth from "../auth.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
import { anyApi, componentsGeneric } from "convex/server";
const fullApi: ApiFromModules<{
adapter: typeof adapter;
auth: typeof auth;
}> = anyApi as any;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
> = anyApi as any;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
> = anyApi as any;
export const components = componentsGeneric() as unknown as {};
================================================
FILE: convex/betterAuth/_generated/component.ts
================================================
/* eslint-disable */
/**
* Generated `ComponentApi` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type { FunctionReference } from "convex/server";
/**
* A utility for referencing a Convex component's exposed API.
*
* Useful when expecting a parameter like `components.myComponent`.
* Usage:
* ```ts
* async function myFunction(ctx: QueryCtx, component: ComponentApi) {
* return ctx.runQuery(component.someFile.someQuery, { ...args });
* }
* ```
*/
export type ComponentApi<Name extends string | undefined = string | undefined> =
{
adapter: {
create: FunctionReference<
"mutation",
"internal",
{
input:
| {
data: {
banExpires?: null | number;
banReason?: null | string;
banned?: null | boolean;
createdAt: number;
displayUsername?: null | string;
email: string;
emailVerified: boolean;
image?: null | string;
name: string;
role?: null | string;
updatedAt: number;
userId?: null | string;
username?: null | string;
};
model: "user";
}
| {
data: {
createdAt: number;
expiresAt: number;
impersonatedBy?: null | string;
ipAddress?: null | string;
token: string;
updatedAt: number;
userAgent?: null | string;
userId: string;
};
model: "session";
}
| {
data: {
accessToken?: null | string;
accessTokenExpiresAt?: null | number;
accountId: string;
createdAt: number;
idToken?: null | string;
password?: null | string;
providerId: string;
refreshToken?: null | string;
refreshTokenExpiresAt?: null | number;
scope?: null | string;
updatedAt: number;
userId: string;
};
model: "account";
}
| {
data: {
createdAt: number;
expiresAt: number;
identifier: string;
updatedAt: number;
value: string;
};
model: "verification";
}
| {
data: {
createdAt: number;
expiresAt?: null | number;
privateKey: string;
publicKey: string;
};
model: "jwks";
};
onCreateHandle?: string;
select?: Array<string>;
},
any,
Name
>;
deleteMany: FunctionReference<
"mutation",
"internal",
{
input:
| {
model: "user";
where?: Array<{
connector?: "AND" | "OR";
field:
| "name"
| "email"
| "emailVerified"
| "image"
| "createdAt"
| "updatedAt"
| "userId"
| "username"
| "displayUsername"
| "role"
| "banned"
| "banReason"
| "banExpires"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "session";
where?: Array<{
connector?: "AND" | "OR";
field:
| "expiresAt"
| "token"
| "createdAt"
| "updatedAt"
| "ipAddress"
| "userAgent"
| "userId"
| "impersonatedBy"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "account";
where?: Array<{
connector?: "AND" | "OR";
field:
| "accountId"
| "providerId"
| "userId"
| "accessToken"
| "refreshToken"
| "idToken"
| "accessTokenExpiresAt"
| "refreshTokenExpiresAt"
| "scope"
| "password"
| "createdAt"
| "updatedAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "verification";
where?: Array<{
connector?: "AND" | "OR";
field:
| "identifier"
| "value"
| "expiresAt"
| "createdAt"
| "updatedAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "jwks";
where?: Array<{
connector?: "AND" | "OR";
field:
| "publicKey"
| "privateKey"
| "createdAt"
| "expiresAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
};
onDeleteHandle?: string;
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
},
any,
Name
>;
deleteOne: FunctionReference<
"mutation",
"internal",
{
input:
| {
model: "user";
where?: Array<{
connector?: "AND" | "OR";
field:
| "name"
| "email"
| "emailVerified"
| "image"
| "createdAt"
| "updatedAt"
| "userId"
| "username"
| "displayUsername"
| "role"
| "banned"
| "banReason"
| "banExpires"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "session";
where?: Array<{
connector?: "AND" | "OR";
field:
| "expiresAt"
| "token"
| "createdAt"
| "updatedAt"
| "ipAddress"
| "userAgent"
| "userId"
| "impersonatedBy"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "account";
where?: Array<{
connector?: "AND" | "OR";
field:
| "accountId"
| "providerId"
| "userId"
| "accessToken"
| "refreshToken"
| "idToken"
| "accessTokenExpiresAt"
| "refreshTokenExpiresAt"
| "scope"
| "password"
| "createdAt"
| "updatedAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "verification";
where?: Array<{
connector?: "AND" | "OR";
field:
| "identifier"
| "value"
| "expiresAt"
| "createdAt"
| "updatedAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "jwks";
where?: Array<{
connector?: "AND" | "OR";
field:
| "publicKey"
| "privateKey"
| "createdAt"
| "expiresAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
};
onDeleteHandle?: string;
},
any,
Name
>;
findMany: FunctionReference<
"query",
"internal",
{
join?: any;
limit?: number;
model: "user" | "session" | "account" | "verification" | "jwks";
offset?: number;
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
select?: Array<string>;
sortBy?: { direction: "asc" | "desc"; field: string };
where?: Array<{
connector?: "AND" | "OR";
field: string;
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
},
any,
Name
>;
findOne: FunctionReference<
"query",
"internal",
{
join?: any;
model: "user" | "session" | "account" | "verification" | "jwks";
select?: Array<string>;
where?: Array<{
connector?: "AND" | "OR";
field: string;
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
},
any,
Name
>;
get: FunctionReference<"query", "internal", { id: string }, any, Name>;
searchUsersAll: FunctionReference<
"query",
"internal",
{
activatedFilter: "all" | "yes" | "no";
limit: number;
roleFilter: "all" | "user" | "contributor" | "admin";
},
any,
Name
>;
searchUsersByEmailPrefix: FunctionReference<
"query",
"internal",
{
activatedFilter: "all" | "yes" | "no";
limit: number;
prefix: string;
roleFilter: "all" | "user" | "contributor" | "admin";
},
any,
Name
>;
searchUsersById: FunctionReference<
"query",
"internal",
{
activatedFilter: "all" | "yes" | "no";
id: string;
roleFilter: "all" | "user" | "contributor" | "admin";
},
any,
Name
>;
searchUsersByUsernamePrefix: FunctionReference<
"query",
"internal",
{
activatedFilter: "all" | "yes" | "no";
limit: number;
prefix: string;
roleFilter: "all" | "user" | "contributor" | "admin";
},
any,
Name
>;
updateMany: FunctionReference<
"mutation",
"internal",
{
input:
| {
model: "user";
update: {
banExpires?: null | number;
banReason?: null | string;
banned?: null | boolean;
createdAt?: number;
displayUsername?: null | string;
email?: string;
emailVerified?: boolean;
image?: null | string;
name?: string;
role?: null | string;
updatedAt?: number;
userId?: null | string;
username?: null | string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "name"
| "email"
| "emailVerified"
| "image"
| "createdAt"
| "updatedAt"
| "userId"
| "username"
| "displayUsername"
| "role"
| "banned"
| "banReason"
| "banExpires"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "session";
update: {
createdAt?: number;
expiresAt?: number;
impersonatedBy?: null | string;
ipAddress?: null | string;
token?: string;
updatedAt?: number;
userAgent?: null | string;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "expiresAt"
| "token"
| "createdAt"
| "updatedAt"
| "ipAddress"
| "userAgent"
| "userId"
| "impersonatedBy"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "account";
update: {
accessToken?: null | string;
accessTokenExpiresAt?: null | number;
accountId?: string;
createdAt?: number;
idToken?: null | string;
password?: null | string;
providerId?: string;
refreshToken?: null | string;
refreshTokenExpiresAt?: null | number;
scope?: null | string;
updatedAt?: number;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "accountId"
| "providerId"
| "userId"
| "accessToken"
| "refreshToken"
| "idToken"
| "accessTokenExpiresAt"
| "refreshTokenExpiresAt"
| "scope"
| "password"
| "createdAt"
| "updatedAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "verification";
update: {
createdAt?: number;
expiresAt?: number;
identifier?: string;
updatedAt?: number;
value?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "identifier"
| "value"
| "expiresAt"
| "createdAt"
| "updatedAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "jwks";
update: {
createdAt?: number;
expiresAt?: null | number;
privateKey?: string;
publicKey?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "publicKey"
| "privateKey"
| "createdAt"
| "expiresAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
};
onUpdateHandle?: string;
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
},
any,
Name
>;
updateOne: FunctionReference<
"mutation",
"internal",
{
input:
| {
model: "user";
update: {
banExpires?: null | number;
banReason?: null | string;
banned?: null | boolean;
createdAt?: number;
displayUsername?: null | string;
email?: string;
emailVerified?: boolean;
image?: null | string;
name?: string;
role?: null | string;
updatedAt?: number;
userId?: null | string;
username?: null | string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "name"
| "email"
| "emailVerified"
| "image"
| "createdAt"
| "updatedAt"
| "userId"
| "username"
| "displayUsername"
| "role"
| "banned"
| "banReason"
| "banExpires"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "session";
update: {
createdAt?: number;
expiresAt?: number;
impersonatedBy?: null | string;
ipAddress?: null | string;
token?: string;
updatedAt?: number;
userAgent?: null | string;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "expiresAt"
| "token"
| "createdAt"
| "updatedAt"
| "ipAddress"
| "userAgent"
| "userId"
| "impersonatedBy"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "account";
update: {
accessToken?: null | string;
accessTokenExpiresAt?: null | number;
accountId?: string;
createdAt?: number;
idToken?: null | string;
password?: null | string;
providerId?: string;
refreshToken?: null | string;
refreshTokenExpiresAt?: null | number;
scope?: null | string;
updatedAt?: number;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "accountId"
| "providerId"
| "userId"
| "accessToken"
| "refreshToken"
| "idToken"
| "accessTokenExpiresAt"
| "refreshTokenExpiresAt"
| "scope"
| "password"
| "createdAt"
| "updatedAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "verification";
update: {
createdAt?: number;
expiresAt?: number;
identifier?: string;
updatedAt?: number;
value?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "identifier"
| "value"
| "expiresAt"
| "createdAt"
| "updatedAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "jwks";
update: {
createdAt?: number;
expiresAt?: null | number;
privateKey?: string;
publicKey?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "publicKey"
| "privateKey"
| "createdAt"
| "expiresAt"
| "_id";
mode?: "sensitive" | "insensitive";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
};
onUpdateHandle?: string;
},
any,
Name
>;
};
};
================================================
FILE: convex/betterAuth/_generated/dataModel.ts
================================================
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
================================================
FILE: convex/betterAuth/_generated/server.ts
================================================
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query: QueryBuilder<DataModel, "public"> = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery: QueryBuilder<DataModel, "internal"> =
internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation: MutationBuilder<DataModel, "public"> = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation: MutationBuilder<DataModel, "internal"> =
internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action: ActionBuilder<DataModel, "public"> = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction: ActionBuilder<DataModel, "internal"> =
internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction: HttpActionBuilder = httpActionGeneric;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*
* If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
================================================
FILE: convex/betterAuth/adapter.ts
================================================
import { createApi } from "@convex-dev/better-auth";
import { createAuthOptions } from "./auth";
import schema from "./schema";
import { query } from "./_generated/server";
import { v } from "convex/values";
const activatedFilterValidator = v.union(
v.literal("all"),
v.literal("yes"),
v.literal("no"),
);
const roleFilterValidator = v.union(
v.literal("all"),
v.literal("user"),
v.literal("contributor"),
v.literal("admin"),
);
export const get = query({
args: {
id: v.id("user"),
},
handler: async (ctx, args) => {
return await ctx.db.get("user", args.id);
},
});
export const searchUsersById = query({
args: {
activatedFilter: activatedFilterValidator,
roleFilter: roleFilterValidator,
id: v.string(),
},
handler: async (ctx, args) => {
let query = ctx.db
.query("user")
.withIndex("userId", (q) => q.eq("userId", args.id));
if (args.roleFilter === "admin") {
query = query.filter((q) => q.eq(q.field("role"), "admin"));
} else if (args.roleFilter === "contributor") {
query = query.filter((q) => q.eq(q.field("role"), "contributor"));
} else if (args.roleFilter === "user") {
query = query.filter((q) =>
q.and(
q.neq(q.field("role"), "admin"),
q.neq(q.field("role"), "contributor"),
),
);
}
if (args.activatedFilter === "yes") {
query = query.filter((q) => q.eq(q.field("emailVerified"), true));
} else if (args.activatedFilter === "no") {
query = query.filter((q) => q.eq(q.field("emailVerified"), false));
}
return query.take(1);
},
});
export const searchUsersByEmailPrefix = query({
args: {
activatedFilter: activatedFilterValidator,
roleFilter: roleFilterValidator,
limit: v.number(),
prefix: v.string(),
},
handler: async (ctx, args) => {
const search = args.prefix.trim();
if (search.length === 0) return [];
let query = ctx.db.query("user").withSearchIndex("search_email", (q) => {
let built = q.search("email", search);
if (args.roleFilter === "admin" || args.roleFilter === "contributor") {
built = built.eq("role", args.roleFilter);
}
if (args.activatedFilter === "yes") {
built = built.eq("emailVerified", true);
} else if (args.activatedFilter === "no") {
built = built.eq("emailVerified", false);
}
return built;
});
if (args.roleFilter === "user") {
query = query.filter((q) =>
q.and(
q.neq(q.field("role"), "admin"),
q.neq(q.field("role"), "contributor"),
),
);
}
return query.take(args.limit);
},
});
export const searchUsersByUsernamePrefix = query({
args: {
activatedFilter: activatedFilterValidator,
roleFilter: roleFilterValidator,
limit: v.number(),
prefix: v.string(),
},
handler: async (ctx, args) => {
const search = args.prefix.trim();
if (search.length === 0) return [];
let query = ctx.db.query("user").withSearchIndex("search_username", (q) => {
let built = q.search("username", search);
if (args.roleFilter === "admin" || args.roleFilter === "contributor") {
built = built.eq("role", args.roleFilter);
}
if (args.activatedFilter === "yes") {
built = built.eq("emailVerified", true);
} else if (args.activatedFilter === "no") {
built = built.eq("emailVerified", false);
}
return built;
});
if (args.roleFilter === "user") {
query = query.filter((q) =>
q.and(
q.neq(q.field("role"), "admin"),
q.neq(q.field("role"), "contributor"),
),
);
}
return query.take(args.limit);
},
});
export const searchUsersAll = query({
args: {
activatedFilter: activatedFilterValidator,
roleFilter: roleFilterValidator,
limit: v.number(),
},
handler: async (ctx, args) => {
if (args.roleFilter === "admin" || args.roleFilter === "contributor") {
let query = ctx.db
.query("user")
.withIndex("role", (q) => q.eq("role", args.roleFilter))
.order("desc");
if (args.activatedFilter === "yes") {
query = query.filter((q) => q.eq(q.field("emailVerified"), true));
} else if (args.activatedFilter === "no") {
query = query.filter((q) => q.eq(q.field("emailVerified"), false));
}
return query.take(args.limit);
}
let query = ctx.db.query("user").order("desc");
if (args.roleFilter === "user") {
query = query.filter((q) =>
q.and(
q.neq(q.field("role"), "admin"),
q.neq(q.field("role"), "contributor"),
),
);
}
if (args.activatedFilter === "yes") {
query = query.filter((q) => q.eq(q.field("emailVerified"), true));
} else if (args.activatedFilter === "no") {
query = query.filter((q) => q.eq(q.field("emailVerified"), false));
}
return query.take(args.limit);
},
});
export const {
create,
findOne,
findMany,
updateOne,
updateMany,
deleteOne,
deleteMany,
} = createApi(schema, createAuthOptions);
================================================
FILE: convex/betterAuth/auth.ts
================================================
import { createClient } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import type { GenericCtx } from "@convex-dev/better-auth/utils";
import type { BetterAuthOptions } from "better-auth";
import type { DataModel } from "./_generated/dataModel";
import { betterAuth } from "better-auth";
import { admin, username } from "better-auth/plugins";
import { defaultRoles, userAc } from "better-auth/plugins/admin/access";
import { components, internal } from "../_generated/api";
import authConfig from "../auth.config";
import { syncDiscordAvatarFromAccount } from "../lib/discordAvatarSync";
import { phpbbCheckHash, phpbbHash } from "../lib/phpbb";
import schema from "./schema";
const typedCreateClient = createClient<DataModel, typeof schema>;
const getEnv = (...keys: string[]) =>
keys.map((key) => process.env[key]).find((value) => value);
const getSocialProvider = (idKeys: string[], secretKeys: string[]) => {
const clientId = getEnv(...idKeys);
const clientSecret = getEnv(...secretKeys);
if (!clientId || !clientSecret) return undefined;
return { clientId, clientSecret };
};
const socialProviders = Object.fromEntries(
Object.entries({
github: getSocialProvider(
["GITHUB_CLIENT_ID", "GITHUB_ID", "AUTH_GITHUB_ID"],
["GITHUB_CLIENT_SECRET", "GITHUB_SECRET", "AUTH_GITHUB_SECRET"],
),
google: getSocialProvider(
["GOOGLE_CLIENT_ID", "AUTH_GOOGLE_ID"],
["GOOGLE_CLIENT_SECRET", "AUTH_GOOGLE_SECRET"],
),
discord: getSocialProvider(
["DISCORD_CLIENT_ID", "AUTH_DISCORD_CLIENT_ID"],
["DISCORD_CLIENT_SECRET", "AUTH_DISCORD_CLIENT_SECRET"],
),
facebook: getSocialProvider(
["FACEBOOK_CLIENT_ID", "AUTH_FACEBOOK_ID"],
["FACEBOOK_CLIENT_SECRET", "AUTH_FACEBOOK_SECRET"],
),
}).filter(([, value]) => value),
);
const sendEmail = async ({
to,
subject,
html,
}: {
to: string;
subject: string;
html: string;
}) => {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error("RESEND_API_KEY is not set");
}
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "Unofficial Duolingo Stories <register@duostories.org>",
to,
subject,
html,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Resend email failed: ${response.status} ${text}`);
}
};
function normalizeExpiresAt(value: number | Date | null | undefined) {
if (typeof value === "number") return value;
if (value instanceof Date) return value.getTime();
return null;
}
async function syncDiscordAccountAvatarFromHook(
account: {
id?: string;
userId?: string | null;
providerId?: string | null;
accountId?: string | null;
accessToken?: string | null;
refreshToken?: string | null;
accessTokenExpiresAt?: number | Date | null;
scope?: string | null;
} | null,
hookContext?: {
context: {
internalAdapter: {
updateUser: (
userId: string,
update: Record<string, string | number | null>,
) => Promise<unknown>;
updateAccount: (
accountId: string,
update: Record<string, string | number | null>,
) => Promise<unknown>;
};
logger: {
error: (message: string, error?: unknown) => void;
};
};
} | null,
) {
if (!account?.userId || !account.providerId || !hookContext) {
return;
}
try {
const result = await syncDiscordAvatarFromAccount(account);
if (!result.ok) return;
if (result.imageUrl) {
await hookContext.context.internalAdapter.updateUser(account.userId, {
image: result.imageUrl,
});
}
if (!account.id) return;
const accountUpdate: Record<string, string | number | null> = {};
if (result.accessToken !== account.accessToken) {
accountUpdate.accessToken = result.accessToken;
}
if (result.refreshToken !== account.refreshToken) {
accountUpdate.refreshToken = result.refreshToken;
}
if (
result.accessTokenExpiresAt !==
normalizeExpiresAt(account.accessTokenExpiresAt)
) {
accountUpdate.accessTokenExpiresAt = result.accessTokenExpiresAt;
}
if (result.scope !== account.scope) {
accountUpdate.scope = result.scope;
}
if (Object.keys(accountUpdate).length > 0) {
await hookContext.context.internalAdapter.updateAccount(
account.id,
accountUpdate,
);
}
} catch (error) {
hookContext.context.logger.error("Failed to sync Discord avatar", error);
}
}
// Better Auth Component
export const authComponent: ReturnType<typeof typedCreateClient> =
typedCreateClient(components.betterAuth, {
local: { schema },
verbose: false,
authFunctions: {
onCreate: internal.authFunctions.onCreate,
},
triggers: {
user: {
onCreate: async () => {},
},
},
});
const authBaseUrl =
process.env.SITE_URL ??
process.env.BETTER_AUTH_URL ??
process.env.NEXTAUTH_URL;
// Better Auth Options
export const createAuthOptions = (ctx: GenericCtx<DataModel>) => {
return {
appName: "Duostories",
baseURL: authBaseUrl,
trustedOrigins: async (req) => {
const host =
req?.headers.get("x-forwarded-host") ?? req?.headers.get("host");
const proto = req?.headers.get("x-forwarded-proto") ?? "https";
const origin = host ? `${proto}://${host}` : null;
const allowed = [
"http://localhost:3000",
authBaseUrl,
"https://*-duostories-team.vercel.app",
].filter(Boolean) as string[];
if (host?.endsWith("-duostories-team.vercel.app") && origin) {
allowed.push(origin);
}
return allowed;
},
secret: process.env.BETTER_AUTH_SECRET,
socialProviders,
database: authComponent.adapter(ctx),
databaseHooks: {
account: {
create: {
after: async (account, hookContext) => {
await syncDiscordAccountAvatarFromHook(account, hookContext);
},
},
update: {
after: async (account, hookContext) => {
await syncDiscordAccountAvatarFromHook(account, hookContext);
},
},
},
},
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "[Unofficial Duolingo Stories] Reset Password",
html: `Hey ${user.name ?? "there"},<br/>
<br/>
You have requested to reset your password for 'Unofficial Duolingo Stories'.<br/>
Use the following link to reset your password.<br/>
<a href='${url}'>Reset Password</a>
<br/><br/>
Happy learning.`,
});
},
password: {
hash: async (password) => phpbbHash(password),
verify: async ({ password, hash }) => phpbbCheckHash(password, hash),
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "[Unofficial Duolingo Stories] Verify Email",
html: `Hey ${user.name ?? "there"},<br/>
<br/>
Please verify your email address by clicking the link below.<br/>
<a href='${url}'>Verify Email</a>
<br/><br/>
Happy learning.`,
});
},
},
user: {
changeEmail: {
enabled: true,
updateEmailWithoutVerification: false,
sendChangeEmailConfirmation: async ({ user, newEmail, url }) => {
await sendEmail({
to: user.email,
subject: "[Unofficial Duolingo Stories] Confirm Email Change",
html: `Hey ${user.name ?? "there"},<br/>
<br/>
You requested to change your account email to <b>${newEmail}</b>.<br/>
Confirm this change using the link below.<br/>
<a href='${url}'>Confirm Email Change</a>
<br/><br/>
If this was not you, you can ignore this email.`,
});
},
},
},
plugins: [
convex({ authConfig }),
username(),
admin({
adminRoles: ["admin"],
roles: {
...defaultRoles,
contributor: userAc,
},
}),
],
} satisfies BetterAuthOptions;
};
// For `@better-auth/cli`
const options = createAuthOptions({} as GenericCtx<DataModel>);
// Better Auth Instance
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth(createAuthOptions(ctx));
};
================================================
FILE: convex/betterAuth/convex.config.ts
================================================
import { defineComponent } from "convex/server";
const component = defineComponent("betterAuth");
export default component;
================================================
FILE: convex/betterAuth/schema.ts
================================================
/**
* This file is auto-generated. Do not edit this file manually.
* To regenerate the schema, run:
* `npx @better-auth/cli generate --output ./convex/betterAuth/schema.ts -y`
*
* To customize the schema, generate to an alternate file and import
* the table definitions to your schema file. See
* https://labs.convex.dev/better-auth/features/local-install#adding-custom-indexes.
*/
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
const tables = {
user: defineTable({
name: v.string(),
email: v.string(),
emailVerified: v.boolean(),
image: v.optional(v.union(v.null(), v.string())),
createdAt: v.number(),
updatedAt: v.number(),
userId: v.optional(v.union(v.null(), v.string())),
username: v.optional(v.union(v.null(), v.string())),
displayUsername: v.optional(v.union(v.null(), v.string())),
role: v.optional(v.union(v.null(), v.string())),
banned: v.optional(v.union(v.null(), v.boolean())),
banReason: v.optional(v.union(v.null(), v.string())),
banExpires: v.optional(v.union(v.null(), v.number())),
})
.index("email", ["email"])
.index("email_name", ["email", "name"])
.index("name", ["name"])
.index("role", ["role"])
.index("role_createdAt", ["role", "createdAt"])
.index("userId", ["userId"])
.index("username", ["username"])
.searchIndex("search_email", {
searchField: "email",
filterFields: ["role", "emailVerified"],
})
.searchIndex("search_username", {
searchField: "username",
filterFields: ["role", "emailVerified"],
}),
session: defineTable({
expiresAt: v.number(),
token: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
ipAddress: v.optional(v.union(v.null(), v.string())),
userAgent: v.optional(v.union(v.null(), v.string())),
userId: v.string(),
impersonatedBy: v.optional(v.union(v.null(), v.string())),
})
.index("expiresAt", ["expiresAt"])
.index("expiresAt_userId", ["expiresAt", "userId"])
.index("token", ["token"])
.index("userId", ["userId"]),
account: defineTable({
accountId: v.string(),
providerId: v.string(),
userId: v.string(),
accessToken: v.optional(v.union(v.null(), v.string())),
refreshToken: v.optional(v.union(v.null(), v.string())),
idToken: v.optional(v.union(v.null(), v.string())),
accessTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
refreshTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
scope: v.optional(v.union(v.null(), v.string())),
password: v.optional(v.union(v.null(), v.string())),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("accountId", ["accountId"])
.index("accountId_providerId", ["accountId", "providerId"])
.index("providerId_userId", ["providerId", "userId"])
.index("userId", ["userId"]),
verification: defineTable({
identifier: v.string(),
value: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("expiresAt", ["expiresAt"])
.index("identifier", ["identifier"]),
jwks: defineTable({
publicKey: v.string(),
privateKey: v.string(),
createdAt: v.number(),
expiresAt: v.optional(v.union(v.null(), v.number())),
}),
};
const schema = defineSchema(tables);
export default schema;
================================================
FILE: convex/convex-env.d.ts
================================================
declare namespace NodeJS {
interface ProcessEnv {
[key: string]: string | undefined;
}
interface Process {
env: ProcessEnv;
exit(code?: number): never;
}
}
declare var process: NodeJS.Process;
================================================
FILE: convex/convex.config.ts
================================================
import { defineApp } from "convex/server";
import betterAuth from "./betterAuth/convex.config";
const app = defineApp();
app.use(betterAuth);
export default app;
================================================
FILE: convex/convex_rules.md
================================================
# Convex guidelines
## Function guidelines
### New function syntax
- ALWAYS use the new function syntax for Convex functions. For example:
```typescript
import { query } from "./_generated/server";
import { v } from "convex/values";
export const f = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
// Function body
},
});
```
### Http endpoint syntax
- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:
```typescript
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/echo",
method: "POST",
handler: httpAction(async (ctx, req) => {
const body = await req.bytes();
return new Response(body, { status: 200 });
}),
});
```
- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`.
### Validators
- Below is an example of an array validator:
```typescript
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export default mutation({
args: {
simpleArray: v.array(v.union(v.string(), v.number())),
},
handler: async (ctx, args) => {
//...
},
});
```
- Below is an example of a schema with validators that codify a discriminated union type:
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
results: defineTable(
v.union(
v.object({
kind: v.literal("error"),
errorMessage: v.string(),
}),
v.object({
kind: v.literal("success"),
value: v.number(),
}),
),
),
});
```
- Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value:
```typescript
import { query } from "./_generated/server";
import { v } from "convex/values";
export const exampleQuery = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
console.log("This query returns a null value");
return null;
},
});
```
- Here are the valid Convex types along with their respective validators:
Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Id | string | `doc._id` | `v.id(tableName)` | |
| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. |
| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |
| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. |
| Boolean | boolean | `true` | `v.boolean()` |
| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. |
| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. |
| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. |
| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". |
| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". |
### Function registration
- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`.
- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private.
- You CANNOT register a function through the `api` or `internal` objects.
- ALWAYS include argument and return validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. If a function doesn't return anything, include `returns: v.null()` as its output validator.
- If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`.
### Function calling
- Use `ctx.runQuery` to call a query from a query, mutation, or action.
- Use `ctx.runMutation` to call a mutation from a mutation or action.
- Use `ctx.runAction` to call an action from an action.
- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead.
- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions.
- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.
- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example,
```
export const f = query({
args: { name: v.string() },
returns: v.string(),
handler: async (ctx, args) => {
return "Hello " + args.name;
},
});
export const g = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
return null;
},
});
```
### Function references
- Function references are pointers to registered Convex functions.
- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.
- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`.
- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.
- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.
- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`.
### Api design
- Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory.
- Use `query`, `mutation`, and `action` to define public functions.
- Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions.
### Pagination
- Paginated queries are queries that return a list of results in incremental pages.
- You can define pagination using the following syntax:
```ts
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
export const listWithExtraArg = query({
args: { paginationOpts: paginationOptsValidator, author: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_author", (q) => q.eq("author", args.author))
.order("desc")
.paginate(args.paginationOpts);
},
});
```
Note: `paginationOpts` is an object with the following properties:
- `numItems`: the maximum number of documents to return (the validator is `v.number()`)
- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)
- A query that ends in `.paginate()` return
gitextract_re_01zim/ ├── .codex/ │ └── environments/ │ └── environment.toml ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── build.yml │ ├── build_pr.yml │ └── ci.yaml ├── .gitignore ├── AGENTS.md ├── CLAUDE.md ├── CONTEXT.md ├── README.md ├── biome.json ├── components.json ├── convex/ │ ├── _generated/ │ │ ├── ai/ │ │ │ ├── ai-files.state.json │ │ │ └── guidelines.md │ │ ├── api.d.ts │ │ ├── api.js │ │ ├── dataModel.d.ts │ │ ├── server.d.ts │ │ └── server.js │ ├── account.ts │ ├── adminData.ts │ ├── adminStoryWrite.ts │ ├── adminWrite.ts │ ├── audioRead.ts │ ├── auth.config.ts │ ├── auth.ts │ ├── authFunctions.ts │ ├── authMigration.ts │ ├── betterAuth/ │ │ ├── _generated/ │ │ │ ├── api.ts │ │ │ ├── component.ts │ │ │ ├── dataModel.ts │ │ │ └── server.ts │ │ ├── adapter.ts │ │ ├── auth.ts │ │ ├── convex.config.ts │ │ └── schema.ts │ ├── convex-env.d.ts │ ├── convex.config.ts │ ├── convex_rules.md │ ├── courseContributorBackfill.ts │ ├── courseWrite.ts │ ├── discordAvatarSync.ts │ ├── discordBot.ts │ ├── discordData.ts │ ├── discordRoleSync.ts │ ├── editorRead.ts │ ├── editorSideEffects.ts │ ├── http.ts │ ├── landing.ts │ ├── languageWrite.ts │ ├── lib/ │ │ ├── authorization.ts │ │ ├── courseContributors.ts │ │ ├── courseCounts.ts │ │ ├── discordAvatarSync.ts │ │ ├── phpbb.ts │ │ └── publicStoryContent.ts │ ├── localization.ts │ ├── localizationWrite.ts │ ├── lookupTables.ts │ ├── roles.ts │ ├── schema.ts │ ├── storyApproval.ts │ ├── storyDone.ts │ ├── storyPublicContent.ts │ ├── storyRead.ts │ ├── storyTables.ts │ ├── storyWrite.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── userPreferences.ts ├── database/ │ └── stories/ │ ├── es-en-o/ │ │ ├── 1_1_es-en-buenos-dias.txt │ │ ├── 1_2_es-en-una-cita.txt │ │ └── 1_3_es-en-una-cosa.txt │ ├── nl-en/ │ │ ├── 0_1_es-en-el-pastel-de-dragones.txt │ │ ├── 0_2_es-en-es-amor.txt │ │ ├── 0_3_es-en-la-pelea-de-boxeo.txt │ │ ├── 0_4_es-en-el-neumatico-pinchado.txt │ │ ├── 1_1_es-buenos-dias.txt │ │ ├── 1_2_es-una-cita.txt │ │ ├── 1_3_es-una-cosa.txt │ │ ├── 1_4_es-en-la-luna-de-miel.txt │ │ ├── 2_1_es-en-la-chaqueta-roja.txt │ │ ├── 2_2_es-en-el-pasaporte.txt │ │ ├── 2_3_es-en-una-familia-muy-grande.txt │ │ └── 2_4_es-en-el-doctor-eddy.txt │ └── test-en/ │ ├── 1_1_es-en-buenos-dias.txt │ └── 1_2_es-en-una-cita.txt ├── discord_roles/ │ ├── CONTEXT.md │ ├── audio_cleanup.py │ ├── blame.py │ ├── combine.py │ ├── discord_bot.py │ ├── discord_reacting_bot.py │ ├── env_utils.py │ └── requirements.txt ├── docs/ │ └── bulk-audio-editor-spec.md ├── import_tools/ │ ├── README.md │ ├── app.py │ └── greasmonkey.js ├── instrumentation-client.ts ├── jsconfig.json ├── knip.json ├── next.config.js ├── package.json ├── postcss.config.mjs ├── process.d.ts ├── public/ │ ├── .well-known/ │ │ └── assetlinks.json │ ├── darklight.js │ ├── docs/ │ │ ├── audio-generation/ │ │ │ ├── character-editor.mdx │ │ │ ├── edit.mdx │ │ │ ├── engines.mdx │ │ │ ├── fix-problems.mdx │ │ │ ├── generate.mdx │ │ │ └── overview.mdx │ │ ├── become-contributor/ │ │ │ ├── application.mdx │ │ │ └── colang.mdx │ │ ├── docs.json │ │ ├── introduction.mdx │ │ ├── search.js │ │ ├── story-creation/ │ │ │ ├── import.mdx │ │ │ └── translate.mdx │ │ ├── story-editing/ │ │ │ ├── exercises.mdx │ │ │ ├── overview.mdx │ │ │ └── translation-hints.mdx │ │ └── story-publishing/ │ │ ├── publishing.mdx │ │ └── without_tts.mdx │ ├── linja-pona-4.9.otf │ ├── linjalipamanka-normal.otf │ ├── robots.txt │ └── sw.js ├── scripts/ │ ├── backfill-course-contributors.ts │ ├── backfill-discord-avatars.ts │ └── find-missing-story-images.ts ├── skills-lock.json ├── src/ │ ├── app/ │ │ ├── (stories)/ │ │ │ ├── (main)/ │ │ │ │ ├── EditorCommandPaletteClient.tsx │ │ │ │ ├── [course_id]/ │ │ │ │ │ ├── course_page_client.tsx │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── story_button.tsx │ │ │ │ ├── course-dropdown.tsx │ │ │ │ ├── course_list.tsx │ │ │ │ ├── faq/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── footer_links.tsx │ │ │ │ ├── get_course_data.ts │ │ │ │ ├── header.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── landing_stats_client.tsx │ │ │ │ ├── language_button.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── privacy_policy/ │ │ │ │ │ └── page.tsx │ │ │ │ └── profile/ │ │ │ │ ├── actions.ts │ │ │ │ ├── data.ts │ │ │ │ ├── page.tsx │ │ │ │ └── profile.tsx │ │ │ ├── learn/ │ │ │ │ ├── page.tsx │ │ │ │ └── welcome.tsx │ │ │ └── story/ │ │ │ ├── [story_id]/ │ │ │ │ ├── auto_play/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── story_wrapper.tsx │ │ │ │ ├── getStory.ts │ │ │ │ ├── loading.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── script/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── story_wrapper.tsx │ │ │ │ ├── story_wrapper.tsx │ │ │ │ └── test/ │ │ │ │ ├── page.tsx │ │ │ │ └── story_wrapper.tsx │ │ │ └── layout.tsx │ │ ├── admin/ │ │ │ ├── AdminDialogTrigger.tsx │ │ │ ├── AdminHeader.tsx │ │ │ ├── FlagName.tsx │ │ │ ├── adminDetailStyles.ts │ │ │ ├── adminTableStyles.ts │ │ │ ├── courses/ │ │ │ │ ├── courses.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── page_client.tsx │ │ │ ├── edit_dialog.tsx │ │ │ ├── languages/ │ │ │ │ ├── language_list.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── page_client.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ ├── story/ │ │ │ │ ├── [story_id]/ │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── story_display.tsx │ │ │ │ └── page.tsx │ │ │ └── users/ │ │ │ ├── [user_id]/ │ │ │ │ ├── actions.ts │ │ │ │ ├── page.tsx │ │ │ │ ├── schema.ts │ │ │ │ └── user_display.tsx │ │ │ ├── page.tsx │ │ │ └── user_list.tsx │ │ ├── api/ │ │ │ ├── auth/ │ │ │ │ └── [...all]/ │ │ │ │ └── route.ts │ │ │ ├── og/ │ │ │ │ └── route.tsx │ │ │ ├── og-course/ │ │ │ │ └── route.tsx │ │ │ └── og-story/ │ │ │ └── route.tsx │ │ ├── audio/ │ │ │ ├── _lib/ │ │ │ │ └── audio/ │ │ │ │ ├── azure_tts.ts │ │ │ │ ├── elevenlabs.ts │ │ │ │ ├── google.ts │ │ │ │ ├── index.ts │ │ │ │ ├── polly.ts │ │ │ │ └── types.ts │ │ │ ├── create/ │ │ │ │ └── route.ts │ │ │ ├── elevenlabs_quota/ │ │ │ │ └── page.tsx │ │ │ ├── upload/ │ │ │ │ └── route.ts │ │ │ └── voices/ │ │ │ └── route.ts │ │ ├── auth/ │ │ │ ├── admin/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── editor/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── register/ │ │ │ │ ├── page.tsx │ │ │ │ └── register.tsx │ │ │ ├── reset_pw/ │ │ │ │ ├── page.tsx │ │ │ │ └── reset_pw.tsx │ │ │ └── signin/ │ │ │ ├── login_options.tsx │ │ │ └── page.tsx │ │ ├── dev/ │ │ │ └── story-footer-button-test/ │ │ │ ├── page.module.css │ │ │ └── page.tsx │ │ ├── docs/ │ │ │ ├── [[...slug]]/ │ │ │ │ ├── doc_data.ts │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── loading.tsx │ │ ├── editor/ │ │ │ ├── (course)/ │ │ │ │ ├── course/ │ │ │ │ │ └── [course_id]/ │ │ │ │ │ ├── import/ │ │ │ │ │ │ └── [from_id]/ │ │ │ │ │ │ ├── import_list.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── page_client.tsx │ │ │ │ │ ├── localization/ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── page_client.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── page_client.tsx │ │ │ │ │ ├── story/ │ │ │ │ │ │ └── [story]/ │ │ │ │ │ │ ├── audio-cutter/ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ └── page_client.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── voices/ │ │ │ │ │ ├── edit/ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── page_client.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── page_client.tsx │ │ │ │ ├── course_list.tsx │ │ │ │ ├── course_view_memory.ts │ │ │ │ ├── edit_list.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── layout_client.tsx │ │ │ │ ├── layout_flag.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── swipe.tsx │ │ │ │ └── types.ts │ │ │ ├── _components/ │ │ │ │ ├── breadcrumbs.tsx │ │ │ │ ├── editor_command_palette.tsx │ │ │ │ ├── header_context.tsx │ │ │ │ ├── header_shell.tsx │ │ │ │ ├── page_layout.tsx │ │ │ │ └── story_editor_preferences.tsx │ │ │ ├── editor_button.tsx │ │ │ ├── language/ │ │ │ │ └── [language]/ │ │ │ │ ├── language_editor.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── page_client.tsx │ │ │ │ ├── tts_edit/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── page_client.tsx │ │ │ │ │ └── tts_edit.tsx │ │ │ │ └── types.ts │ │ │ ├── layout.tsx │ │ │ ├── localization/ │ │ │ │ └── [language]/ │ │ │ │ ├── layout.tsx │ │ │ │ ├── localization_editor.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── page_client.tsx │ │ │ │ └── text_edit.tsx │ │ │ └── story/ │ │ │ └── [story]/ │ │ │ ├── audio-cutter-dialog.tsx │ │ │ ├── audio-cutter-storage.ts │ │ │ ├── bulk-audio-editor.tsx │ │ │ ├── editor_state.ts │ │ │ ├── header.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ ├── page_client.tsx │ │ │ ├── sound-recorder.tsx │ │ │ ├── types.ts │ │ │ └── v2/ │ │ │ ├── editor_v2.tsx │ │ │ └── use_story_editor_model.ts │ │ ├── layout.tsx │ │ ├── manifest.json │ │ └── not-found.tsx │ ├── components/ │ │ ├── Button/ │ │ │ ├── Button.tsx │ │ │ └── index.ts │ │ ├── CheckButton/ │ │ │ ├── CheckButton.tsx │ │ │ └── index.ts │ │ ├── ContributorList.tsx │ │ ├── Docs/ │ │ │ ├── CustomMDXServer/ │ │ │ │ ├── CustomMDXServer.tsx │ │ │ │ ├── index.ts │ │ │ │ └── process_mdx.ts │ │ │ ├── MdxTree/ │ │ │ │ ├── MdxTree.tsx │ │ │ │ └── index.ts │ │ │ └── docsClasses.ts │ │ ├── DocsBreadCrumbNav/ │ │ │ ├── DocsBreadCrumbNav.tsx │ │ │ └── index.ts │ │ ├── DocsHeader/ │ │ │ ├── DocsHeader.tsx │ │ │ └── index.ts │ │ ├── DocsNavigation/ │ │ │ ├── DocsNavigation.tsx │ │ │ └── index.ts │ │ ├── DocsNavigationBackdrop/ │ │ │ ├── DocsNavigationBackdrop.tsx │ │ │ └── index.ts │ │ ├── DocsSearchModal/ │ │ │ ├── DocsSearchModal.tsx │ │ │ └── index.ts │ │ ├── EditorSSMLDisplay/ │ │ │ ├── EditorSSMLDisplay.tsx │ │ │ └── index.ts │ │ ├── FadeGlideIn/ │ │ │ ├── FadeGlideIn.tsx │ │ │ └── index.ts │ │ ├── LocalisationProvider/ │ │ │ ├── LocalisationProvider.tsx │ │ │ ├── LocalisationProviderContext.tsx │ │ │ └── index.ts │ │ ├── NavigationModeProvider/ │ │ │ ├── NavigationModeProvider.tsx │ │ │ └── index.ts │ │ ├── PlayAudio/ │ │ │ ├── PlayAudio.tsx │ │ │ └── index.ts │ │ ├── ProgressBar/ │ │ │ ├── ProgressBar.tsx │ │ │ └── index.ts │ │ ├── StoryAutoPlay/ │ │ │ ├── StoryAutoPlay.tsx │ │ │ └── index.ts │ │ ├── StoryChallengeArrange/ │ │ │ ├── StoryChallengeArrange.tsx │ │ │ └── index.ts │ │ ├── StoryChallengeContinuation/ │ │ │ ├── StoryChallengeContinuation.tsx │ │ │ └── index.ts │ │ ├── StoryChallengeMatch/ │ │ │ ├── StoryChallengeMatch.tsx │ │ │ └── index.ts │ │ ├── StoryChallengeMultipleChoice/ │ │ │ ├── StoryChallengeMultipleChoice.tsx │ │ │ └── index.ts │ │ ├── StoryChallengePointToPhrase/ │ │ │ ├── StoryChallengePointToPhrase.tsx │ │ │ └── index.ts │ │ ├── StoryChallengeSelectPhrases/ │ │ │ ├── StoryChallengeSelectPhrases.tsx │ │ │ └── index.ts │ │ ├── StoryEditorPreview/ │ │ │ ├── StoryEditorPreview.tsx │ │ │ └── index.ts │ │ ├── StoryFinishedScreen/ │ │ │ ├── StoryFinishedScreen.tsx │ │ │ └── index.ts │ │ ├── StoryFooter/ │ │ │ ├── StoryFooter.tsx │ │ │ └── index.ts │ │ ├── StoryHeader/ │ │ │ ├── StoryHeader.tsx │ │ │ └── index.ts │ │ ├── StoryHeaderProgress/ │ │ │ ├── StoryHeaderProgress.tsx │ │ │ └── index.ts │ │ ├── StoryLineHints/ │ │ │ ├── StoryLineHints.tsx │ │ │ └── index.ts │ │ ├── StoryProgress/ │ │ │ ├── StoryProgress.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionArrange/ │ │ │ ├── StoryQuestionArrange.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionMatch/ │ │ │ ├── StoryQuestionMatch.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionMultipleChoice/ │ │ │ ├── StoryQuestionMultipleChoice.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionPointToPhrase/ │ │ │ ├── StoryQuestionPointToPhrase.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionPrompt/ │ │ │ ├── StoryQuestionPrompt.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionSelectPhrase/ │ │ │ ├── StoryQuestionSelectPhrase.tsx │ │ │ └── index.ts │ │ ├── StoryTextLine/ │ │ │ ├── StoryTextLine.tsx │ │ │ ├── index.ts │ │ │ └── use-audio.hook.ts │ │ ├── StoryTextLineSimple/ │ │ │ ├── StoryTextLineSimple.tsx │ │ │ └── index.ts │ │ ├── StoryTitlePage/ │ │ │ ├── StoryTitlePage.tsx │ │ │ └── index.ts │ │ ├── VisuallyHidden/ │ │ │ ├── VisuallyHidden.tsx │ │ │ └── index.ts │ │ ├── WordButton/ │ │ │ ├── WordButton.tsx │ │ │ └── index.ts │ │ ├── auth/ │ │ │ └── styles.ts │ │ ├── editor/ │ │ │ └── story/ │ │ │ ├── cast.tsx │ │ │ ├── editor-resize.ts │ │ │ ├── inline_tts.ts │ │ │ ├── parser.test.ts │ │ │ ├── parser.ts │ │ │ ├── scroll_linking.ts │ │ │ ├── syntax_parser_new.ts │ │ │ └── syntax_parser_types.ts │ │ ├── icons.tsx │ │ ├── layout/ │ │ │ └── legal.tsx │ │ ├── login/ │ │ │ ├── LoggedInButtonWrappedClient.tsx │ │ │ └── loggedinbutton.tsx │ │ ├── providers/ │ │ │ ├── ConvexClientProvider.tsx │ │ │ └── PostHogUserIdentifier.tsx │ │ └── ui/ │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── flag.tsx │ │ ├── input.tsx │ │ ├── kbd.tsx │ │ ├── language-flag.tsx │ │ ├── shadcn/ │ │ │ ├── dropdown-menu.tsx │ │ │ └── index.ts │ │ ├── sheet.tsx │ │ ├── spinner.tsx │ │ └── switch.tsx │ ├── hooks/ │ │ ├── use-choice-buttons.hook.ts │ │ ├── use-keypress.hook.ts │ │ └── use-scroll-into-view.hook.ts │ ├── instrumentation-client.ts │ ├── lib/ │ │ ├── audio/ │ │ │ └── client-audio-processing.ts │ │ ├── auth-client.ts │ │ ├── auth-server.ts │ │ ├── editor/ │ │ │ ├── audio/ │ │ │ │ ├── audio_edit_tools.test.ts │ │ │ │ ├── audio_edit_tools.ts │ │ │ │ └── text_with_mapping.ts │ │ │ ├── editorHandlers.ts │ │ │ └── tts_transcripte.ts │ │ ├── fetch_post.ts │ │ ├── getUserId.ts │ │ ├── get_localisation.ts │ │ ├── get_localisation_func.tsx │ │ ├── hooks.ts │ │ ├── is-typing-target.ts │ │ ├── lamejs-compat.ts │ │ ├── posthog-server.ts │ │ ├── posthog-user.ts │ │ ├── shuffle.ts │ │ ├── sound-effects.ts │ │ ├── story-preferences.ts │ │ ├── story-search.ts │ │ ├── userInterface.ts │ │ └── utils.ts │ ├── styles/ │ │ └── global.css │ └── types/ │ ├── lamejs.d.ts │ └── react-dom.d.ts └── tsconfig.json
SYMBOL INDEX (1199 symbols across 280 files)
FILE: convex/_generated/dataModel.d.ts
type TableNames (line 23) | type TableNames = TableNamesInDataModel<DataModel>;
type Doc (line 30) | type Doc<TableName extends TableNames> = DocumentByName<
type Id (line 48) | type Id<TableName extends TableNames | SystemTableNames> =
type DataModel (line 60) | type DataModel = DataModelFromSchemaDefinition<typeof schema>;
FILE: convex/_generated/server.d.ts
type QueryCtx (line 107) | type QueryCtx = GenericQueryCtx<DataModel>;
type MutationCtx (line 115) | type MutationCtx = GenericMutationCtx<DataModel>;
type ActionCtx (line 123) | type ActionCtx = GenericActionCtx<DataModel>;
type DatabaseReader (line 132) | type DatabaseReader = GenericDatabaseReader<DataModel>;
type DatabaseWriter (line 143) | type DatabaseWriter = GenericDatabaseWriter<DataModel>;
FILE: convex/adminData.ts
type AuthCtx (line 11) | type AuthCtx = MutationCtx | QueryCtx;
function isAdmin (line 13) | async function isAdmin(ctx: AuthCtx) {
function findAuthUserByLegacyId (line 98) | async function findAuthUserByLegacyId(ctx: AuthCtx, legacyId: number) {
function toAdminUser (line 114) | function toAdminUser(
function getDiscordAccountIdsByAuthUserIds (line 178) | async function getDiscordAccountIdsByAuthUserIds(
function getStoriesRoleSnapshotsByLegacyUserIds (line 207) | async function getStoriesRoleSnapshotsByLegacyUserIds(
FILE: convex/adminWrite.ts
function toLegacyLanguageResponse (line 6) | function toLegacyLanguageResponse(row: {
function getNextLegacyId (line 37) | async function getNextLegacyId(
function getNextUnusedLegacyId (line 49) | async function getNextUnusedLegacyId(
function getLanguageByLegacyId (line 65) | async function getLanguageByLegacyId(ctx: MutationCtx, legacyId: number) {
FILE: convex/authMigration.ts
constant PAGE_SIZE (line 5) | const PAGE_SIZE = 200;
function normalizeUsername (line 129) | function normalizeUsername(input: string): string {
FILE: convex/betterAuth/_generated/component.ts
type ComponentApi (line 24) | type ComponentApi<Name extends string | undefined = string | undefined> =
FILE: convex/betterAuth/_generated/dataModel.ts
type TableNames (line 23) | type TableNames = TableNamesInDataModel<DataModel>;
type Doc (line 30) | type Doc<TableName extends TableNames> = DocumentByName<
type Id (line 48) | type Id<TableName extends TableNames | SystemTableNames> =
type DataModel (line 60) | type DataModel = DataModelFromSchemaDefinition<typeof schema>;
FILE: convex/betterAuth/_generated/server.ts
type QueryCtx (line 118) | type QueryCtx = GenericQueryCtx<DataModel>;
type MutationCtx (line 128) | type MutationCtx = GenericMutationCtx<DataModel>;
type ActionCtx (line 136) | type ActionCtx = GenericActionCtx<DataModel>;
type DatabaseReader (line 145) | type DatabaseReader = GenericDatabaseReader<DataModel>;
type DatabaseWriter (line 156) | type DatabaseWriter = GenericDatabaseWriter<DataModel>;
FILE: convex/betterAuth/auth.ts
function normalizeExpiresAt (line 80) | function normalizeExpiresAt(value: number | Date | null | undefined) {
function syncDiscordAccountAvatarFromHook (line 86) | async function syncDiscordAccountAvatarFromHook(
FILE: convex/convex-env.d.ts
type ProcessEnv (line 2) | interface ProcessEnv {
type Process (line 6) | interface Process {
FILE: convex/courseContributorBackfill.ts
constant DEFAULT_BATCH_SIZE (line 9) | const DEFAULT_BATCH_SIZE = 10;
constant MAX_BATCH_SIZE (line 10) | const MAX_BATCH_SIZE = 25;
function normalizeBatchSize (line 12) | function normalizeBatchSize(value: number | undefined) {
function json (line 20) | function json(data: unknown, status = 200) {
function requireCourseContributorBackfillSecret (line 27) | async function requireCourseContributorBackfillSecret(req: Request) {
FILE: convex/discordAvatarSync.ts
type AdapterWhere (line 11) | type AdapterWhere = Array<{
type AuthAccountRow (line 17) | type AuthAccountRow = {
type PaginatedAdapterResponse (line 28) | type PaginatedAdapterResponse<T> = {
type BackfillArgs (line 34) | type BackfillArgs = {
type BackfillResult (line 40) | type BackfillResult = {
constant DEFAULT_BATCH_SIZE (line 54) | const DEFAULT_BATCH_SIZE = 25;
constant MAX_BATCH_SIZE (line 55) | const MAX_BATCH_SIZE = 100;
function json (line 57) | function json(data: unknown, status = 200) {
function requireDiscordAvatarSyncSecret (line 64) | async function requireDiscordAvatarSyncSecret(req: Request) {
function normalizeBatchSize (line 97) | function normalizeBatchSize(value: number | undefined) {
function dedupeAccounts (line 105) | function dedupeAccounts(accounts: AuthAccountRow[]) {
function findManyPage (line 119) | async function findManyPage<T>(
function runDiscordAvatarBackfill (line 133) | async function runDiscordAvatarBackfill(
FILE: convex/discordBot.ts
type Role (line 4) | type Role = "user" | "contributor" | "admin";
function json (line 6) | function json(data: unknown, status = 200) {
function requireDiscordSyncSecret (line 13) | async function requireDiscordSyncSecret(req: Request) {
type CombineKind (line 46) | type CombineKind = "users" | "publicStories" | "approvals";
type StoriesRoleSyncStatus (line 47) | type StoriesRoleSyncStatus =
function parseStoriesRoleSyncStatus (line 55) | function parseStoriesRoleSyncStatus(
function parseNumItems (line 71) | function parseNumItems(value: unknown) {
function parseKind (line 76) | function parseKind(value: unknown): CombineKind | null {
FILE: convex/discordData.ts
type AdapterWhere (line 32) | type AdapterWhere = Array<{
type BetterAuthModel (line 37) | type BetterAuthModel = "user" | "account";
type AuthUserRow (line 39) | type AuthUserRow = {
type AuthAccountRow (line 46) | type AuthAccountRow = {
type PaginatedAdapterResponse (line 51) | type PaginatedAdapterResponse<T> = {
function findManyAll (line 57) | async function findManyAll<T>(
function chunk (line 81) | function chunk<T>(items: T[], size: number): T[][] {
function getContributorAndAdminUsers (line 89) | async function getContributorAndAdminUsers(ctx: QueryCtx) {
FILE: convex/editorRead.ts
type LanguageDoc (line 7) | type LanguageDoc = Doc<"languages">;
type CourseDoc (line 8) | type CourseDoc = Doc<"courses">;
type StoryDoc (line 9) | type StoryDoc = Doc<"stories">;
type AvatarDoc (line 10) | type AvatarDoc = Doc<"avatars">;
type AvatarMappingDoc (line 11) | type AvatarMappingDoc = Doc<"avatar_mappings">;
function toNumber (line 36) | function toNumber(value: unknown): number | undefined {
function toLanguage (line 45) | function toLanguage(language: LanguageDoc) {
function toCourse (line 59) | function toCourse(
function getCourseByIdentifier (line 88) | async function getCourseByIdentifier(ctx: QueryCtx, identifier: string) {
function getUserNameByLegacyId (line 105) | async function getUserNameByLegacyId(ctx: QueryCtx, legacyIds: number[]) {
function getUserNameByAuthDocId (line 128) | async function getUserNameByAuthDocId(ctx: QueryCtx, authDocIds: string[...
function buildAvatarRows (line 174) | async function buildAvatarRows(
FILE: convex/editorSideEffects.ts
constant CONTENT_REPOSITORY (line 8) | const CONTENT_REPOSITORY = "rgerum/unofficial-duolingo-stories-content";
function toBase64Utf8 (line 12) | function toBase64Utf8(value: string) {
function getOctokit (line 22) | function getOctokit() {
function uploadWithDiffToGithub (line 31) | async function uploadWithDiffToGithub(args: {
function getPosthogClient (line 91) | function getPosthogClient() {
FILE: convex/landing.ts
type LandingCourseItem (line 52) | type LandingCourseItem = {
type LandingGroup (line 65) | type LandingGroup = {
FILE: convex/languageWrite.ts
function toLegacyLanguageResponse (line 6) | function toLegacyLanguageResponse(row: {
function getLanguageByLegacyId (line 37) | async function getLanguageByLegacyId(
function getLanguageByShort (line 47) | async function getLanguageByShort(ctx: MutationCtx, short?: string | nul...
function getAvatarByLegacyId (line 55) | async function getAvatarByLegacyId(ctx: MutationCtx, legacyAvatarId: num...
FILE: convex/lib/authorization.ts
type AuthCtx (line 3) | type AuthCtx = MutationCtx | QueryCtx;
type RoleIdentity (line 5) | type RoleIdentity = {
function getIdentity (line 10) | async function getIdentity(ctx: AuthCtx) {
function getRole (line 14) | async function getRole(ctx: AuthCtx) {
function requireAdmin (line 19) | async function requireAdmin(ctx: AuthCtx) {
function requireContributorOrAdmin (line 26) | async function requireContributorOrAdmin(ctx: AuthCtx) {
function requireSessionLegacyUserId (line 33) | async function requireSessionLegacyUserId(ctx: AuthCtx) {
function getSessionLegacyUserId (line 52) | async function getSessionLegacyUserId(ctx: AuthCtx) {
FILE: convex/lib/courseContributors.ts
type ContributorCtx (line 6) | type ContributorCtx = QueryCtx | MutationCtx;
type AdapterWhere (line 8) | type AdapterWhere = Array<{
type AuthUserRow (line 14) | type AuthUserRow = {
type AuthAccountRow (line 21) | type AuthAccountRow = {
type PaginatedAdapterResponse (line 26) | type PaginatedAdapterResponse<T> = {
type CourseContributor (line 39) | type CourseContributor = {
function findManyAll (line 48) | async function findManyAll<T>(
function getUsersByLegacyId (line 72) | async function getUsersByLegacyId(
function getRankedCourseContributors (line 136) | async function getRankedCourseContributors(
function partitionCourseContributors (line 181) | function partitionCourseContributors(contributors: CourseContributor[]) {
FILE: convex/lib/courseCounts.ts
function recomputeCoursePublishedCount (line 4) | async function recomputeCoursePublishedCount(
FILE: convex/lib/discordAvatarSync.ts
type DiscordAccountRecord (line 1) | type DiscordAccountRecord = {
type DiscordTokenResponse (line 11) | type DiscordTokenResponse = {
type DiscordUserResponse (line 18) | type DiscordUserResponse = {
type DiscordAvatarSyncResult (line 24) | type DiscordAvatarSyncResult =
function getDiscordCredentials (line 38) | function getDiscordCredentials() {
function getDiscordBotToken (line 53) | function getDiscordBotToken() {
function fetchDiscordUserWithOAuth (line 62) | async function fetchDiscordUserWithOAuth(
function fetchDiscordUserById (line 83) | async function fetchDiscordUserById(
function refreshDiscordAccessToken (line 110) | async function refreshDiscordAccessToken(refreshToken: string) {
function buildDiscordAvatarUrl (line 139) | function buildDiscordAvatarUrl(user: DiscordUserResponse) {
function tryFetchDiscordUserWithBot (line 160) | async function tryFetchDiscordUserWithBot(account: DiscordAccountRecord) {
function syncDiscordAvatarFromAccount (line 169) | async function syncDiscordAvatarFromAccount(
FILE: convex/lib/phpbb.ts
function md5Hex (line 5) | function md5Hex(content: string): string {
function md5Bytes (line 9) | function md5Bytes(input: Uint8Array): Uint8Array {
function bytesToHex (line 17) | function bytesToHex(bytes: Uint8Array): string {
function concatBytes (line 25) | function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
function phpbbHash (line 35) | function phpbbHash(password: string): string {
function hashCryptPrivate (line 43) | function hashCryptPrivate(password: string, setting: string): string {
function hashEncode64 (line 74) | function hashEncode64(input: Uint8Array | string, count: number): string {
function hashGensaltPrivate (line 113) | function hashGensaltPrivate(
function phpbbCheckHash (line 124) | function phpbbCheckHash(password: string, hash: string): boolean {
FILE: convex/lib/publicStoryContent.ts
type RecordValue (line 4) | type RecordValue = Record<string, unknown>;
function isRecord (line 6) | function isRecord(value: unknown): value is RecordValue {
function sanitizeConvexValue (line 10) | function sanitizeConvexValue(value: unknown): unknown {
function compactAudio (line 27) | function compactAudio(value: unknown): unknown {
function compactStoryValue (line 41) | function compactStoryValue(
function toPublicStoryJson (line 73) | function toPublicStoryJson(json: unknown): unknown {
function upsertPublicStoryContent (line 77) | async function upsertPublicStoryContent(
function getPublicStoryJson (line 104) | async function getPublicStoryJson(
FILE: convex/storyApproval.ts
function recomputeCourseContributors (line 21) | async function recomputeCourseContributors(
FILE: convex/storyDone.ts
function getDoneStoryIdsForCourseIdAndUser (line 125) | async function getDoneStoryIdsForCourseIdAndUser(
function upsertStoryDoneState (line 218) | async function upsertStoryDoneState(
function getNextStepForCourse (line 250) | async function getNextStepForCourse(
function upsertCourseActivity (line 338) | async function upsertCourseActivity(
function getCurrentIdentityLegacyUserId (line 366) | async function getCurrentIdentityLegacyUserId(
FILE: convex/storyRead.ts
function nonEmptyString (line 49) | function nonEmptyString(value: unknown) {
FILE: discord_roles/audio_cleanup.py
function move (line 14) | def move(source, target):
FILE: discord_roles/blame.py
function decode_git_stdout (line 8) | def decode_git_stdout(result):
function get_commits_per_file (line 14) | def get_commits_per_file(filename):
function get_author_percentages (line 20) | def get_author_percentages(filename, ignore_rev=None):
function parse_blame_porcelain (line 44) | def parse_blame_porcelain(output, base_file):
function get_files_since_commit (line 86) | def get_files_since_commit(commit):
function get_new_file_list (line 93) | def get_new_file_list():
function update_repo (line 102) | def update_repo():
function update_output_csv (line 111) | def update_output_csv():
FILE: discord_roles/combine.py
function fetch_combine_resource (line 19) | def fetch_combine_resource(kind, *, cursor=None, num_items=200, since_da...
function fetch_contributor_users (line 54) | def fetch_contributor_users():
function fetch_public_story_ids (line 86) | def fetch_public_story_ids():
function load_approval_cache (line 110) | def load_approval_cache():
function save_approval_cache (line 130) | def save_approval_cache(data):
function update_approval_cache (line 142) | def update_approval_cache():
function get_user_to_discord_mapping (line 200) | def get_user_to_discord_mapping():
function get_user_approval_count (line 208) | def get_user_approval_count():
function join_and_group_data (line 244) | def join_and_group_data():
function get_milestone_grouped (line 274) | def get_milestone_grouped():
function get_stories_role_sync_rows (line 283) | def get_stories_role_sync_rows():
function get_milestone_grouped_debug_missing_links (line 311) | def get_milestone_grouped_debug_missing_links():
function get_milestone_grouped2 (line 334) | def get_milestone_grouped2():
FILE: discord_roles/discord_bot.py
function sync_stories_role_status (line 23) | def sync_stories_role_status(snapshots):
function get_snapshot_row (line 54) | def get_snapshot_row(row, *, sync_status, assigned_stories_count=None, l...
function set_user_roles (line 69) | def set_user_roles(sync_rows):
FILE: discord_roles/discord_reacting_bot.py
function sync_user_role (line 8) | def sync_user_role(discord_id, write=None):
class MyClient (line 45) | class MyClient(discord.Client):
method on_ready (line 46) | async def on_ready(self):
method on_message (line 49) | async def on_message(self, message):
method _is_contributor_request_channel (line 75) | def _is_contributor_request_channel(self, channel):
method _get_first_message_if_match (line 82) | async def _get_first_message_if_match(self, channel, message_id):
method check_reaction (line 90) | async def check_reaction(self, reaction):
method on_raw_reaction_add (line 112) | async def on_raw_reaction_add(self, reaction):
method on_raw_reaction_remove (line 143) | async def on_raw_reaction_remove(self, reaction):
method on_member_update (line 163) | async def on_member_update(self, before, after):
method log (line 212) | async def log(self, message):
FILE: discord_roles/env_utils.py
function load_env_file (line 4) | def load_env_file(path: Path) -> dict[str, str]:
FILE: import_tools/app.py
function hello_world (line 9) | def hello_world():
function login (line 16) | def login():
function getfiles (line 23) | def getfiles():
FILE: import_tools/greasmonkey.js
function fetch_post (line 8) | function fetch_post(url, data) {
function getStories (line 24) | async function getStories(learningLanguage, fromLanguage) {
FILE: next.config.js
method rewrites (line 11) | async rewrites() {
FILE: public/darklight.js
function get_current_theme (line 1) | function get_current_theme() {
function load (line 19) | function load() {
FILE: public/docs/search.js
function getPageData (line 4) | async function getPageData(page) {
function search (line 47) | async function search() {
function display_search (line 75) | function display_search(do_show) {
function toggle (line 88) | function toggle(value) {
function init (line 100) | function init() {
FILE: scripts/backfill-course-contributors.ts
constant CONVEX_SITE_URL (line 5) | const CONVEX_SITE_URL =
constant COURSE_CONTRIBUTOR_BACKFILL_SECRET (line 10) | const COURSE_CONTRIBUTOR_BACKFILL_SECRET =
constant BATCH_SIZE (line 12) | const BATCH_SIZE = parsePositiveNumber(
constant BATCH_DELAY_MS (line 16) | const BATCH_DELAY_MS = parsePositiveNumber(
constant DRY_RUN (line 20) | const DRY_RUN = parseBooleanEnv(
type BackfillResult (line 37) | type BackfillResult = {
function parsePositiveNumber (line 48) | function parsePositiveNumber(value: string | undefined, fallback: number) {
function parseBooleanEnv (line 58) | function parseBooleanEnv(value: string | undefined, defaultValue: boolea...
function sleep (line 66) | function sleep(ms: number) {
function runBatch (line 70) | async function runBatch(baseUrl: string, cursor: string | null) {
function main (line 101) | async function main() {
FILE: scripts/backfill-discord-avatars.ts
constant CONVEX_SITE_URL (line 5) | const CONVEX_SITE_URL =
constant DISCORD_AVATAR_SYNC_SECRET (line 10) | const DISCORD_AVATAR_SYNC_SECRET = process.env.DISCORD_AVATAR_SYNC_SECRET;
constant TOTAL_LIMIT (line 11) | const TOTAL_LIMIT = parseOptionalNumber(process.env.DISCORD_AVATAR_SYNC_...
constant BATCH_SIZE (line 12) | const BATCH_SIZE = parsePositiveNumber(
constant BATCH_DELAY_MS (line 16) | const BATCH_DELAY_MS = parsePositiveNumber(
constant DRY_RUN (line 20) | const DRY_RUN = parseBooleanEnv(process.env.DISCORD_AVATAR_SYNC_DRY_RUN,...
type BackfillResult (line 34) | type BackfillResult = {
function parseOptionalNumber (line 48) | function parseOptionalNumber(value: string | undefined) {
function parsePositiveNumber (line 58) | function parsePositiveNumber(value: string | undefined, fallback: number) {
function parseBooleanEnv (line 68) | function parseBooleanEnv(value: string | undefined, defaultValue: boolea...
function sleep (line 76) | function sleep(ms: number) {
function runBatch (line 80) | async function runBatch(
function main (line 115) | async function main() {
FILE: scripts/find-missing-story-images.ts
constant CONVEX_URL (line 8) | const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CON...
constant CONCURRENCY (line 9) | const CONCURRENCY = Number(process.env.STORY_IMAGE_AUDIT_CONCURRENCY ?? ...
constant PUBLISHED_ONLY (line 10) | const PUBLISHED_ONLY = parseBooleanEnv(
type StorySummary (line 27) | type StorySummary = {
type Finding (line 36) | type Finding = {
function mapWithConcurrency (line 44) | async function mapWithConcurrency<T, R>(
function parseBooleanEnv (line 69) | function parseBooleanEnv(value: string | undefined, defaultValue: boolea...
function getAllStories (line 77) | async function getAllStories(): Promise<StorySummary[]> {
function main (line 105) | async function main() {
FILE: src/app/(stories)/(main)/EditorCommandPaletteClient.tsx
type SessionUser (line 6) | type SessionUser = {
function EditorCommandPaletteClient (line 10) | function EditorCommandPaletteClient() {
FILE: src/app/(stories)/(main)/[course_id]/course_page_client.tsx
function SetTitle (line 12) | function SetTitle({ children }: { children: React.ReactNode }) {
function SetGrid (line 20) | function SetGrid({
function About (line 41) | function About({ about }: { about: string }) {
function Contributors (line 51) | function Contributors({
function NoNativeWarning (line 90) | function NoNativeWarning() {
function CoursePageClient (line 112) | function CoursePageClient({
FILE: src/app/(stories)/(main)/[course_id]/not-found.tsx
function NotFound (line 4) | function NotFound() {
FILE: src/app/(stories)/(main)/[course_id]/page.tsx
function generateMetadata (line 11) | async function generateMetadata(
function generateStaticParams (line 56) | async function generateStaticParams() {
function Page (line 68) | async function Page({
FILE: src/app/(stories)/(main)/[course_id]/story_button.tsx
type StoryData (line 6) | interface StoryData {
function StoryButton (line 14) | function StoryButton({
FILE: src/app/(stories)/(main)/course-dropdown.tsx
function LanguageButtonSmall (line 15) | function LanguageButtonSmall({ course }: { course?: CourseData }) {
function CourseDropdown (line 37) | function CourseDropdown() {
FILE: src/app/(stories)/(main)/course_list.tsx
type LandingGroupData (line 11) | interface LandingGroupData {
function RenderCourseGroups (line 20) | function RenderCourseGroups({ groups }: { groups: LandingGroupData[] }) {
function CourseList (line 51) | function CourseList({
FILE: src/app/(stories)/(main)/faq/page.tsx
function Page (line 12) | async function Page() {
FILE: src/app/(stories)/(main)/footer_links.tsx
function FooterLinks (line 4) | async function FooterLinks({}) {
FILE: src/app/(stories)/(main)/get_course_data.ts
type CourseData (line 5) | interface CourseData {
function get_course_data (line 20) | async function get_course_data() {
function get_course (line 24) | async function get_course(short: string) {
FILE: src/app/(stories)/(main)/header.tsx
function Header (line 1) | function Header({ children }: { children: React.ReactNode }) {
FILE: src/app/(stories)/(main)/icons.tsx
function Icons (line 11) | function Icons() {
FILE: src/app/(stories)/(main)/landing_stats_client.tsx
function LandingStatsText (line 6) | function LandingStatsText({
function LandingStatsClientPreloaded (line 27) | function LandingStatsClientPreloaded({
function LandingStatsClientQuery (line 36) | function LandingStatsClientQuery() {
function LandingStatsClient (line 41) | function LandingStatsClient({
FILE: src/app/(stories)/(main)/language_button.tsx
type LandingCourseButtonData (line 6) | interface LandingCourseButtonData {
function LanguageButton (line 18) | function LanguageButton({
FILE: src/app/(stories)/(main)/layout.tsx
function Layout (line 35) | function Layout({ children }: { children: React.ReactNode }) {
FILE: src/app/(stories)/(main)/not-found.tsx
function NotFound (line 4) | function NotFound() {
FILE: src/app/(stories)/(main)/page.tsx
function Page (line 10) | async function Page({}) {
FILE: src/app/(stories)/(main)/privacy_policy/page.tsx
function Page (line 13) | async function Page() {
FILE: src/app/(stories)/(main)/profile/actions.ts
constant STORY_PREFERENCE_COOKIE_MAX_AGE (line 8) | const STORY_PREFERENCE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
function setHideStoryQuestionsPreference (line 10) | async function setHideStoryQuestionsPreference(hideQuestions: boolean) {
function deleteCurrentUserAccount (line 25) | async function deleteCurrentUserAccount() {
FILE: src/app/(stories)/(main)/profile/data.ts
type ProfileData (line 10) | interface ProfileData {
function getProfileData (line 20) | async function getProfileData() {
FILE: src/app/(stories)/(main)/profile/page.tsx
function Page (line 12) | async function Page() {
FILE: src/app/(stories)/(main)/profile/profile.tsx
function roleBadgeTone (line 31) | function roleBadgeTone(role: string) {
function StatusText (line 39) | function StatusText({
function SettingRow (line 65) | function SettingRow({
function LinkedAccountRow (line 101) | function LinkedAccountRow({
function ProfileAvatar (line 164) | function ProfileAvatar({
function Profile (line 198) | function Profile({ providers }: { providers: ProfileData }) {
FILE: src/app/(stories)/learn/page.tsx
function Page (line 17) | async function Page() {
FILE: src/app/(stories)/learn/welcome.tsx
function Page (line 9) | function Page() {
FILE: src/app/(stories)/story/[story_id]/auto_play/page.tsx
function generateMetadata (line 7) | async function generateMetadata({
function Page (line 35) | async function Page({
FILE: src/app/(stories)/story/[story_id]/auto_play/story_wrapper.tsx
function StoryWrapper (line 8) | function StoryWrapper({ storyId }: { storyId: number }) {
FILE: src/app/(stories)/story/[story_id]/getStory.ts
function get_story (line 4) | async function get_story(story_id: number) {
type StoryData (line 10) | type StoryData = NonNullable<Awaited<ReturnType<typeof get_story>>>;
FILE: src/app/(stories)/story/[story_id]/loading.tsx
function Loading (line 6) | function Loading() {
FILE: src/app/(stories)/story/[story_id]/not-found.tsx
function NotFound (line 4) | function NotFound() {
FILE: src/app/(stories)/story/[story_id]/page.tsx
function generateMetadata (line 27) | async function generateMetadata({
function Page (line 55) | async function Page({
FILE: src/app/(stories)/story/[story_id]/script/page.tsx
function generateMetadata (line 10) | async function generateMetadata({
function getNavigationMode (line 31) | async function getNavigationMode() {
function Page (line 42) | async function Page({
FILE: src/app/(stories)/story/[story_id]/script/story_wrapper.tsx
function StoryWrapper (line 7) | function StoryWrapper({
FILE: src/app/(stories)/story/[story_id]/story_wrapper.tsx
function StoryWrapper (line 18) | function StoryWrapper({
FILE: src/app/(stories)/story/[story_id]/test/page.tsx
function Page (line 5) | async function Page({
FILE: src/app/(stories)/story/[story_id]/test/story_wrapper.tsx
function StoryWrapper (line 8) | function StoryWrapper({ storyId }: { storyId: number }) {
FILE: src/app/(stories)/story/layout.tsx
function Layout (line 21) | function Layout({ children }: { children: React.ReactNode }) {
FILE: src/app/admin/AdminDialogTrigger.tsx
type AdminDialogTriggerProps (line 10) | interface AdminDialogTriggerProps {
function AdminDialogTrigger (line 17) | function AdminDialogTrigger({
FILE: src/app/admin/AdminHeader.tsx
function AdminButton (line 12) | function AdminButton({
function AdminHeader (line 34) | async function AdminHeader() {
FILE: src/app/admin/FlagName.tsx
function FlagName (line 4) | function FlagName({
FILE: src/app/admin/courses/courses.tsx
type CourseProps (line 20) | interface CourseProps {
type AdminLanguageProps (line 33) | interface AdminLanguageProps {
function InputLanguage (line 50) | function InputLanguage({
function EditCourse (line 143) | function EditCourse({
function TableRow (line 324) | function TableRow({
function CourseList (line 436) | function CourseList({
FILE: src/app/admin/courses/page.tsx
function Page (line 3) | function Page() {
FILE: src/app/admin/courses/page_client.tsx
function CourseListClient (line 8) | function CourseListClient() {
FILE: src/app/admin/edit_dialog.tsx
function Content (line 16) | function Content({ children }: { children: React.ReactNode }) {
function DialogTitle (line 42) | function DialogTitle({
function DialogDescription (line 49) | function DialogDescription({
function Fieldset (line 65) | function Fieldset({
function Label (line 80) | function Label({
function InputArea (line 126) | function InputArea({
function InputText (line 141) | function InputText({
function InputTextArea (line 164) | function InputTextArea({
function InputBool (line 187) | function InputBool({
FILE: src/app/admin/languages/language_list.tsx
type Language (line 19) | interface Language {
type EditLanguageProps (line 29) | interface EditLanguageProps {
function EditLanguage (line 37) | function EditLanguage({
type TableRowProps (line 184) | interface TableRowProps {
function TableRow (line 191) | function TableRow({
type LanguageListProps (line 266) | interface LanguageListProps {
function LanguageList (line 270) | function LanguageList({ all_languages }: LanguageListProps) {
FILE: src/app/admin/languages/page.tsx
function Page (line 3) | function Page() {
FILE: src/app/admin/languages/page_client.tsx
function LanguageListClient (line 8) | function LanguageListClient() {
FILE: src/app/admin/layout.tsx
function Layout (line 4) | async function Layout({
FILE: src/app/admin/page.tsx
function Page (line 1) | function Page({}) {
FILE: src/app/admin/story/[story_id]/actions.ts
function requireAdmin (line 7) | async function requireAdmin() {
function togglePublished (line 14) | async function togglePublished(
function removeApproval (line 26) | async function removeApproval(
FILE: src/app/admin/story/[story_id]/page.tsx
function Page (line 3) | async function Page({
FILE: src/app/admin/story/[story_id]/story_display.tsx
function StoryDisplay (line 19) | function StoryDisplay({ storyId }: { storyId: number }) {
FILE: src/app/admin/story/page.tsx
function Page (line 8) | function Page() {
FILE: src/app/admin/users/[user_id]/actions.ts
function setUserActivatedAction (line 25) | async function setUserActivatedAction(input: unknown) {
function setUserWriteAction (line 34) | async function setUserWriteAction(input: unknown) {
function setUserDeleteAction (line 43) | async function setUserDeleteAction(input: unknown) {
FILE: src/app/admin/users/[user_id]/page.tsx
function user_properties (line 7) | async function user_properties(id: string) {
function Page (line 34) | async function Page({
FILE: src/app/admin/users/[user_id]/schema.ts
type AdminUser (line 29) | type AdminUser = z.infer<typeof UserSchema>;
FILE: src/app/admin/users/[user_id]/user_display.tsx
function setUserActivated (line 19) | async function setUserActivated(data: {
function setUserWrite (line 26) | async function setUserWrite(data: { id: number; write: 0 | 1 | boolean }) {
function setUserDelete (line 30) | async function setUserDelete(data: { id: number }) {
function Activate (line 34) | function Activate({ user }: { user: AdminUser }) {
function Write (line 54) | function Write({ user }: { user: AdminUser }) {
function UserDisplay (line 74) | function UserDisplay({ user }: { user: AdminUser }) {
FILE: src/app/admin/users/page.tsx
constant LOAD_STEP (line 5) | const LOAD_STEP = 50;
function normalizeQuery (line 7) | function normalizeQuery(value: string | string[] | undefined) {
type FilterValue (line 13) | type FilterValue = "all" | "yes" | "no";
type RoleFilterValue (line 14) | type RoleFilterValue = "all" | "user" | "contributor" | "admin";
function normalizeFilter (line 16) | function normalizeFilter(value: string | string[] | undefined): FilterVa...
function normalizeRoleFilter (line 22) | function normalizeRoleFilter(
function Page (line 31) | async function Page({
FILE: src/app/admin/users/user_list.tsx
type AdminUserList (line 24) | type AdminUserList = AdminUser & { admin?: boolean; rowKey?: string };
type FilterValue (line 26) | type FilterValue = "all" | "yes" | "no";
type RoleFilterValue (line 27) | type RoleFilterValue = "all" | "user" | "contributor" | "admin";
type UserListProps (line 29) | interface UserListProps {
type AdminFilters (line 39) | type AdminFilters = {
type PendingAction (line 44) | type PendingAction = "search" | "filters" | "loadMore" | null;
function formatRegistered (line 46) | function formatRegistered(value: Date | string | undefined) {
function buildQueryString (line 59) | function buildQueryString(query: string, limit: number, filters: AdminFi...
function getRoleLabel (line 76) | function getRoleLabel(user: AdminUserList) {
function getStoriesRoleTitle (line 82) | function getStoriesRoleTitle(user: AdminUserList) {
function ActivatedStatus (line 106) | function ActivatedStatus({ activated }: { activated: boolean | undefined...
function DiscordAvatar (line 128) | function DiscordAvatar({ user }: { user: AdminUserList }) {
function UserList (line 168) | function UserList({
FILE: src/app/api/og-course/route.tsx
function get_flag_id (line 6) | function get_flag_id(iso: string | null): number {
function GET (line 64) | async function GET(request: NextRequest) {
FILE: src/app/api/og-story/route.tsx
function GET (line 5) | async function GET(request: NextRequest) {
FILE: src/app/api/og/route.tsx
function GET (line 5) | async function GET(_request: NextRequest) {
FILE: src/app/audio/_lib/audio/azure_tts.ts
function get_raw (line 6) | function get_raw(text: string): string {
function synthesizeSpeechAzure (line 20) | async function synthesizeSpeechAzure(
function getVoices (line 103) | async function getVoices(): Promise<Voice[]> {
function isValidVoice (line 127) | function isValidVoice(voice: string): boolean {
FILE: src/app/audio/_lib/audio/elevenlabs.ts
type GenerateResult (line 14) | interface GenerateResult {
function generate (line 19) | async function generate(
function synthesizeSpeechElevenLabs (line 111) | async function synthesizeSpeechElevenLabs(
function getUserInfo (line 171) | async function getUserInfo(): Promise<ElevenLabsSubscription> {
function isValidVoice (line 184) | async function isValidVoice(voiceId: string): Promise<boolean> {
function getVoices (line 202) | async function getVoices(): Promise<Voice[]> {
FILE: src/app/audio/_lib/audio/google.ts
function synthesizeSpeechGoogle (line 6) | async function synthesizeSpeechGoogle(
type MarkData (line 83) | interface MarkData {
function add_marks (line 90) | function add_marks(text: string): [string, MarkData[]] {
type GoogleVoice (line 113) | interface GoogleVoice {
function getVoices (line 119) | async function getVoices(): Promise<Voice[]> {
function isValidVoice (line 151) | function isValidVoice(voice: string): boolean {
FILE: src/app/audio/_lib/audio/polly.ts
function synthesizeSpeechCall (line 24) | async function synthesizeSpeechCall(
function streamToString (line 42) | function streamToString(stream: Readable): Promise<string> {
function streamToBuffer (line 51) | function streamToBuffer(stream: Readable): Promise<Buffer> {
function streamToBase64 (line 60) | async function streamToBase64(stream: Readable): Promise<string> {
function synthesizeSpeechPolly (line 76) | async function synthesizeSpeechPolly(
function getVoices (line 159) | async function getVoices(): Promise<Voice[]> {
function isValidVoice (line 189) | function isValidVoice(voice: string): boolean {
function getVoiceData (line 193) | async function getVoiceData(voice: string): Promise<SpeakerData | undefi...
FILE: src/app/audio/_lib/audio/types.ts
type AudioMark (line 4) | interface AudioMark {
type SynthesisResult (line 15) | interface SynthesisResult {
type Voice (line 31) | interface Voice {
type TTSEngine (line 49) | interface TTSEngine {
type ElevenLabsEngine (line 67) | interface ElevenLabsEngine extends TTSEngine {
type ElevenLabsSubscription (line 74) | interface ElevenLabsSubscription {
type SpeakerData (line 93) | interface SpeakerData {
FILE: src/app/audio/create/route.ts
function mkdir (line 9) | async function mkdir(folderName: string): Promise<void> {
function exists (line 22) | async function exists(filename: string): Promise<boolean> {
function POST (line 34) | async function POST(req: NextRequest) {
FILE: src/app/audio/elevenlabs_quota/page.tsx
function Page (line 3) | async function Page() {
FILE: src/app/audio/upload/route.ts
function mkdir (line 7) | async function mkdir(folderName: string): Promise<void> {
function exists (line 20) | async function exists(filename: string): Promise<boolean> {
function POST (line 32) | async function POST(req: NextRequest) {
FILE: src/app/audio/voices/route.ts
function GET (line 8) | async function GET(_req: NextRequest) {
FILE: src/app/auth/admin/layout.tsx
function Layout (line 5) | async function Layout({
FILE: src/app/auth/admin/page.tsx
function Page (line 6) | function Page({}) {
FILE: src/app/auth/editor/layout.tsx
type LayoutProps (line 5) | interface LayoutProps {
function Layout (line 9) | async function Layout({ children }: LayoutProps) {
FILE: src/app/auth/editor/page.tsx
function Page (line 8) | function Page() {
FILE: src/app/auth/layout.tsx
function Layout (line 22) | function Layout({ children }: { children: React.ReactNode }) {
FILE: src/app/auth/register/page.tsx
function Page (line 6) | async function Page({}) {
FILE: src/app/auth/register/register.tsx
function Register (line 18) | function Register() {
FILE: src/app/auth/reset_pw/page.tsx
function Page (line 6) | async function Page({
FILE: src/app/auth/reset_pw/reset_pw.tsx
function ResetPassword (line 17) | function ResetPassword() {
FILE: src/app/auth/signin/login_options.tsx
constant PENDING_SIGNIN_STORAGE_KEY (line 20) | const PENDING_SIGNIN_STORAGE_KEY = "posthog_pending_signin";
function LoginOptions (line 22) | function LoginOptions(props: {
FILE: src/app/auth/signin/page.tsx
type ProviderProps (line 6) | interface ProviderProps {
function Page (line 17) | async function Page({
FILE: src/app/dev/story-footer-button-test/page.tsx
function Page (line 102) | function Page() {
FILE: src/app/docs/[[...slug]]/doc_data.ts
function getPageData (line 6) | async function getPageData(path: string) {
type DocData (line 25) | interface DocData {
FILE: src/app/docs/[[...slug]]/page.tsx
function generateStaticParams (line 28) | async function generateStaticParams() {
function generateMetadata (line 41) | async function generateMetadata({
function save_tag (line 55) | function save_tag(tag: string) {
function CustomMDX (line 91) | function CustomMDX(props: { source: string }) {
function SlugToPath (line 100) | function SlugToPath(slug: string[]) {
type Heading (line 110) | interface Heading {
function getHeadings (line 116) | function getHeadings(title: string, body: string) {
function Page (line 127) | async function Page({
FILE: src/app/docs/layout.tsx
function Layout (line 12) | async function Layout({
FILE: src/app/docs/loading.tsx
function Loading (line 8) | function Loading() {
FILE: src/app/editor/(course)/course/[course_id]/import/[from_id]/import_list.tsx
function ImportList (line 10) | function ImportList({
function pad (line 136) | function pad(x: number) {
FILE: src/app/editor/(course)/course/[course_id]/import/[from_id]/page.tsx
function generateMetadata (line 8) | async function generateMetadata({
function Page (line 27) | async function Page({
FILE: src/app/editor/(course)/course/[course_id]/import/[from_id]/page_client.tsx
function ImportPageClient (line 12) | function ImportPageClient({
FILE: src/app/editor/(course)/course/[course_id]/localization/page.tsx
function getCanonicalLocalizationPath (line 8) | function getCanonicalLocalizationPath(courseShort: string) {
function generateMetadata (line 12) | async function generateMetadata({
function Page (line 32) | async function Page({
FILE: src/app/editor/(course)/course/[course_id]/localization/page_client.tsx
function CourseLocalizationPageClient (line 12) | function CourseLocalizationPageClient({
FILE: src/app/editor/(course)/course/[course_id]/page.tsx
function generateMetadata (line 7) | async function generateMetadata({
function Page (line 26) | async function Page({
FILE: src/app/editor/(course)/course/[course_id]/page_client.tsx
function CourseEditorPageClient (line 13) | function CourseEditorPageClient({
FILE: src/app/editor/(course)/course/[course_id]/story/[story]/audio-cutter/page.tsx
function Page (line 4) | async function Page({
FILE: src/app/editor/(course)/course/[course_id]/story/[story]/audio-cutter/page_client.tsx
function AudioCutterPageClient (line 43) | function AudioCutterPageClient({
type LanguageData (line 428) | type LanguageData = {
type UploadedSegment (line 434) | type UploadedSegment = AudioCutterPreparedSegment & {
function getElementAudio (line 438) | function getElementAudio(
function getAudioCutterTranscriptItems (line 446) | function getAudioCutterTranscriptItems(elements: StoryElement[]) {
function stripAudioPathPrefix (line 491) | function stripAudioPathPrefix(filename: string) {
function uploadAudioFile (line 499) | async function uploadAudioFile(file: File, storyId: number) {
function applyAudioUpdatesToText (line 540) | function applyAudioUpdatesToText(
function toConvexValue (line 581) | function toConvexValue(value: unknown): unknown {
function StoryNavButton (line 594) | function StoryNavButton({
function ChevronIcon (line 638) | function ChevronIcon({ direction }: { direction: "left" | "right" }) {
FILE: src/app/editor/(course)/course/[course_id]/story/[story]/layout.tsx
function Layout (line 3) | function Layout({ children }: { children: React.ReactNode }) {
FILE: src/app/editor/(course)/course/[course_id]/story/[story]/page.tsx
function getCanonicalStoryEditorPath (line 8) | function getCanonicalStoryEditorPath(courseShort: string, storyId: numbe...
function generateMetadata (line 12) | async function generateMetadata({
function Page (line 36) | async function Page({
FILE: src/app/editor/(course)/course/[course_id]/voices/edit/page.tsx
function getCanonicalVoicesEditPath (line 8) | function getCanonicalVoicesEditPath(courseShort: string) {
function generateMetadata (line 12) | async function generateMetadata({
function Page (line 32) | async function Page({
FILE: src/app/editor/(course)/course/[course_id]/voices/edit/page_client.tsx
function CourseVoicesEditPageClient (line 17) | function CourseVoicesEditPageClient({
FILE: src/app/editor/(course)/course/[course_id]/voices/page.tsx
function getCanonicalVoicesPath (line 8) | function getCanonicalVoicesPath(courseShort: string) {
function generateMetadata (line 12) | async function generateMetadata({
function Page (line 32) | async function Page({
FILE: src/app/editor/(course)/course/[course_id]/voices/page_client.tsx
function CourseVoicesPageClient (line 16) | function CourseVoicesPageClient({
FILE: src/app/editor/(course)/course_list.tsx
type CourseListProps (line 11) | interface CourseListProps {
function CourseList (line 17) | function CourseList({
FILE: src/app/editor/(course)/course_view_memory.ts
constant COURSE_SCROLL_KEY_PREFIX (line 3) | const COURSE_SCROLL_KEY_PREFIX = "editor-course-scroll:";
constant COURSE_FILTER_KEY_PREFIX (line 4) | const COURSE_FILTER_KEY_PREFIX = "editor-course-filter:";
constant COURSE_SCROLL_CONTAINER_SELECTOR (line 5) | const COURSE_SCROLL_CONTAINER_SELECTOR =
constant COURSE_FILTER_VALUES (line 7) | const COURSE_FILTER_VALUES = [
type CourseFilterValue (line 15) | type CourseFilterValue = (typeof COURSE_FILTER_VALUES)[number];
function getCourseScrollKey (line 17) | function getCourseScrollKey(courseIdentifier: string) {
function getCourseFilterKey (line 21) | function getCourseFilterKey(courseIdentifier: string) {
function getCourseScrollContainer (line 25) | function getCourseScrollContainer() {
function rememberCourseScrollPosition (line 31) | function rememberCourseScrollPosition(courseIdentifier: string) {
function readCourseScrollPosition (line 43) | function readCourseScrollPosition(courseIdentifier: string) {
function rememberCourseFilter (line 56) | function rememberCourseFilter(
function readCourseFilter (line 65) | function readCourseFilter(
function restoreCourseScrollPosition (line 78) | function restoreCourseScrollPosition(
function isCourseFilterValue (line 125) | function isCourseFilterValue(value: string): value is CourseFilterValue {
FILE: src/app/editor/(course)/edit_list.tsx
type StoryState (line 28) | type StoryState = "draft" | "feedback" | "finished" | "published";
type StoryFilter (line 29) | type StoryFilter = "all" | StoryState;
constant STORY_FILTER_ORDER (line 31) | const STORY_FILTER_ORDER: StoryFilter[] = [
function EditList (line 39) | function EditList({
function getStoryState (line 396) | function getStoryState(story: Pick<StoryListDataProps, "status" | "publi...
function getFilterLabel (line 403) | function getFilterLabel(filter: StoryFilter) {
function getEmptyStateMessage (line 411) | function getEmptyStateMessage(filter: StoryFilter, searchQuery: string) {
function pad_space (line 417) | function pad_space(x: number) {
function pad (line 422) | function pad(x: number) {
function formatDate (line 427) | function formatDate(datetime: string | number | Date | undefined) {
function DropDownStatus (line 440) | function DropDownStatus(props: {
FILE: src/app/editor/(course)/layout.tsx
function Layout (line 4) | async function Layout({
FILE: src/app/editor/(course)/layout_client.tsx
function EditorLayoutClient (line 8) | function EditorLayoutClient({
FILE: src/app/editor/(course)/layout_flag.tsx
type BreadcrumbPath (line 14) | interface BreadcrumbPath {
function LayoutFlag (line 28) | function LayoutFlag() {
FILE: src/app/editor/(course)/loading.tsx
function Loading (line 10) | function Loading() {
FILE: src/app/editor/(course)/page.tsx
function generateMetadata (line 4) | async function generateMetadata({}): Promise<Metadata> {
function Page (line 13) | async function Page({}) {
FILE: src/app/editor/(course)/swipe.tsx
type SwiperSideBarProps (line 9) | interface SwiperSideBarProps {
function SwiperSideBar (line 13) | function SwiperSideBar({ children }: SwiperSideBarProps) {
FILE: src/app/editor/(course)/types.ts
type CourseProps (line 1) | type CourseProps = {
type ContributorSummaryProps (line 21) | type ContributorSummaryProps = {
type DetailedCourseProps (line 28) | type DetailedCourseProps = Omit<
type StoryListDataProps (line 36) | type StoryListDataProps = {
type CourseImportProps (line 53) | type CourseImportProps = {
FILE: src/app/editor/_components/breadcrumbs.tsx
type BreadcrumbLanguage (line 6) | interface BreadcrumbLanguage {
type BreadcrumbStoryData (line 11) | interface BreadcrumbStoryData {
type BreadcrumbPartData (line 16) | interface BreadcrumbPartData {
function MyLink (line 25) | function MyLink({
function BreadcrumbPart (line 44) | function BreadcrumbPart({
function Breadcrumbs (line 121) | function Breadcrumbs({ path }: { path: BreadcrumbPartData[] }) {
FILE: src/app/editor/_components/editor_command_palette.tsx
type PaletteSection (line 45) | type PaletteSection = "root" | "editor" | "admin" | "public";
type AdminListSection (line 46) | type AdminListSection = "courses" | "languages";
type EditorStoryState (line 47) | type EditorStoryState = "draft" | "feedback" | "finished" | "published";
type PaletteIcon (line 48) | type PaletteIcon =
type PublicCourseListItem (line 63) | type PublicCourseListItem = {
type PublicCourseStory (line 74) | type PublicCourseStory = {
type PublicCoursePageData (line 82) | type PublicCoursePageData = {
type AdminLanguageItem (line 91) | type AdminLanguageItem = {
type AdminCourseItem (line 101) | type AdminCourseItem = {
type PaletteFlag (line 114) | type PaletteFlag = {
type AdminCourseData (line 120) | type AdminCourseData = {
type PaletteItem (line 125) | type PaletteItem = {
function EditorCommandPalette (line 161) | function EditorCommandPalette({
function openPaletteInstant (line 1390) | function openPaletteInstant(
function resetPaletteState (line 1437) | function resetPaletteState(
function getInitialSection (line 1459) | function getInitialSection({
function getCourseIdentifier (line 1480) | function getCourseIdentifier(course: CourseProps) {
function getCourseKey (line 1484) | function getCourseKey(course: CourseProps) {
function getPublicCourseKey (line 1488) | function getPublicCourseKey(course: PublicCourseListItem) {
function matchesCourseSearch (line 1492) | function matchesCourseSearch(course: CourseProps, normalizedQuery: strin...
function matchesPublicCourseSearch (line 1513) | function matchesPublicCourseSearch(
function matchesPaletteSearch (line 1535) | function matchesPaletteSearch(item: PaletteItem, normalizedQuery: string) {
function appendRankedPaletteItems (line 1548) | function appendRankedPaletteItems(
function getPaletteSearchScore (line 1570) | function getPaletteSearchScore(item: PaletteItem, normalizedQuery: strin...
function getEmptyState (line 1594) | function getEmptyState(
function getEditorStoryState (line 1641) | function getEditorStoryState(
function getEditorStoryStateLabel (line 1650) | function getEditorStoryStateLabel(state: EditorStoryState) {
function PaletteSectionHeading (line 1657) | function PaletteSectionHeading({
function PaletteListItem (line 1677) | function PaletteListItem({
function getPaletteItemIcon (line 1881) | function getPaletteItemIcon(icon: PaletteIcon | undefined) {
FILE: src/app/editor/_components/header_context.tsx
type EditorHeaderSlotName (line 6) | type EditorHeaderSlotName = "breadcrumbs" | "actions";
type EditorHeaderContextValue (line 8) | type EditorHeaderContextValue = {
function EditorHeaderProvider (line 16) | function EditorHeaderProvider({
function useEditorHeaderContext (line 53) | function useEditorHeaderContext() {
function useEditorHeaderSlotRef (line 61) | function useEditorHeaderSlotRef(slot: EditorHeaderSlotName) {
function EditorHeaderPortal (line 72) | function EditorHeaderPortal({
function EditorHeaderBreadcrumbs (line 87) | function EditorHeaderBreadcrumbs({
function EditorHeaderActions (line 95) | function EditorHeaderActions({
FILE: src/app/editor/_components/header_shell.tsx
function EditorHeaderShell (line 8) | function EditorHeaderShell() {
FILE: src/app/editor/_components/page_layout.tsx
function EditorPageLayout (line 6) | function EditorPageLayout({
FILE: src/app/editor/_components/story_editor_preferences.tsx
constant EDITOR_STORY_PREFERENCES_STORAGE_KEY (line 5) | const EDITOR_STORY_PREFERENCES_STORAGE_KEY = "editor_story_preferences";
type StoryEditorPreferencesValue (line 7) | type StoryEditorPreferencesValue = {
function readInitialPreferences (line 17) | function readInitialPreferences() {
function StoryEditorPreferencesProvider (line 52) | function StoryEditorPreferencesProvider({
function useStoryEditorPreferences (line 93) | function useStoryEditorPreferences() {
FILE: src/app/editor/editor_button.tsx
function EditorButton (line 4) | function EditorButton({
FILE: src/app/editor/language/[language]/language_editor.tsx
type PlayFn (line 25) | type PlayFn = (
function LanguageEditor (line 31) | function LanguageEditor({
function Layout (line 92) | function Layout({
type AvatarData (line 177) | interface AvatarData {
function Avatar (line 185) | function Avatar(props: {
type PlayButtonProps (line 325) | interface PlayButtonProps {
function PlayButton (line 331) | function PlayButton(props: PlayButtonProps) {
function SpeakerEntry (line 377) | function SpeakerEntry(props: {
function AvatarNames (line 434) | function AvatarNames({
FILE: src/app/editor/language/[language]/layout.tsx
function Layout (line 4) | function Layout({ children }: { children: React.ReactNode }) {
FILE: src/app/editor/language/[language]/page.tsx
function getCanonicalVoicesPath (line 7) | function getCanonicalVoicesPath(courseShort: string) {
function generateMetadata (line 11) | async function generateMetadata({
function Page (line 44) | async function Page({
FILE: src/app/editor/language/[language]/page_client.tsx
function LanguageEditorPageClient (line 6) | function LanguageEditorPageClient({
FILE: src/app/editor/language/[language]/tts_edit/page.tsx
function getCanonicalVoicesEditPath (line 8) | function getCanonicalVoicesEditPath(courseShort: string) {
function generateMetadata (line 12) | async function generateMetadata({
function Page (line 45) | async function Page({
FILE: src/app/editor/language/[language]/tts_edit/page_client.tsx
function LanguageTtsEditorPageClient (line 10) | function LanguageTtsEditorPageClient({
FILE: src/app/editor/language/[language]/tts_edit/tts_edit.tsx
function Tts_edit (line 55) | function Tts_edit({
FILE: src/app/editor/language/[language]/types.ts
type AvatarNamesType (line 1) | type AvatarNamesType = {
type SpeakersType (line 10) | type SpeakersType = {
type LanguageType (line 19) | type LanguageType = {
type CourseStudType (line 31) | type CourseStudType = {
FILE: src/app/editor/layout.tsx
function Layout (line 7) | async function Layout({
FILE: src/app/editor/localization/[language]/layout.tsx
function Layout (line 4) | function Layout({ children }: { children: React.ReactNode }) {
FILE: src/app/editor/localization/[language]/localization_editor.tsx
type LanguageType (line 11) | type LanguageType = {
type CourseType (line 18) | type CourseType = {
type LocalizationRow (line 22) | type LocalizationRow = {
function LocalizationEditor (line 28) | function LocalizationEditor({
function Layout (line 78) | function Layout({
function ListLocalizations (line 115) | function ListLocalizations({
FILE: src/app/editor/localization/[language]/page.tsx
type LanguageType (line 6) | interface LanguageType {
type CourseType (line 13) | interface CourseType {
type PageProps (line 19) | interface PageProps {
function getCanonicalLocalizationPath (line 23) | function getCanonicalLocalizationPath(courseShort: string) {
function get_language (line 27) | async function get_language(id: string) {
function generateMetadata (line 39) | async function generateMetadata({ params }: PageProps) {
function Page (line 63) | async function Page({ params }: PageProps) {
FILE: src/app/editor/localization/[language]/page_client.tsx
function LocalizationPageClient (line 6) | function LocalizationPageClient({
FILE: src/app/editor/localization/[language]/text_edit.tsx
type TextEditProps (line 4) | interface TextEditProps {
function TextEdit (line 10) | function TextEdit({
FILE: src/app/editor/story/[story]/audio-cutter-dialog.tsx
constant DEFAULT_WAVEFORM_ZOOM (line 36) | const DEFAULT_WAVEFORM_ZOOM = 180;
constant MIN_WAVEFORM_ZOOM (line 37) | const MIN_WAVEFORM_ZOOM = 24;
constant MAX_WAVEFORM_ZOOM (line 38) | const MAX_WAVEFORM_ZOOM = 420;
constant WAVEFORM_ZOOM_STEP (line 39) | const WAVEFORM_ZOOM_STEP = 24;
constant DEFAULT_SEGMENT_LENGTH_SECONDS (line 40) | const DEFAULT_SEGMENT_LENGTH_SECONDS = 1.8;
constant MIN_SEGMENT_LENGTH_SECONDS (line 41) | const MIN_SEGMENT_LENGTH_SECONDS = 0.25;
constant MIN_PERSISTED_NEW_SEGMENT_SECONDS (line 42) | const MIN_PERSISTED_NEW_SEGMENT_SECONDS = 0.1;
constant WAVEFORM_TO_TRANSCRIPT_SYNC_LOCK_MS (line 43) | const WAVEFORM_TO_TRANSCRIPT_SYNC_LOCK_MS = 700;
constant SILENCE_WINDOW_SECONDS (line 44) | const SILENCE_WINDOW_SECONDS = 0.02;
constant DEFAULT_DETECTION_MIN_SILENCE_SECONDS (line 45) | const DEFAULT_DETECTION_MIN_SILENCE_SECONDS = 1;
constant DEFAULT_DETECTION_START_BUFFER_SECONDS (line 46) | const DEFAULT_DETECTION_START_BUFFER_SECONDS = 0.04;
constant DEFAULT_DETECTION_END_BUFFER_SECONDS (line 47) | const DEFAULT_DETECTION_END_BUFFER_SECONDS = 0.04;
constant DEFAULT_MAX_INTERNAL_SILENCE_SECONDS (line 48) | const DEFAULT_MAX_INTERNAL_SILENCE_SECONDS = 0.3;
constant DETECTION_SETTINGS_STORAGE_KEY (line 49) | const DETECTION_SETTINGS_STORAGE_KEY = "audio-cutter-detection-settings-...
constant SHRINK_WRAP_STABILITY_EPSILON_SECONDS (line 50) | const SHRINK_WRAP_STABILITY_EPSILON_SECONDS = 0.01;
constant MP3_BITRATE_KBPS (line 51) | const MP3_BITRATE_KBPS = 128;
constant MP3_SAMPLE_BLOCK_SIZE (line 52) | const MP3_SAMPLE_BLOCK_SIZE = 1152;
constant MIN_WORD_MARK_GAP_MS (line 53) | const MIN_WORD_MARK_GAP_MS = 20;
constant SEGMENT_COLOR (line 54) | const SEGMENT_COLOR = "rgba(28,176,246,0.2)";
constant SEGMENT_BORDER_COLOR (line 55) | const SEGMENT_BORDER_COLOR = "rgba(15,95,131,0.4)";
constant SEGMENT_ACTIVE_BORDER_COLOR (line 56) | const SEGMENT_ACTIVE_BORDER_COLOR = "rgba(28,176,246,0.95)";
type TimeRange (line 62) | type TimeRange = {
type Segment (line 67) | type Segment = {
type MergePreview (line 75) | type MergePreview = {
type SegmentDraft (line 80) | type SegmentDraft = {
type AudioSilenceAnalysis (line 86) | type AudioSilenceAnalysis = {
type CachedAudioSegmentation (line 98) | type CachedAudioSegmentation = {
type DetectionSettings (line 103) | type DetectionSettings = {
type SegmentRegion (line 110) | type SegmentRegion = {
type RegionsPlugin (line 128) | type RegionsPlugin = {
type SegmentedPlaybackState (line 154) | type SegmentedPlaybackState = {
constant EMPTY_REGIONS_PLUGIN (line 160) | const EMPTY_REGIONS_PLUGIN: RegionsPlugin = {
function sortSegments (line 175) | function sortSegments(segments: Segment[]) {
function clamp (line 179) | function clamp(value: number, min: number, max: number) {
function formatSeconds (line 183) | function formatSeconds(value: number) {
function getFileBaseName (line 191) | function getFileBaseName(filename: string) {
function getErrorMessage (line 196) | function getErrorMessage(error: unknown, fallback: string) {
function waitForNextAnimationFrame (line 200) | function waitForNextAnimationFrame() {
function createSegmentId (line 206) | function createSegmentId() {
function getWaveformScrollElement (line 210) | function getWaveformScrollElement(
function isEditableTarget (line 225) | function isEditableTarget(target: EventTarget | null) {
function renderTextWithHighlightedWord (line 237) | function renderTextWithHighlightedWord(
function getSegmentsFromPlugin (line 280) | function getSegmentsFromPlugin(plugin: RegionsPlugin) {
function getOverlappingRegion (line 291) | function getOverlappingRegion(
function overlapsSegment (line 313) | function overlapsSegment(
function sortRanges (line 322) | function sortRanges(ranges: TimeRange[]) {
function normalizeRanges (line 326) | function normalizeRanges(
function getTotalRangeDuration (line 349) | function getTotalRangeDuration(ranges: TimeRange[] | undefined) {
function getKeepRangeEnd (line 356) | function getKeepRangeEnd(
function getEffectiveSegmentDuration (line 364) | function getEffectiveSegmentDuration(segment: {
function getKeepRanges (line 375) | function getKeepRanges(bounds: TimeRange, skipRanges: TimeRange[] | unde...
function mapPlayableOffsetToAbsoluteTime (line 402) | function mapPlayableOffsetToAbsoluteTime(
function clampTimeToKeepRanges (line 420) | function clampTimeToKeepRanges(timeSeconds: number, keepRanges: TimeRang...
function getTranscriptWordTokens (line 445) | function getTranscriptWordTokens(text: string) {
function getApproximateWordMarks (line 455) | function getApproximateWordMarks(text: string, segment: Segment): AudioM...
function getApproximateWordPlaybackRange (line 495) | function getApproximateWordPlaybackRange(
function applyWordMarkTimeOverrides (line 523) | function applyWordMarkTimeOverrides(
function getActiveWordMarkIndex (line 586) | function getActiveWordMarkIndex(
function getKeypointsFromWordMarks (line 623) | function getKeypointsFromWordMarks(marks: AudioMark[]) {
function sanitizeDetectionSettings (line 640) | function sanitizeDetectionSettings(
function getDetectionSettingsCacheKey (line 668) | function getDetectionSettingsCacheKey(settings: DetectionSettings) {
function loadPersistedDetectionSettings (line 677) | function loadPersistedDetectionSettings() {
function analyzeAudioSilence (line 691) | function analyzeAudioSilence(
function detectSpeechSegmentsFromAnalysis (line 746) | function detectSpeechSegmentsFromAnalysis({
function getSegmentSkipRangesFromAnalysis (line 827) | function getSegmentSkipRangesFromAnalysis(
function getCachedAudioSegmentation (line 903) | function getCachedAudioSegmentation(
function getShrinkWrappedSegment (line 926) | function getShrinkWrappedSegment(
function syncRegionSkipMarkers (line 1011) | function syncRegionSkipMarkers(regionElement: HTMLElement, segment: Segm...
function syncRegionWordMarkers (line 1056) | function syncRegionWordMarkers(
function createIconSvg (line 1108) | function createIconSvg(path: string) {
function createIconButton (line 1127) | function createIconButton({
function createRegionContent (line 1162) | function createRegionContent({
function detectSpeechSegments (line 1249) | function detectSpeechSegments(
function float32ToInt16Sample (line 1256) | function float32ToInt16Sample(sample: number) {
function toPlainArrayBuffer (line 1263) | function toPlainArrayBuffer(view: Uint8Array | Int8Array) {
function audioBufferToWavBlob (line 1271) | function audioBufferToWavBlob(buffer: AudioBuffer) {
function encodeSegmentAsMp3 (line 1331) | async function encodeSegmentAsMp3(
function AudioCutterDialog (line 1415) | function AudioCutterDialog({
FILE: src/app/editor/story/[story]/audio-cutter-storage.ts
type AudioCutterTranscriptItem (line 6) | type AudioCutterTranscriptItem = Pick<
type AudioCutterPreparedSegment (line 19) | type AudioCutterPreparedSegment = {
type StoredAudioCutterTranscript (line 27) | type StoredAudioCutterTranscript = {
type StoredAudioCutterOutput (line 32) | type StoredAudioCutterOutput = {
constant AUDIO_CUTTER_DB_NAME (line 41) | const AUDIO_CUTTER_DB_NAME = "audio-cutter";
constant AUDIO_CUTTER_OUTPUT_STORE (line 42) | const AUDIO_CUTTER_OUTPUT_STORE = "output-files";
function getTranscriptStorageKey (line 44) | function getTranscriptStorageKey(storyId: number) {
function getOutputStorageKey (line 48) | function getOutputStorageKey(storyId: number) {
function parseStoredAudioCutterOutput (line 52) | function parseStoredAudioCutterOutput(raw: string | null) {
function getIndexedDb (line 62) | function getIndexedDb() {
function openAudioCutterDb (line 70) | function openAudioCutterDb() {
function requestToPromise (line 91) | function requestToPromise<T>(request: IDBRequest<T>) {
function transactionToPromise (line 102) | function transactionToPromise(transaction: IDBTransaction) {
function deleteStoredOutputBlobs (line 116) | async function deleteStoredOutputBlobs(blobKeys: string[]) {
function storeAudioCutterTranscript (line 138) | function storeAudioCutterTranscript(
function loadAudioCutterTranscript (line 155) | function loadAudioCutterTranscript(storyId: number) {
function storeAudioCutterOutput (line 169) | async function storeAudioCutterOutput(storyId: number, files: File[]) {
function consumeAudioCutterOutput (line 216) | async function consumeAudioCutterOutput(storyId: number) {
FILE: src/app/editor/story/[story]/bulk-audio-editor.tsx
constant PUBLIC_BLOB_BASE_URL (line 35) | const PUBLIC_BLOB_BASE_URL =
constant DEFAULT_WAVEFORM_ZOOM (line 37) | const DEFAULT_WAVEFORM_ZOOM = 420;
constant MIN_WAVEFORM_ZOOM (line 38) | const MIN_WAVEFORM_ZOOM = 40;
constant MAX_WAVEFORM_ZOOM (line 39) | const MAX_WAVEFORM_ZOOM = 640;
constant WAVEFORM_ZOOM_STEP (line 40) | const WAVEFORM_ZOOM_STEP = 40;
constant ZIP_MIME_TYPES (line 41) | const ZIP_MIME_TYPES = new Set([
constant AUDIO_EXTENSIONS (line 46) | const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".m4a", ".ogg", ".aac"]);
type BulkAudioEditorDraft (line 48) | type BulkAudioEditorDraft = {
type BulkAudioEditorItem (line 58) | type BulkAudioEditorItem = {
type BulkAudioEditorUpdate (line 71) | type BulkAudioEditorUpdate = {
type Region (line 79) | interface Region {
type RegionsPlugin (line 86) | interface RegionsPlugin {
type Part (line 101) | interface Part {
function cumulativeSums (line 106) | function cumulativeSums(values: number[]): number[] {
function stripAudioPathPrefix (line 116) | function stripAudioPathPrefix(filename: string) {
function getPublicAudioUrl (line 124) | function getPublicAudioUrl(filename: string) {
function isZipFile (line 132) | function isZipFile(file: File) {
function getFileExtension (line 138) | function getFileExtension(filename: string) {
function getAudioMimeType (line 143) | function getAudioMimeType(filename: string) {
function isAudioFilename (line 153) | function isAudioFilename(filename: string) {
function timingTextFromKeypoints (line 157) | function timingTextFromKeypoints(
function createDraft (line 171) | function createDraft(item: BulkAudioEditorItem): BulkAudioEditorDraft {
function createDraftMap (line 183) | function createDraftMap(items: BulkAudioEditorItem[]) {
function revokeDraftUrls (line 187) | function revokeDraftUrls(drafts: Record<string, BulkAudioEditorDraft>) {
function getLeadingNumber (line 193) | function getLeadingNumber(filename: string) {
function isChanged (line 203) | function isChanged(item: BulkAudioEditorItem, draft: BulkAudioEditorDraf...
function uploadAudioFile (line 214) | async function uploadAudioFile(file: File, storyId: number) {
function expandUploadFiles (line 240) | async function expandUploadFiles(files: File[]) {
function rowLabel (line 266) | function rowLabel(item: BulkAudioEditorItem) {
function statusLabel (line 270) | function statusLabel(item: BulkAudioEditorItem, draft: BulkAudioEditorDr...
function getParts (line 277) | function getParts(text: string) {
function getAutoRegionStarts (line 292) | function getAutoRegionStarts(parts: Part[], duration: number) {
function timingTextFromStarts (line 314) | function timingTextFromStarts(parts: Part[], starts: number[]) {
function getWordPlaybackSegments (line 332) | function getWordPlaybackSegments(
function BulkAudioRow (line 357) | function BulkAudioRow({
function BulkAudioEditor (line 852) | function BulkAudioEditor({
FILE: src/app/editor/story/[story]/editor_state.ts
type AudioInsertLinesType (line 10) | type AudioInsertLinesType = ReturnType<typeof processStoryFile>[2];
type EditorStateType (line 12) | type EditorStateType = {
FILE: src/app/editor/story/[story]/header.tsx
type StoryNavigationTarget (line 7) | type StoryNavigationTarget = {
type Window (line 13) | interface Window {
type HeaderProps (line 19) | type HeaderProps = {
function StoryEditorHeader (line 37) | function StoryEditorHeader({
function StoryEditorHeaderLoading (line 165) | function StoryEditorHeaderLoading() {
function SaveStatus (line 220) | function SaveStatus({ lastSavedAt }: { lastSavedAt: number }) {
function StoryNavButton (line 231) | function StoryNavButton({
function ChevronIcon (line 277) | function ChevronIcon({ direction }: { direction: "left" | "right" }) {
FILE: src/app/editor/story/[story]/layout.tsx
function Layout (line 4) | function Layout({ children }: { children: React.ReactNode }) {
FILE: src/app/editor/story/[story]/page.tsx
function getCanonicalStoryEditorPath (line 7) | function getCanonicalStoryEditorPath(courseShort: string, storyId: numbe...
function generateMetadata (line 11) | async function generateMetadata({
function Page (line 34) | async function Page({
FILE: src/app/editor/story/[story]/page_client.tsx
function StoryEditorPageClient (line 17) | function StoryEditorPageClient({
FILE: src/app/editor/story/[story]/sound-recorder.tsx
type Region (line 13) | interface Region {
type RegionsPlugin (line 19) | interface RegionsPlugin {
type SoundRecorderProps (line 29) | interface SoundRecorderProps {
type Part (line 42) | interface Part {
function cumulativeSums (line 47) | function cumulativeSums(values: number[]): number[] {
function uploadAudio (line 57) | async function uploadAudio(
function SoundRecorder (line 81) | function SoundRecorder({
FILE: src/app/editor/story/[story]/types.ts
type StoryData (line 1) | type StoryData = {
type StoryEditorPageData (line 16) | type StoryEditorPageData = {
type Avatar (line 21) | type Avatar = {
FILE: src/app/editor/story/[story]/v2/editor_v2.tsx
type StoryNavigation (line 39) | type StoryNavigation = {
type LanguageData (line 50) | type LanguageData = {
function getMax (line 64) | function getMax<T>(list: T[], callback: (obj: T) => number) {
function normalizeDocText (line 73) | function normalizeDocText(text: string): string {
function scrollEditorLineIntoView (line 77) | function scrollEditorLineIntoView(view: EditorView, lineNumber: number) {
function getElementAudio (line 107) | function getElementAudio(
function getBulkAudioEditorItems (line 115) | function getBulkAudioEditorItems(
function EditorV2 (line 164) | function EditorV2({
FILE: src/app/editor/story/[story]/v2/use_story_editor_model.ts
type LanguageLike (line 12) | type LanguageLike = {
type ImageLike (line 18) | type ImageLike = {
type UseStoryEditorModelArgs (line 24) | type UseStoryEditorModelArgs = {
function normalizeDocText (line 34) | function normalizeDocText(text: string): string {
function toConvexValue (line 38) | function toConvexValue(value: unknown): unknown {
type EditorModel (line 51) | type EditorModel = {
function useStoryEditorModel (line 77) | function useStoryEditorModel({
FILE: src/app/layout.tsx
function RootLayout (line 15) | function RootLayout({
FILE: src/app/not-found.tsx
function NotFound (line 4) | function NotFound() {
FILE: src/components/CheckButton/CheckButton.tsx
function CheckButton (line 4) | function CheckButton({ type }: { type: string }) {
FILE: src/components/ContributorList.tsx
type ContributorProfile (line 6) | type ContributorProfile = {
type ContributorInput (line 13) | type ContributorInput =
function normalizeContributor (line 23) | function normalizeContributor(
function ContributorAvatar (line 52) | function ContributorAvatar({
function ContributorList (line 89) | function ContributorList({
FILE: src/components/Docs/CustomMDXServer/CustomMDXServer.tsx
function CustomMDXServer (line 7) | async function CustomMDXServer({ source }: { source: string }) {
FILE: src/components/Docs/CustomMDXServer/process_mdx.ts
type TreeNode (line 14) | interface TreeNode {
function process_mdx (line 24) | async function process_mdx(
FILE: src/components/Docs/MdxTree/MdxTree.tsx
function startsLowerCase (line 16) | function startsLowerCase(tagName: string) {
function node_to_string (line 22) | function node_to_string(children: any) {
function save_tag (line 30) | function save_tag(tag: string) {
function Video (line 39) | function Video({
function getTreeLeaveID (line 132) | function getTreeLeaveID(i: any, index: number) {
function toCamelCase (line 141) | function toCamelCase(cssProperty: string) {
function MdxTreeRoot (line 151) | function MdxTreeRoot({
function MdxTree (line 174) | function MdxTree({
FILE: src/components/Docs/docsClasses.ts
function docsPageLinkClass (line 30) | function docsPageLinkClass(active: boolean) {
function docsSearchResultClass (line 84) | function docsSearchResultClass(type: "main" | "sub") {
FILE: src/components/DocsBreadCrumbNav/DocsBreadCrumbNav.tsx
function DocsBreadCrumbNav (line 11) | function DocsBreadCrumbNav({
FILE: src/components/DocsHeader/DocsHeader.tsx
function DocsHeader (line 16) | function DocsHeader() {
FILE: src/components/DocsNavigation/DocsNavigation.tsx
function DocsNavigation (line 22) | function DocsNavigation({
function NavigationContent (line 63) | function NavigationContent({
function PageLink (line 99) | function PageLink({
FILE: src/components/DocsNavigationBackdrop/DocsNavigationBackdrop.tsx
function DocsNavigationBackdrop (line 9) | function DocsNavigationBackdrop({ children }: { children: React.ReactNod...
FILE: src/components/DocsSearchModal/DocsSearchModal.tsx
function getPageData (line 18) | async function getPageData(path: string) {
type Page (line 83) | type Page =
function loadAll (line 99) | async function loadAll() {
function DocsSearchModal (line 118) | function DocsSearchModal({
FILE: src/components/EditorSSMLDisplay/EditorSSMLDisplay.tsx
type EditorSSMLDisplayProps (line 15) | interface EditorSSMLDisplayProps {
function EditorSSMLDisplay (line 24) | function EditorSSMLDisplay({
FILE: src/components/FadeGlideIn/FadeGlideIn.tsx
function FadeGlideIn (line 5) | function FadeGlideIn({
FILE: src/components/LocalisationProvider/LocalisationProvider.tsx
function LocalisationProvider (line 6) | async function LocalisationProvider({
FILE: src/components/LocalisationProvider/LocalisationProviderContext.tsx
function useLocalisation (line 7) | function useLocalisation() {
function LocalisationProviderInner (line 13) | function LocalisationProviderInner({
FILE: src/components/NavigationModeProvider/NavigationModeProvider.tsx
function useNavigationMode (line 9) | function useNavigationMode() {
function NavigationModeProvider (line 13) | function NavigationModeProvider({
FILE: src/components/PlayAudio/PlayAudio.tsx
function PlayAudio (line 4) | function PlayAudio({
FILE: src/components/ProgressBar/ProgressBar.tsx
function ProgressBar (line 3) | function ProgressBar({
FILE: src/components/StoryAutoPlay/StoryAutoPlay.tsx
type StoryAutoPlayProps (line 15) | interface StoryAutoPlayProps {
constant BLOB_PUBLIC_BASE (line 26) | const BLOB_PUBLIC_BASE =
constant FETCH_RETRIES (line 28) | const FETCH_RETRIES = 2;
constant LARGE_MERGE_SEGMENT_THRESHOLD (line 29) | const LARGE_MERGE_SEGMENT_THRESHOLD = 60;
type AutoPlayableElement (line 48) | type AutoPlayableElement = StoryElementHeader | StoryElementLine;
type TimelineSegment (line 50) | type TimelineSegment = {
type TimelineMeta (line 57) | type TimelineMeta = {
function getParts (line 64) | function getParts(story: StoryType) {
function toAbsoluteAudioUrl (line 78) | function toAbsoluteAudioUrl(url: string): string | null {
function getElementAudioUrl (line 84) | function getElementAudioUrl(element: AutoPlayableElement): string | null {
function getElementKeypoints (line 93) | function getElementKeypoints(
function clearHints (line 103) | function clearHints(element: StoryElement): StoryElement {
function findSegmentIndexByTime (line 137) | function findSegmentIndexByTime(meta: TimelineMeta[], time: number): num...
function formatTime (line 145) | function formatTime(seconds: number): string {
function fetchArrayBufferWithRetry (line 152) | async function fetchArrayBufferWithRetry(url: string): Promise<ArrayBuff...
function mergeBuffersToWav (line 174) | function mergeBuffersToWav(buffers: AudioBuffer[]): Blob {
function StoryAutoPlay (line 259) | function StoryAutoPlay({ story }: StoryAutoPlayProps) {
type AutoPlayPartProps (line 604) | interface AutoPlayPartProps {
function AutoPlayPart (line 612) | function AutoPlayPart({
type AutoPlayElementProps (line 635) | interface AutoPlayElementProps {
function AutoPlayElement (line 643) | function AutoPlayElement({
FILE: src/components/StoryChallengeArrange/StoryChallengeArrange.tsx
function StoryChallengeArrange (line 12) | function StoryChallengeArrange({
FILE: src/components/StoryChallengeContinuation/StoryChallengeContinuation.tsx
function StoryChallengeContinuation (line 14) | function StoryChallengeContinuation({
FILE: src/components/StoryChallengeMatch/StoryChallengeMatch.tsx
function StoryChallengeMatch (line 10) | function StoryChallengeMatch({
FILE: src/components/StoryChallengeMultipleChoice/StoryChallengeMultipleChoice.tsx
function StoryChallengeMultipleChoice (line 11) | function StoryChallengeMultipleChoice({
FILE: src/components/StoryChallengePointToPhrase/StoryChallengePointToPhrase.tsx
function StoryChallengePointToPhrase (line 11) | function StoryChallengePointToPhrase({
FILE: src/components/StoryChallengeSelectPhrases/StoryChallengeSelectPhrases.tsx
function StoryChallengeSelectPhrases (line 12) | function StoryChallengeSelectPhrases({
FILE: src/components/StoryEditorPreview/StoryEditorPreview.tsx
type StoryEditorPreviewProps (line 28) | interface StoryEditorPreviewProps {
function GetParts (line 36) | function GetParts(story: StoryType) {
function StoryEditorPreview (line 52) | function StoryEditorPreview({
type EditorPartProps (line 85) | interface EditorPartProps {
function EditorPart (line 96) | function EditorPart({
type EditorElementProps (line 128) | interface EditorElementProps {
function EditorElement (line 139) | function EditorElement({
function EditorError (line 272) | function EditorError({
type EditorQuestionWrapperProps (line 320) | interface EditorQuestionWrapperProps {
function EditorQuestionWrapper (line 326) | function EditorQuestionWrapper({
type EditorChallengePromptProps (line 344) | interface EditorChallengePromptProps {
function EditorChallengePrompt (line 350) | function EditorChallengePrompt({
FILE: src/components/StoryFinishedScreen/StoryFinishedScreen.tsx
function StoryFinishedScreen (line 6) | function StoryFinishedScreen({
FILE: src/components/StoryFooter/StoryFooter.tsx
function Message (line 18) | function Message({ children }: { children: React.ReactNode }) {
function Check (line 26) | function Check() {
function StoryFooter (line 38) | function StoryFooter({
FILE: src/components/StoryHeader/StoryHeader.tsx
function StoryHeader (line 18) | function StoryHeader({
FILE: src/components/StoryHeaderProgress/StoryHeaderProgress.tsx
type StoryHeaderProgressProps (line 13) | interface StoryHeaderProgressProps {
function PencilIcon (line 21) | function PencilIcon() {
function StoryHeaderProgress (line 48) | function StoryHeaderProgress({
FILE: src/components/StoryLineHints/StoryLineHints.tsx
function splitTextTokens (line 25) | function splitTextTokens(text: string, keep_tilde = true) {
function Tooltip (line 34) | function Tooltip({
function StoryLineHints (line 87) | function StoryLineHints({
FILE: src/components/StoryProgress/StoryProgress.tsx
function getComponent (line 27) | function getComponent(parts: StoryElement[]) {
function Header (line 48) | function Header({
function Line (line 76) | function Line({
function GetParts (line 103) | function GetParts(story: StoryType) {
function getCharacter (line 130) | function getCharacter(parts: StoryElement[]) {
function shouldSkipStoryPart (line 139) | function shouldSkipStoryPart(
function getNextVisibleStoryProgress (line 150) | function getNextVisibleStoryProgress(
function getVisibleStoryLength (line 165) | function getVisibleStoryLength(
function getVisibleStoryProgress (line 178) | function getVisibleStoryProgress(
type StorySettings (line 197) | type StorySettings = {
function getInitialStoryProgress (line 214) | function getInitialStoryProgress({
function getInitialPartProgress (line 253) | function getInitialPartProgress({
function getVisibleEditorLine (line 283) | function getVisibleEditorLine({
function getCurrentVisiblePart (line 307) | function getCurrentVisiblePart({
function StoryProgress (line 330) | function StoryProgress({
function NameButtons (line 631) | function NameButtons({
FILE: src/components/StoryQuestionArrange/StoryQuestionArrange.tsx
function StoryQuestionArrange (line 16) | function StoryQuestionArrange({
function useArrangeButtons (line 61) | function useArrangeButtons(
FILE: src/components/StoryQuestionMatch/StoryQuestionMatch.tsx
type WordState (line 10) | type WordState = "idle" | "selected" | "right" | "wrong";
type Word (line 12) | interface Word {
type State (line 19) | interface State {
type SelectionOutcome (line 23) | type SelectionOutcome = "noop" | "selected" | "right" | "wrong";
type SelectionResult (line 25) | interface SelectionResult {
type Action (line 30) | type Action =
function applySelection (line 54) | function applySelection(currentState: State, action: Action): SelectionR...
function reducer (line 116) | function reducer(currentState: State, action: Action) {
function shuffle_lists (line 123) | function shuffle_lists(state: State) {
function getNumberIndex (line 127) | function getNumberIndex(key: string) {
function StoryQuestionMatch (line 134) | function StoryQuestionMatch({
FILE: src/components/StoryQuestionMultipleChoice/StoryQuestionMultipleChoice.tsx
function StoryQuestionMultipleChoice (line 28) | function StoryQuestionMultipleChoice({
FILE: src/components/StoryQuestionPointToPhrase/StoryQuestionPointToPhrase.tsx
function StoryQuestionPointToPhrase (line 19) | function StoryQuestionPointToPhrase({
FILE: src/components/StoryQuestionPrompt/StoryQuestionPrompt.tsx
function StoryQuestionPrompt (line 6) | function StoryQuestionPrompt({
FILE: src/components/StoryQuestionSelectPhrase/StoryQuestionSelectPhrase.tsx
function StoryQuestionSelectPhrase (line 21) | function StoryQuestionSelectPhrase({
FILE: src/components/StoryTextLine/StoryTextLine.tsx
function StoryTextLine (line 19) | function StoryTextLine({
FILE: src/components/StoryTextLine/use-audio.hook.ts
type Window (line 9) | interface Window {
type UseAudioElement (line 14) | type UseAudioElement = StoryElementLine | StoryElementHeader;
function useAudio (line 16) | function useAudio(
FILE: src/components/StoryTextLineSimple/StoryTextLineSimple.tsx
function StoryTextLineSimple (line 4) | function StoryTextLineSimple({
FILE: src/components/StoryTitlePage/StoryTitlePage.tsx
function StoryTitlePage (line 6) | function StoryTitlePage({
FILE: src/components/VisuallyHidden/VisuallyHidden.tsx
type VisuallyHiddenProps (line 7) | interface VisuallyHiddenProps extends React.HTMLAttributes<HTMLSpanEleme...
FILE: src/components/WordButton/WordButton.tsx
function WordButton (line 4) | function WordButton({
FILE: src/components/editor/story/cast.tsx
function Cast (line 4) | function Cast(props: {
function Character (line 92) | function Character(props: {
FILE: src/components/editor/story/editor-resize.ts
function useResizeEditor (line 4) | function useResizeEditor(
FILE: src/components/editor/story/inline_tts.ts
type InlineTtsReplacement (line 1) | type InlineTtsReplacement = {
type InlineTtsError (line 8) | type InlineTtsError = {
function isInlineTtsSegmentBoundary (line 22) | function isInlineTtsSegmentBoundary(char: string | undefined) {
function createInlineTtsError (line 32) | function createInlineTtsError(
function formatInlineTtsError (line 41) | function formatInlineTtsError(
function scanInlineTts (line 49) | function scanInlineTts(text: string) {
FILE: src/components/editor/story/parser.test.ts
function getLineTokenTexts (line 7) | function getLineTokenTexts(tokens: Array<{ text: string; style: string }...
function parseLine (line 30) | function parseLine(
function parseStory (line 45) | function parseStory(
FILE: src/components/editor/story/parser.ts
constant STATE_DEFAULT (line 10) | const STATE_DEFAULT = "atom";
constant STATE_DATA_KEY (line 12) | const STATE_DATA_KEY = "heading";
constant STATE_DATA_VALUE (line 13) | const STATE_DATA_VALUE = "name";
constant STATE_TRANS_EVEN (line 15) | const STATE_TRANS_EVEN = "propertyName";
constant STATE_TRANS_ODD (line 16) | const STATE_TRANS_ODD = "macroName";
constant STATE_TEXT_EVEN (line 17) | const STATE_TEXT_EVEN = "tagName";
constant STATE_TEXT_ODD (line 18) | const STATE_TEXT_ODD = "name";
constant STATE_TEXT_HIDE_EVEN (line 20) | const STATE_TEXT_HIDE_EVEN = "className";
constant STATE_TEXT_HIDE_ODD (line 21) | const STATE_TEXT_HIDE_ODD = "typeName";
constant STATE_TEXT_HIDE_NEUTRAL (line 22) | const STATE_TEXT_HIDE_NEUTRAL = "changed";
constant STATE_TEXT_BUTTON_EVEN (line 24) | const STATE_TEXT_BUTTON_EVEN = "number";
constant STATE_TEXT_BUTTON_ODD (line 25) | const STATE_TEXT_BUTTON_ODD = "labelName";
constant STATE_TEXT_HIDE_BUTTON_EVEN (line 27) | const STATE_TEXT_HIDE_BUTTON_EVEN = "meta";
constant STATE_TEXT_HIDE_BUTTON_ODD (line 28) | const STATE_TEXT_HIDE_BUTTON_ODD = "comment";
constant STATE_TEXT_BUTTON_RIGHT_EVEN (line 29) | const STATE_TEXT_BUTTON_RIGHT_EVEN = "modifier";
constant STATE_BLOCK_TYPE (line 31) | const STATE_BLOCK_TYPE = "keyword";
constant STATE_SPEAKER_TYPE (line 32) | const STATE_SPEAKER_TYPE = STATE_DATA_KEY;
constant STATE_ERROR (line 34) | const STATE_ERROR = "deleted";
constant STATE_TODO (line 35) | const STATE_TODO = "annotation";
constant STATE_AUDIO (line 37) | const STATE_AUDIO = "color";
constant STATE_COMMENT (line 38) | const STATE_COMMENT = "comment";
function parserTextWithTranslation (line 220) | function parserTextWithTranslation(
function parserTranslation (line 291) | function parserTranslation(stream: StringStream, state: State) {
function parserPair (line 304) | function parserPair(stream: StringStream, state: State) {
function parseBlockData (line 316) | function parseBlockData(stream: StringStream, state: State) {
function parseBlockHeader (line 333) | function parseBlockHeader(stream: StringStream, state: State) {
function parseBlockLine (line 360) | function parseBlockLine(stream: StringStream, state: State) {
function startLine (line 395) | function startLine(
function setInlineTtsHighlightRanges (line 418) | function setInlineTtsHighlightRanges(
function hasInlineTtsHighlightError (line 434) | function hasInlineTtsHighlightError(state: State, start: number, end: nu...
function parseBlockSelectPhrase (line 440) | function parseBlockSelectPhrase(stream: StringStream, state: State) {
function parseBlockContinuation (line 497) | function parseBlockContinuation(stream: StringStream, state: State) {
function parseBlockMultipleChoice (line 552) | function parseBlockMultipleChoice(stream: StringStream, state: State) {
function parseBlockArrange (line 593) | function parseBlockArrange(stream: StringStream, state: State) {
function parseBlockPointToPhrase (line 644) | function parseBlockPointToPhrase(stream: StringStream, state: State) {
function parseBlockMatch (line 695) | function parseBlockMatch(stream: StringStream, state: State) {
constant BLOCK_FUNCS (line 731) | const BLOCK_FUNCS: Record<
function parseBlockDef (line 746) | function parseBlockDef(stream: StringStream, state: State) {
function parserWithMetadata (line 764) | function parserWithMetadata(stream: StringStream, state: State) {
type State (line 786) | type State = {
function createStartState (line 803) | function createStartState(): State {
function __testTokenizeLines (line 822) | function __testTokenizeLines(
method startState (line 848) | startState() {
function example (line 855) | function example() {
FILE: src/components/editor/story/scroll_linking.ts
function update_lines (line 5) | function update_lines(editor: HTMLElement, svg_parent: SVGElement | null) {
function createScrollLookUp (line 48) | function createScrollLookUp(
function map_side (line 73) | function map_side(
function useAutoResetRef (line 105) | function useAutoResetRef<T>() {
function useScrollLinking (line 123) | function useScrollLinking(
FILE: src/components/editor/story/syntax_parser_new.ts
type IpaReplacement (line 29) | type IpaReplacement = InlineTtsReplacement;
function generateHintMap (line 31) | function generateHintMap(
function hintsShift (line 156) | function hintsShift(content: ContentWithHints, pos: number): void {
function getButtons (line 165) | function getButtons(content: ContentWithHints): [string[], number[]] {
function regexIndexOf (line 194) | function regexIndexOf(
function removeDoubleBrackets (line 202) | function removeDoubleBrackets(
function getHideRanges (line 217) | function getHideRanges(content: ContentWithHints): HideRange[] {
function shuffleArray (line 243) | function shuffleArray(selectablePhrases: string[]): [number[], string[]] {
function split_lines (line 262) | function split_lines(text: string) {
function processBlockData (line 283) | function processBlockData(line_iter: LineIterator, story: StoryWithMeta) {
function splitTextTokens (line 353) | function splitTextTokens(text: string, keep_tilde = true) {
function splitTextTokens2 (line 365) | function splitTextTokens2(text: string, keep_tilde = true) {
function getInputStringText (line 374) | function getInputStringText(text: string) {
function speaker_text_trans (line 381) | function speaker_text_trans(
function line_to_audio (line 501) | function line_to_audio(
function get_avatar (line 546) | function get_avatar(
type Speaker (line 561) | type Speaker = {
function getText (line 572) | function getText(
function pushStoryErrorData (line 616) | function pushStoryErrorData(
function toErrorMessage (line 648) | function toErrorMessage(error: unknown) {
function safeSpeakerTextTrans (line 653) | function safeSpeakerTextTrans(
function getAnswers (line 682) | function getAnswers(
function pointToPhraseButtons (line 722) | function pointToPhraseButtons(line: string) {
function processBlockHeader (line 773) | function processBlockHeader(
function processBlockLine (line 809) | function processBlockLine(
function processBlockMultipleChoice (line 846) | function processBlockMultipleChoice(
function processBlockSelectPhrase (line 883) | function processBlockSelectPhrase(
function processBlockContinuation (line 954) | function processBlockContinuation(
function processBlockArrange (line 1030) | function processBlockArrange(
function processBlockPointToPhrase (line 1103) | function processBlockPointToPhrase(
function processBlockMatch (line 1168) | function processBlockMatch(
function line_iterator (line 1241) | function line_iterator(lines: LineTuple[]) {
type LineIterator (line 1255) | type LineIterator = ReturnType<typeof line_iterator>;
type StoryWithMeta (line 1257) | type StoryWithMeta = StoryType & {
type StoryType (line 1260) | type StoryType = {
type Meta (line 1265) | type Meta = {
type AvatarOverwrites (line 1291) | type AvatarOverwrites = {
type StoryLanguages (line 1298) | type StoryLanguages = {
type TranscribeData (line 1303) | type TranscribeData = string;
type LineTuple (line 1305) | type LineTuple = [number, string];
function isBlockHeaderLine (line 1307) | function isBlockHeaderLine(line: string | undefined) {
function consumeUnknownBlock (line 1311) | function consumeUnknownBlock(
function skipToNextBlock (line 1329) | function skipToNextBlock(line_iter: LineIterator) {
function processStoryFile (line 1343) | function processStoryFile(
FILE: src/components/editor/story/syntax_parser_types.ts
type Audio (line 1) | type Audio = {
type HintMapItem (line 16) | interface HintMapItem {
type HintMapResult (line 22) | interface HintMapResult {
type ContentWithHints (line 32) | interface ContentWithHints {
type HideRange (line 38) | interface HideRange {
type LineElementCharacter (line 43) | type LineElementCharacter = {
type LineElementProse (line 50) | type LineElementProse = {
type LineElementTitle (line 54) | type LineElementTitle = {
type LineElement (line 58) | type LineElement =
type StoryElementHeader (line 63) | type StoryElementHeader = {
type StoryElementLine (line 79) | type StoryElementLine = {
type StoryElementMultipleChoice (line 94) | type StoryElementMultipleChoice = {
type StoryElementChallengePrompt (line 112) | type StoryElementChallengePrompt = {
type StoryElementSelectPhrase (line 128) | type StoryElementSelectPhrase = {
type StoryElementArrange (line 140) | type StoryElementArrange = {
type StoryElementPointToPhrase (line 153) | type StoryElementPointToPhrase = {
type StoryElementMatch (line 172) | type StoryElementMatch = {
type StoryElementError (line 190) | type StoryElementError = {
type StoryElement (line 209) | type StoryElement =
FILE: src/components/icons.tsx
function IconDiscord (line 1) | function IconDiscord() {
function IconGithub (line 32) | function IconGithub() {
function IconOpenCollective (line 50) | function IconOpenCollective() {
function IconInstagram (line 73) | function IconInstagram() {
function IconTwitter (line 91) | function IconTwitter() {
function IconFacebook (line 109) | function IconFacebook() {
function IconGoogle (line 129) | function IconGoogle() {
function IconPlayStore (line 159) | function IconPlayStore() {
function GetIcon (line 177) | function GetIcon({ name }: { name: string }) {
FILE: src/components/layout/legal.tsx
function Legal (line 4) | function Legal({ language_name }: { language_name?: string }) {
FILE: src/components/login/LoggedInButtonWrappedClient.tsx
function LoggedInButtonWrappedClient (line 7) | function LoggedInButtonWrappedClient(props: {
FILE: src/components/login/loggedinbutton.tsx
function themeToLightOrDark (line 19) | function themeToLightOrDark(
function get_current_theme (line 27) | function get_current_theme(): "light" | "dark" | undefined {
function useDarkLight (line 53) | function useDarkLight() {
function LogInButton (line 76) | function LogInButton() {
function LoggedInButton (line 85) | function LoggedInButton({
FILE: src/components/providers/ConvexClientProvider.tsx
function ConvexClientProvider (line 8) | function ConvexClientProvider({
FILE: src/components/providers/PostHogUserIdentifier.tsx
constant PENDING_SIGNIN_STORAGE_KEY (line 8) | const PENDING_SIGNIN_STORAGE_KEY = "posthog_pending_signin";
type SessionUser (line 10) | type SessionUser = {
type PendingSignInPayload (line 18) | type PendingSignInPayload = {
function PostHogUserIdentifier (line 23) | function PostHogUserIdentifier() {
FILE: src/components/ui/badge.tsx
function Badge (line 4) | function Badge({
FILE: src/components/ui/button.tsx
type ButtonVariant (line 4) | type ButtonVariant =
type ButtonSize (line 14) | type ButtonSize = "default" | "sm" | "lg";
type ButtonProps (line 16) | type ButtonProps = {
function resolveVariant (line 23) | function resolveVariant({
function buttonRootClassName (line 37) | function buttonRootClassName({
function buttonInnerClassName (line 73) | function buttonInnerClassName({
FILE: src/components/ui/dialog.tsx
function Dialog (line 9) | function Dialog({
function DialogTrigger (line 15) | function DialogTrigger({
function DialogPortal (line 21) | function DialogPortal({
function DialogClose (line 27) | function DialogClose({
function DialogOverlay (line 33) | function DialogOverlay({
function DialogContent (line 57) | function DialogContent({
function DialogHeader (line 107) | function DialogHeader({ className, ...props }: React.ComponentProps<"div...
function DialogFooter (line 117) | function DialogFooter({
function DialogTitle (line 142) | function DialogTitle({
function DialogDescription (line 155) | function DialogDescription({
FILE: src/components/ui/flag.tsx
function Flag (line 4) | function Flag(props: {
FILE: src/components/ui/input.tsx
type InputProps (line 4) | type InputProps = {
FILE: src/components/ui/kbd.tsx
function Kbd (line 3) | function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
function KbdGroup (line 18) | function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
FILE: src/components/ui/language-flag.tsx
type FlagProps (line 9) | type FlagProps = ComponentProps<typeof Flag>;
type LanguageFlagEntry (line 11) | type LanguageFlagEntry = {
function useLanguageFlag (line 18) | function useLanguageFlag(languageId?: Id<"languages"> | string) {
function LanguageFlag (line 29) | function LanguageFlag({
FILE: src/components/ui/shadcn/dropdown-menu.tsx
function DropdownMenu (line 8) | function DropdownMenu({
function DropdownMenuTrigger (line 14) | function DropdownMenuTrigger({
function DropdownMenuPortal (line 25) | function DropdownMenuPortal({
function DropdownMenuContent (line 33) | function DropdownMenuContent({
function DropdownMenuItem (line 53) | function DropdownMenuItem({
function DropdownMenuSeparator (line 73) | function DropdownMenuSeparator({
FILE: src/components/ui/sheet.tsx
function Sheet (line 9) | function Sheet(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
function SheetTrigger (line 13) | function SheetTrigger(
function SheetClose (line 19) | function SheetClose(props: React.ComponentProps<typeof DialogPrimitive.C...
function SheetPortal (line 23) | function SheetPortal(
function SheetOverlay (line 29) | function SheetOverlay({
function SheetContent (line 45) | function SheetContent({
function SheetHeader (line 82) | function SheetHeader({ className, ...props }: React.ComponentProps<"div"...
function SheetTitle (line 92) | function SheetTitle({
function SheetDescription (line 105) | function SheetDescription({
FILE: src/components/ui/spinner.tsx
function Spinner (line 3) | function Spinner() {
function SpinnerBlue (line 15) | function SpinnerBlue() {
FILE: src/components/ui/switch.tsx
function Switch (line 1) | function Switch({
FILE: src/hooks/use-choice-buttons.hook.ts
function useChoiceButtons (line 5) | function useChoiceButtons(
FILE: src/hooks/use-keypress.hook.ts
constant NUMBERS (line 3) | const NUMBERS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
function useKeypress (line 5) | function useKeypress(
FILE: src/hooks/use-scroll-into-view.hook.ts
function useScrollIntoView (line 3) | function useScrollIntoView(condition: boolean) {
FILE: src/lib/audio/client-audio-processing.ts
constant DEFAULT_NORMALIZATION_TARGET_PEAK (line 3) | const DEFAULT_NORMALIZATION_TARGET_PEAK = 0.97;
constant MP3_BITRATE_KBPS (line 4) | const MP3_BITRATE_KBPS = 128;
constant MP3_SAMPLE_BLOCK_SIZE (line 5) | const MP3_SAMPLE_BLOCK_SIZE = 1152;
function clamp (line 7) | function clamp(value: number, min: number, max: number) {
function float32ToInt16Sample (line 11) | function float32ToInt16Sample(sample: number) {
function toPlainArrayBuffer (line 18) | function toPlainArrayBuffer(view: Uint8Array | Int8Array) {
function decodeAudioData (line 26) | async function decodeAudioData(arrayBuffer: ArrayBuffer) {
function encodeAudioBufferAsMp3 (line 35) | async function encodeAudioBufferAsMp3(buffer: AudioBuffer) {
function normalizeAudioBufferPeak (line 91) | function normalizeAudioBufferPeak(
FILE: src/lib/editor/audio/audio_edit_tools.ts
function generate_ssml_line (line 17) | function generate_ssml_line(
function generate_audio_line (line 179) | async function generate_audio_line(ssml: {
type AudioInsertAnchor (line 281) | type AudioInsertAnchor = {
function content_to_audio (line 287) | function content_to_audio(content: string) {
function timings_to_text (line 300) | function timings_to_text({
function timing_text_without_filename (line 323) | function timing_text_without_filename(text: string) {
function text_to_keypoints (line 329) | function text_to_keypoints(line: string) {
function insert_audio_line (line 349) | function insert_audio_line(
function create_audio_insert_anchor (line 365) | function create_audio_insert_anchor(
function map_audio_insert_anchor (line 398) | function map_audio_insert_anchor(
function insert_audio_at_anchor (line 414) | function insert_audio_at_anchor(
function insert_audio_lines (line 436) | function insert_audio_lines(
FILE: src/lib/editor/audio/text_with_mapping.ts
type Mapping (line 4) | type Mapping = number[];
type MappedText (line 5) | type MappedText = { text: string; mapping: Mapping };
function init_mapping (line 7) | function init_mapping(text: string) {
function replace_with_mapping (line 15) | function replace_with_mapping(
function find_replace_with_mapping (line 49) | function find_replace_with_mapping(
function apply_letter_replacements (line 99) | function apply_letter_replacements(
function iter_word_replacements (line 128) | function iter_word_replacements(
function apply_word_replacements (line 194) | function apply_word_replacements(
function add_word_marks_replacements (line 246) | function add_word_marks_replacements(mapped_text: {
function splitTextTokens (line 268) | function splitTextTokens(text: string, keep_tilde = true) {
function apply_fragment_replacements (line 280) | function apply_fragment_replacements(
function apply_group (line 314) | function apply_group(
function transcribe_text (line 368) | function transcribe_text(
FILE: src/lib/editor/editorHandlers.ts
type EditorBlock (line 3) | interface EditorBlock {
type EditorProps (line 10) | interface EditorProps {
function getEditorHandlers (line 15) | function getEditorHandlers(editorProps?: EditorProps): {
FILE: src/lib/editor/tts_transcripte.ts
function splitTextTokens (line 27) | function splitTextTokens(
type TranscribeConfig (line 389) | interface TranscribeConfig {
function transcribe_text (line 395) | function transcribe_text(text: string, dataYaml: string): [string, numbe...
FILE: src/lib/fetch_post.ts
function fetch_post (line 1) | async function fetch_post(
FILE: src/lib/getUserId.ts
function getUserId (line 3) | async function getUserId() {
FILE: src/lib/get_localisation.ts
function get_localisation_entries_by_convex_language_id (line 21) | async function get_localisation_entries_by_convex_language_id(
function get_localisation_entries_by_legacy_language_id (line 32) | async function get_localisation_entries_by_legacy_language_id(
function get_localisation_by_convex_language_id (line 52) | async function get_localisation_by_convex_language_id(
FILE: src/lib/get_localisation_func.tsx
type LocalisationFunc (line 4) | type LocalisationFunc = ReturnType<typeof get_localisation_func>;
function get_localisation_func (line 6) | function get_localisation_func(data: Record<string, string>) {
function insetWithNewlines (line 23) | function insetWithNewlines(text: string) {
function replaceLinks (line 39) | function replaceLinks(text: string, links: string[]) {
function replaceTags (line 55) | function replaceTags(text: string, tags: Record<string, string>) {
FILE: src/lib/hooks.ts
function useInput (line 3) | function useInput(
FILE: src/lib/is-typing-target.ts
function isTypingTarget (line 1) | function isTypingTarget(
FILE: src/lib/lamejs-compat.ts
type LamejsEncoder (line 1) | type LamejsEncoder = {
type LamejsModule (line 6) | type LamejsModule = {
type CjsModuleNamespace (line 14) | type CjsModuleNamespace = {
type LamejsDependencyLoader (line 18) | type LamejsDependencyLoader = readonly [
constant LAMEJS_GLOBAL_DEPENDENCIES (line 23) | const LAMEJS_GLOBAL_DEPENDENCIES: readonly LamejsDependencyLoader[] = [
function unwrapCjsModule (line 40) | function unwrapCjsModule(module: unknown) {
function exposeLamejsGlobals (line 44) | async function exposeLamejsGlobals() {
function getLamejsModule (line 50) | async function getLamejsModule() {
FILE: src/lib/posthog-server.ts
type PostHogLike (line 9) | type PostHogLike = Pick<PostHog, "capture" | "shutdown">;
function getPostHogClient (line 11) | function getPostHogClient(): PostHogLike {
function shutdownPostHog (line 27) | async function shutdownPostHog() {
FILE: src/lib/posthog-user.ts
type PostHogUser (line 6) | type PostHogUser = {
function identifyPostHogUser (line 14) | function identifyPostHogUser(user: PostHogUser | null | undefined) {
function getCurrentPostHogUser (line 35) | async function getCurrentPostHogUser() {
function resetPostHogUser (line 45) | function resetPostHogUser() {
FILE: src/lib/shuffle.ts
function setSeed (line 7) | function setSeed(seed: number) {
function randomSeeded (line 11) | function randomSeeded() {
function shuffle (line 16) | function shuffle<T>(a: T[]): T[] {
FILE: src/lib/sound-effects.ts
constant SOUND_EFFECT_URLS (line 1) | const SOUND_EFFECT_URLS = {
type SoundEffectName (line 7) | type SoundEffectName = keyof typeof SOUND_EFFECT_URLS;
function playSoundEffect (line 11) | function playSoundEffect(name: SoundEffectName) {
FILE: src/lib/story-preferences.ts
constant HIDE_STORY_QUESTIONS_COOKIE (line 1) | const HIDE_STORY_QUESTIONS_COOKIE = "hide_story_questions";
function isStoryQuestionsDisabled (line 3) | function isStoryQuestionsDisabled(
FILE: src/lib/story-search.ts
type StorySearchable (line 1) | type StorySearchable = {
type StorySearchState (line 9) | type StorySearchState = "draft" | "feedback" | "finished" | "published";
type ParsedStorySearch (line 11) | type ParsedStorySearch = {
type StorySearchOptions (line 18) | type StorySearchOptions = {
function parseStorySearch (line 22) | function parseStorySearch(
function matchesStorySearch (line 115) | function matchesStorySearch(
function formatStorySetLabel (line 160) | function formatStorySetLabel(
function splitStatusTokens (line 166) | function splitStatusTokens(searchQuery: string): {
function normalizeStorySearchStateToken (line 193) | function normalizeStorySearchStateToken(
function matchesStatusPrefix (line 261) | function matchesStatusPrefix(rawStatus: string, acceptedValues: string[]) {
function getStorySearchState (line 265) | function getStorySearchState(
FILE: src/lib/userInterface.ts
type AuthUser (line 6) | type AuthUser = {
type AppUser (line 17) | type AppUser = Omit<AuthUser, "role" | "userId"> & {
function getUser (line 41) | async function getUser(
function requireAdmin (line 71) | async function requireAdmin() {
type RoleLike (line 77) | type RoleLike =
function isAdmin (line 86) | function isAdmin(user: RoleLike) {
function isContributor (line 93) | function isContributor(user: RoleLike) {
FILE: src/lib/utils.ts
function cn (line 4) | function cn(...inputs: ClassValue[]) {
FILE: src/types/lamejs.d.ts
class Mp3Encoder (line 2) | class Mp3Encoder {
Condensed preview — 429 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,762K chars).
[
{
"path": ".codex/environments/environment.toml",
"chars": 290,
"preview": "# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY\nversion = 1\nname = \"unofficial-duolingo-stories\"\n\n[setup]\nscript = '''\npnp"
},
{
"path": ".github/FUNDING.yml",
"chars": 765,
"preview": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [u"
},
{
"path": ".github/workflows/build.yml",
"chars": 3652,
"preview": "# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tes"
},
{
"path": ".github/workflows/build_pr.yml",
"chars": 1945,
"preview": "# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tes"
},
{
"path": ".github/workflows/ci.yaml",
"chars": 681,
"preview": "name: CI\n\non: [push]\n\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n uses: actions/"
},
{
"path": ".gitignore",
"chars": 855,
"preview": ".idea/\nsql_dump/\n/audio/rootkey.csv\n/audio/tts/rootkey.csv\nnode_modules/\nrootkey.csv\nlib_swift/\ndist/\n\n# See https://hel"
},
{
"path": "AGENTS.md",
"chars": 1374,
"preview": "# Agent Notes\n\n## Formatting\n- Run `pnpm run format` after code edits in this repository.\n- Use `pnpm run format:check` "
},
{
"path": "CLAUDE.md",
"chars": 4521,
"preview": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## "
},
{
"path": "CONTEXT.md",
"chars": 13933,
"preview": "# Unofficial Duolingo Stories\n\nThis context describes the domain language for publishing and editing community-translate"
},
{
"path": "README.md",
"chars": 3288,
"preview": "# Unofficial Duolingo Stories\n\n[;\n\nexport default compo"
},
{
"path": "convex/betterAuth/schema.ts",
"chars": 3376,
"preview": "/**\n * This file is auto-generated. Do not edit this file manually.\n * To regenerate the schema, run:\n * `npx @better-au"
},
{
"path": "convex/convex-env.d.ts",
"chars": 215,
"preview": "declare namespace NodeJS {\n interface ProcessEnv {\n [key: string]: string | undefined;\n }\n\n interface Process {\n "
},
{
"path": "convex/convex.config.ts",
"chars": 165,
"preview": "import { defineApp } from \"convex/server\";\nimport betterAuth from \"./betterAuth/convex.config\";\n\nconst app = defineApp()"
},
{
"path": "convex/convex_rules.md",
"chars": 26224,
"preview": "# Convex guidelines\n\n## Function guidelines\n\n### New function syntax\n\n- ALWAYS use the new function syntax for Convex fu"
},
{
"path": "convex/courseContributorBackfill.ts",
"chars": 4857,
"preview": "import { v } from \"convex/values\";\nimport { internal } from \"./_generated/api\";\nimport { httpAction, internalMutation } "
},
{
"path": "convex/courseWrite.ts",
"chars": 911,
"preview": "import { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { requireContributorOrAdmin } "
},
{
"path": "convex/discordAvatarSync.ts",
"chars": 8631,
"preview": "import {\n action,\n httpAction,\n internalAction,\n type ActionCtx,\n} from \"./_generated/server\";\nimport { v } from \"co"
},
{
"path": "convex/discordBot.ts",
"chars": 9719,
"preview": "import { components, internal } from \"./_generated/api\";\nimport { httpAction } from \"./_generated/server\";\n\ntype Role = "
},
{
"path": "convex/discordData.ts",
"chars": 7739,
"preview": "import { paginationOptsValidator } from \"convex/server\";\nimport { v } from \"convex/values\";\nimport { components } from \""
},
{
"path": "convex/discordRoleSync.ts",
"chars": 1363,
"preview": "import { internalMutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nconst syncStatusValidator = "
},
{
"path": "convex/editorRead.ts",
"chars": 22490,
"preview": "import { query, type QueryCtx } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { components } fro"
},
{
"path": "convex/editorSideEffects.ts",
"chars": 7793,
"preview": "\"use node\";\n\nimport { Octokit } from \"@octokit/rest\";\nimport { PostHog } from \"posthog-node\";\nimport { internalAction } "
},
{
"path": "convex/http.ts",
"chars": 1120,
"preview": "import { httpRouter } from \"convex/server\";\nimport { backfillCourseContributorDetailsHttp } from \"./courseContributorBac"
},
{
"path": "convex/landing.ts",
"chars": 15505,
"preview": "import { query } from \"./_generated/server\";\nimport type { Id } from \"./_generated/dataModel\";\nimport { v } from \"convex"
},
{
"path": "convex/languageWrite.ts",
"chars": 7515,
"preview": "import { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport type { MutationCtx } from \"./_"
},
{
"path": "convex/lib/authorization.ts",
"chars": 1567,
"preview": "import type { MutationCtx, QueryCtx } from \"../_generated/server\";\n\ntype AuthCtx = MutationCtx | QueryCtx;\n\ntype RoleIde"
},
{
"path": "convex/lib/courseContributors.ts",
"chars": 5402,
"preview": "import { v } from \"convex/values\";\nimport { components } from \"../_generated/api\";\nimport type { Id } from \"../_generate"
},
{
"path": "convex/lib/courseCounts.ts",
"chars": 657,
"preview": "import type { Id } from \"../_generated/dataModel\";\nimport type { MutationCtx } from \"../_generated/server\";\n\nexport asyn"
},
{
"path": "convex/lib/discordAvatarSync.ts",
"chars": 6708,
"preview": "type DiscordAccountRecord = {\n accountId?: string | null;\n accessToken?: string | null;\n refreshToken?: string | null"
},
{
"path": "convex/lib/phpbb.ts",
"chars": 2927,
"preview": "import md5Raw from \"js-md5\";\n\nconst encoder = new TextEncoder();\n\nfunction md5Hex(content: string): string {\n return by"
},
{
"path": "convex/lib/publicStoryContent.ts",
"chars": 3258,
"preview": "import type { Id } from \"../_generated/dataModel\";\nimport type { MutationCtx, QueryCtx } from \"../_generated/server\";\n\nt"
},
{
"path": "convex/localization.ts",
"chars": 3699,
"preview": "import { query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nconst localizationEntryValidator = v.ob"
},
{
"path": "convex/localizationWrite.ts",
"chars": 1684,
"preview": "import { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { requireContributorOrAdmin } "
},
{
"path": "convex/lookupTables.ts",
"chars": 10977,
"preview": "import { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { requireContributorOrAdmin } "
},
{
"path": "convex/roles.ts",
"chars": 1624,
"preview": "import { action } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { components } from \"./_generate"
},
{
"path": "convex/schema.ts",
"chars": 8572,
"preview": "import { defineSchema, defineTable } from \"convex/server\";\nimport { v } from \"convex/values\";\n\nconst courseContributorDe"
},
{
"path": "convex/storyApproval.ts",
"chars": 8618,
"preview": "import { mutation, query, type MutationCtx } from \"./_generated/server\";\nimport type { Id } from \"./_generated/dataModel"
},
{
"path": "convex/storyDone.ts",
"chars": 10385,
"preview": "import { mutation, query } from \"./_generated/server\";\nimport type { Id } from \"./_generated/dataModel\";\nimport type { M"
},
{
"path": "convex/storyPublicContent.ts",
"chars": 972,
"preview": "import { paginationOptsValidator } from \"convex/server\";\nimport { v } from \"convex/values\";\nimport { mutation } from \"./"
},
{
"path": "convex/storyRead.ts",
"chars": 5417,
"preview": "import { query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { getPublicStoryJson } from \"./li"
},
{
"path": "convex/storyTables.ts",
"chars": 4561,
"preview": "import { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { requireContributorOrAdmin } "
},
{
"path": "convex/storyWrite.ts",
"chars": 9956,
"preview": "import { internal } from \"./_generated/api\";\nimport { mutation } from \"./_generated/server\";\nimport { v } from \"convex/v"
},
{
"path": "convex/tsconfig.json",
"chars": 427,
"preview": "{\n \"compilerOptions\": {\n \"allowJs\": true,\n \"strict\": true,\n \"moduleResolution\": \"Bundler\",\n \"jsx\": \"react-j"
},
{
"path": "convex/tsconfig.node.json",
"chars": 126,
"preview": "{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"types\": [\"node\"]\n },\n \"include\": [\"./editorSideEffects.t"
},
{
"path": "convex/userPreferences.ts",
"chars": 2298,
"preview": "import { query, mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { getSessionLegacyUserI"
},
{
"path": "database/stories/es-en-o/1_1_es-en-buenos-dias.txt",
"chars": 3679,
"preview": "[DATA]\nfromLanguageName=Good Morning\nicon=783305780a6dad8e0e4eb34109d948e6a5fc2c35\nset=1|1\n\n[HEADER]\n> 早上好 \n~ good~morni"
},
{
"path": "database/stories/es-en-o/1_2_es-en-una-cita.txt",
"chars": 2914,
"preview": "[DATA]\nfromLanguageName=A Date\nicon=df24f7756b139f6eda927eb776621b9febe1a3f1\nset=1|2\n\n[HEADER]\n> Una cita\n~ a date\n\n[L"
},
{
"path": "database/stories/es-en-o/1_3_es-en-una-cosa.txt",
"chars": 2221,
"preview": "[DATA]\nfromLanguageName=One Thing\nicon=717bd84875f83c678f64f124937a278061e0e778\nset=1|3\n\n[HEADER]\n> Una cosa \n~ one thin"
},
{
"path": "database/stories/nl-en/0_1_es-en-el-pastel-de-dragones.txt",
"chars": 3156,
"preview": "[DATA]\nfromLanguageName=The Dragon Cake\nicon=c2fbecd45802dc974e3ec727a0206cfec66fd87f\nset=0|1 #28|1\ndeleted=1\n\n[HEADER]\n"
},
{
"path": "database/stories/nl-en/0_2_es-en-es-amor.txt",
"chars": 3793,
"preview": "[DATA]\nfromLanguageName=Is It Love?\nicon=d7461e0ccc18067f34ff9c385653ac769bde4875\nset=0|2 #28|2\ndeleted=1\n\n[HEADER]\n> ¿E"
},
{
"path": "database/stories/nl-en/0_3_es-en-la-pelea-de-boxeo.txt",
"chars": 2871,
"preview": "[DATA]\nfromLanguageName=The Boxing Match\nicon=149224cb1a7c361279bece453d459dc57ba243a5\nset=0|3 #28|3\ndeleted=1\n\n[HEADER]"
},
{
"path": "database/stories/nl-en/0_4_es-en-el-neumatico-pinchado.txt",
"chars": 3528,
"preview": "[DATA]\nfromLanguageName=The Flat Tire\nicon=76e5ba84900b1c2ebd321eafd9329980919ccede\nset=0|4 #28|4\ndeleted=1\n\n[HEADER]\n> "
},
{
"path": "database/stories/nl-en/1_1_es-buenos-dias.txt",
"chars": 3838,
"preview": "[DATA]\nfromLanguageName=Good morning\nicon=783305780a6dad8e0e4eb34109d948e6a5fc2c35\nset=1|1\napprovals=11,12\npublic=1\n\nico"
},
{
"path": "database/stories/nl-en/1_2_es-una-cita.txt",
"chars": 3834,
"preview": "[DATA]\nfromLanguageName=A date\nicon=df24f7756b139f6eda927eb776621b9febe1a3f1\nset=1|2\napprovals=11,12\npublic=1\n\nspeaker_J"
},
{
"path": "database/stories/nl-en/1_3_es-una-cosa.txt",
"chars": 3417,
"preview": "[DATA]\nfromLanguageName=One thing\nicon=717bd84875f83c678f64f124937a278061e0e778\nset=1|3\napprovals=11,12\npublic=1\n\nicon_J"
},
{
"path": "database/stories/nl-en/1_4_es-en-la-luna-de-miel.txt",
"chars": 4191,
"preview": "[DATA]\nfromLanguageName=The honeymoon\nicon=7e5d271488d31d6f1d0c503512e642ca7effe84f\nset=1|4\napprovals=11,12\npublic=1\n\nsp"
},
{
"path": "database/stories/nl-en/2_1_es-en-la-chaqueta-roja.txt",
"chars": 3698,
"preview": "[DATA]\nfromLanguageName=The red jacket\nicon=5361833c123aec9adfa60b0dc63398cd1aa49ef2\nset=2|1\napprovals=11,12\n\nicon_Natal"
},
{
"path": "database/stories/nl-en/2_2_es-en-el-pasaporte.txt",
"chars": 2941,
"preview": "[DATA]\nfromLanguageName=The Passport\nicon=643347b755001a918130ddf5f6d3e914e63a00ce\nset=2|2\napprovals=11,12\n\n[HEADER]\n> H"
},
{
"path": "database/stories/nl-en/2_3_es-en-una-familia-muy-grande.txt",
"chars": 3366,
"preview": "[DATA]\nfromLanguageName=A Very Big Family\nicon=9a2dcd1a9eaff04d1e9b4338e9afcead94c365bf\nset=2|3\napprovals=11,12\n\nspeaker"
},
{
"path": "database/stories/nl-en/2_4_es-en-el-doctor-eddy.txt",
"chars": 3316,
"preview": "[DATA]\nfromLanguageName=Doctor Eddy\nicon=29c5abbf74b46e43a4de510ac83e302c0722a100\nset=2|4\napprovals=11\n\n[HEADER]\n> Dokte"
},
{
"path": "database/stories/test-en/1_1_es-en-buenos-dias.txt",
"chars": 1358,
"preview": "[DATA]\nfromLanguageName=Testing\nicon=783305780a6dad8e0e4eb34109d948e6a5fc2c35\nset=1|1\napprovals=11,12\npublic=1\n\n[HEADER]"
},
{
"path": "database/stories/test-en/1_2_es-en-una-cita.txt",
"chars": 203,
"preview": "[DATA]\nfromLanguageName=Minimal Example\nicon=df24f7756b139f6eda927eb776621b9febe1a3f1\nset=1|2\napprovals=11,12\npublic=1\n\n"
},
{
"path": "discord_roles/CONTEXT.md",
"chars": 1293,
"preview": "# Discord Roles\n\nThis context describes Discord-side contributor onboarding and role language that should stay separate "
},
{
"path": "discord_roles/audio_cleanup.py",
"chars": 989,
"preview": "import mysql.connector\nimport re\nimport os\nfrom pathlib import Path\nimport shutil\n\nmydb = mysql.connector.connect(\n hos"
},
{
"path": "discord_roles/blame.py",
"chars": 4793,
"preview": "import subprocess\nfrom pathlib import Path\nimport pandas as pd\nimport time\nimport os\n\n\ndef decode_git_stdout(result):\n "
},
{
"path": "discord_roles/combine.py",
"chars": 11250,
"preview": "import json\nfrom pathlib import Path\nfrom urllib import error, request\n\nimport pandas as pd\nfrom env_utils import load_e"
},
{
"path": "discord_roles/discord_bot.py",
"chars": 7416,
"preview": "import asyncio\nimport discord\nimport json\nimport time\nfrom urllib import error, request\nfrom pathlib import Path\nfrom en"
},
{
"path": "discord_roles/discord_reacting_bot.py",
"chars": 11064,
"preview": "import discord\nimport json\nfrom urllib import error, request\nfrom pathlib import Path\nfrom env_utils import load_env_fil"
},
{
"path": "discord_roles/env_utils.py",
"chars": 568,
"preview": "from pathlib import Path\n\n\ndef load_env_file(path: Path) -> dict[str, str]:\n env: dict[str, str] = {}\n for raw_lin"
},
{
"path": "discord_roles/requirements.txt",
"chars": 44,
"preview": "mysql-connector-python\npandas\nnumpy\ndiscord\n"
},
{
"path": "docs/bulk-audio-editor-spec.md",
"chars": 2920,
"preview": "# Bulk Audio Editor Spec\n\n## Goal\n\nCreate a dedicated story-level audio workspace that lets editors upload many audio fi"
},
{
"path": "import_tools/README.md",
"chars": 92,
"preview": "# Import Tools\n\nThis folder contains tools to import the stories from the Duolingo website.\n"
},
{
"path": "import_tools/app.py",
"chars": 834,
"preview": "from flask import Flask\nfrom flask import Flask, render_template, send_from_directory\nimport os\nfrom flask import reques"
},
{
"path": "import_tools/greasmonkey.js",
"chars": 2582,
"preview": "// ==UserScript==\n// @name DuolingoImport\n// @version 1\n// @include https*duolingo*\n// @grant none\n// ==/UserSc"
},
{
"path": "instrumentation-client.ts",
"chars": 484,
"preview": "import posthog from \"posthog-js\";\n\nconst posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;\n\nif (posthogKey) {\n posthog."
},
{
"path": "jsconfig.json",
"chars": 77,
"preview": "{\n \"compilerOptions\": {\n \"paths\": {\n \"@/*\": [\"./src/*\"]\n }\n }\n}\n"
},
{
"path": "knip.json",
"chars": 747,
"preview": "{\n \"$schema\": \"https://unpkg.com/knip@latest/schema.json\",\n \"entry\": [\n \"scripts/backfill-course-contributors.ts\",\n"
},
{
"path": "next.config.js",
"chars": 914,
"preview": "module.exports = {\n // next.js config\n reactCompiler: true,\n compiler: {\n styledComponents: true,\n },\n typescrip"
},
{
"path": "package.json",
"chars": 2513,
"preview": "{\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next\",\n \"build\": \"next build\",\n \"start\": \"next start\",\n \"test\":"
},
{
"path": "postcss.config.mjs",
"chars": 94,
"preview": "const config = {\n plugins: {\n \"@tailwindcss/postcss\": {},\n },\n};\n\nexport default config;\n"
},
{
"path": "process.d.ts",
"chars": 92,
"preview": "declare module \"*.svg\" {\n const src: string;\n export default src;\n}\n\ndeclare module \"ws\";\n"
},
{
"path": "public/.well-known/assetlinks.json",
"chars": 415,
"preview": "[\n {\n \"relation\": [\"delegate_permission/common.handle_all_urls\"],\n \"target\": {\n \"namespace\": \"android_app\",\n"
},
{
"path": "public/darklight.js",
"chars": 816,
"preview": "function get_current_theme() {\n // it's currently saved in the document?\n if (document.body.dataset.theme) {\n retur"
},
{
"path": "public/docs/audio-generation/character-editor.mdx",
"chars": 1788,
"preview": "---\ntitle: \"Character Editor\"\ndescription: \"Assign voices to characters.\"\n---\n\nIn the character editor you can assign vo"
},
{
"path": "public/docs/audio-generation/edit.mdx",
"chars": 1728,
"preview": "---\ntitle: \"TTS Edit\"\ndescription: \"Change the audio generation with replacement rules.\"\n---\n\nSometimes the generated au"
},
{
"path": "public/docs/audio-generation/engines.mdx",
"chars": 905,
"preview": "---\ntitle: \"Speech Engines\"\ndescription: \"There are different text-to-speech (TTS) engines that Duostories can use.\"\n---"
},
{
"path": "public/docs/audio-generation/fix-problems.mdx",
"chars": 1476,
"preview": "---\ntitle: \"Fix Problems\"\ndescription: \"What do I do if the TTS is pronouncing words incorrectly?\"\n---\n\n### Use another "
},
{
"path": "public/docs/audio-generation/generate.mdx",
"chars": 1086,
"preview": "---\ntitle: \"Generate Audio\"\ndescription: \"How to apply the voices in a story.\"\n---\n\nIn the story, click the Audio button"
},
{
"path": "public/docs/audio-generation/overview.mdx",
"chars": 478,
"preview": "---\ntitle: \"Overview\"\ndescription: \"The basic steps to generate audio.\"\n---\n\nTo make the Duostories more engaging we use"
},
{
"path": "public/docs/become-contributor/application.mdx",
"chars": 2637,
"preview": "---\ntitle: \"Application\"\ndescription: \"Join our community as a contributor.\"\n---\n\nIf you would like to contribute by tra"
},
{
"path": "public/docs/become-contributor/colang.mdx",
"chars": 2480,
"preview": "---\ntitle: \"Conlangs/Dialects\"\ndescription: \"Does your language qualify for duostories.\"\n---\n\n### Conlangs\n#### If my la"
},
{
"path": "public/docs/docs.json",
"chars": 1016,
"preview": "{\n \"navigation\": [\n {\n \"group\": \"\",\n \"pages\": [\"introduction\"]\n },\n {\n \"group\": \"Become a Contr"
},
{
"path": "public/docs/introduction.mdx",
"chars": 276,
"preview": "---\ntitle: \"Introduction\"\ndescription: \"The guide to contributing to duostories.\"\n---\n\nWelcome to Duostories Documentati"
},
{
"path": "public/docs/search.js",
"chars": 3701,
"preview": "let data = null;\nlet pages = null;\n\nasync function getPageData(page) {\n try {\n const res = await fetch(\"/docs/\" + pa"
},
{
"path": "public/docs/story-creation/import.mdx",
"chars": 2936,
"preview": "---\ntitle: \"Import Story\"\ndescription: \"Initialize a new story translation.\"\n---\n\nClick the import button in the menu fo"
},
{
"path": "public/docs/story-creation/translate.mdx",
"chars": 1894,
"preview": "---\ntitle: \"How to Translate\"\ndescription: \"What to consider when translating.\"\n---\n\n### Translating the Story\n\nTo trans"
},
{
"path": "public/docs/story-editing/exercises.mdx",
"chars": 3465,
"preview": "---\ntitle: \"Exercises\"\ndescription: \"Reading comprehension questions to engage the learner.\"\n---\n\nThe Duostories are lik"
},
{
"path": "public/docs/story-editing/overview.mdx",
"chars": 1729,
"preview": "---\ntitle: \"Overview\"\ndescription: \"The editor with the two views.\"\n---\n\nDuostories is not like the software *Word* wher"
},
{
"path": "public/docs/story-editing/translation-hints.mdx",
"chars": 3174,
"preview": "---\ntitle: \"Translation Hints\"\ndescription: \"How to provide translation hints.\"\n---\n\nAn important part of learning with "
},
{
"path": "public/docs/story-publishing/publishing.mdx",
"chars": 2610,
"preview": "---\ntitle: \"Publish a Story\"\ndescription: \"The review process for publishing.\"\n---\n\nTo make sure that the translated sto"
},
{
"path": "public/docs/story-publishing/without_tts.mdx",
"chars": 2857,
"preview": "---\ntitle: \"Publish without Audio\"\ndescription: \"How to publish stories without TTS audio.\"\n---\n\nOur goal is to have aud"
},
{
"path": "public/robots.txt",
"chars": 173,
"preview": "# Block all crawlers for /editor\nUser-agent: *\nDisallow: /editor\n\n# Block all crawlers for /admin\nUser-agent: *\nDisallow"
},
{
"path": "public/sw.js",
"chars": 2954,
"preview": "/**\n * Copyright 2018 Google Inc. All Rights Reserved.\n * Licensed under the Apache License, Version 2.0 (the \"License\")"
},
{
"path": "scripts/backfill-course-contributors.ts",
"chars": 3941,
"preview": "import dotenv from \"dotenv\";\n\ndotenv.config({ path: \".env.local\" });\n\nconst CONVEX_SITE_URL =\n process.env.NEXT_PUBLIC_"
},
{
"path": "scripts/backfill-discord-avatars.ts",
"chars": 5063,
"preview": "import dotenv from \"dotenv\";\n\ndotenv.config({ path: \".env.local\" });\n\nconst CONVEX_SITE_URL =\n process.env.NEXT_PUBLIC_"
},
{
"path": "scripts/find-missing-story-images.ts",
"chars": 6114,
"preview": "import dotenv from \"dotenv\";\nimport { ConvexHttpClient } from \"convex/browser\";\nimport { api } from \"../convex/_generate"
},
{
"path": "skills-lock.json",
"chars": 1565,
"preview": "{\n \"version\": 1,\n \"skills\": {\n \"convex\": {\n \"source\": \"get-convex/agent-skills\",\n \"sourceType\": \"github\","
},
{
"path": "src/app/(stories)/(main)/EditorCommandPaletteClient.tsx",
"chars": 596,
"preview": "\"use client\";\n\nimport EditorCommandPalette from \"@/app/editor/_components/editor_command_palette\";\nimport { authClient }"
},
{
"path": "src/app/(stories)/(main)/[course_id]/course_page_client.tsx",
"chars": 8365,
"preview": "\"use client\";\n\nimport React from \"react\";\nimport { api } from \"@convex/_generated/api\";\nimport { Preloaded, usePreloaded"
},
{
"path": "src/app/(stories)/(main)/[course_id]/not-found.tsx",
"chars": 321,
"preview": "import Header from \"../header\";\nimport Link from \"next/link\";\n\nexport default function NotFound() {\n return (\n <Head"
},
{
"path": "src/app/(stories)/(main)/[course_id]/page.tsx",
"chars": 2537,
"preview": "import React from \"react\";\nimport { notFound } from \"next/navigation\";\n\nimport CoursePageClient from \"./course_page_clie"
},
{
"path": "src/app/(stories)/(main)/[course_id]/story_button.tsx",
"chars": 2812,
"preview": "\"use client\";\n\nimport Link from \"next/link\";\nimport Image from \"next/image\";\n\ninterface StoryData {\n id: number;\n name"
},
{
"path": "src/app/(stories)/(main)/course-dropdown.tsx",
"chars": 3449,
"preview": "\"use client\";\nimport Link from \"next/link\";\nimport LanguageFlag from \"@/components/ui/language-flag\";\nimport { useSelect"
},
{
"path": "src/app/(stories)/(main)/course_list.tsx",
"chars": 1882,
"preview": "\"use client\";\n\nimport React from \"react\";\nimport LanguageButton, {\n type LandingCourseButtonData,\n} from \"./language_bu"
},
{
"path": "src/app/(stories)/(main)/faq/page.tsx",
"chars": 8099,
"preview": "import React from \"react\";\nimport Link from \"next/link\";\n\nexport const metadata = {\n title: \"Duostories FAQ\",\n descrip"
},
{
"path": "src/app/(stories)/(main)/footer_links.tsx",
"chars": 3475,
"preview": "import Link from \"next/link\";\nimport React from \"react\";\n\nexport default async function FooterLinks({}) {\n return (\n "
},
{
"path": "src/app/(stories)/(main)/get_course_data.ts",
"chars": 760,
"preview": "import { api } from \"@convex/_generated/api\";\nimport type { Id } from \"@convex/_generated/dataModel\";\nimport { fetchQuer"
},
{
"path": "src/app/(stories)/(main)/header.tsx",
"chars": 729,
"preview": "export default function Header({ children }: { children: React.ReactNode }) {\n return (\n <header\n className={\n "
},
{
"path": "src/app/(stories)/(main)/icons.tsx",
"chars": 974,
"preview": "import Link from \"next/link\";\nimport {\n IconDiscord,\n IconGithub,\n IconInstagram,\n IconOpenCollective,\n IconTwitter"
},
{
"path": "src/app/(stories)/(main)/landing_stats_client.tsx",
"chars": 1269,
"preview": "\"use client\";\n\nimport { api } from \"@convex/_generated/api\";\nimport { Preloaded, usePreloadedQuery, useQuery } from \"con"
},
{
"path": "src/app/(stories)/(main)/language_button.tsx",
"chars": 2062,
"preview": "\"use client\";\n\nimport Link from \"next/link\";\nimport Flag from \"@/components/ui/flag\";\n\nexport interface LandingCourseBut"
},
{
"path": "src/app/(stories)/(main)/layout.tsx",
"chars": 2310,
"preview": "import Link from \"next/link\";\nimport React from \"react\";\nimport CourseDropdown from \"./course-dropdown\";\nimport EditorCo"
},
{
"path": "src/app/(stories)/(main)/not-found.tsx",
"chars": 292,
"preview": "import Header from \"./header\";\nimport Link from \"next/link\";\n\nexport default function NotFound() {\n return (\n <Heade"
},
{
"path": "src/app/(stories)/(main)/page.tsx",
"chars": 1307,
"preview": "import Link from \"next/link\";\nimport Header from \"./header\";\nimport CourseList from \"./course_list\";\nimport Icons from \""
},
{
"path": "src/app/(stories)/(main)/privacy_policy/page.tsx",
"chars": 5743,
"preview": "import React from \"react\";\nimport Link from \"next/link\";\nimport { Metadata } from \"next\";\n\nexport const metadata: Metada"
},
{
"path": "src/app/(stories)/(main)/profile/actions.ts",
"chars": 863,
"preview": "\"use server\";\n\nimport { fetchAuthMutation } from \"@/lib/auth-server\";\nimport { cookies } from \"next/headers\";\nimport { H"
},
{
"path": "src/app/(stories)/(main)/profile/data.ts",
"chars": 2065,
"preview": "import { cookies } from \"next/headers\";\nimport { fetchAuthQuery } from \"@/lib/auth-server\";\nimport {\n HIDE_STORY_QUESTI"
},
{
"path": "src/app/(stories)/(main)/profile/page.tsx",
"chars": 610,
"preview": "import Header from \"../header\";\nimport Profile from \"./profile\";\nimport { Metadata } from \"next\";\nimport { getProfileDat"
},
{
"path": "src/app/(stories)/(main)/profile/profile.tsx",
"chars": 27796,
"preview": "\"use client\";\n\nimport React from \"react\";\nimport Link from \"next/link\";\nimport Header from \"../header\";\nimport Button fr"
},
{
"path": "src/app/(stories)/learn/page.tsx",
"chars": 821,
"preview": "import React from \"react\";\nimport { redirect } from \"next/navigation\";\nimport Welcome from \"./welcome\";\nimport { getUser"
},
{
"path": "src/app/(stories)/learn/welcome.tsx",
"chars": 3452,
"preview": "\"use client\";\nimport Link from \"next/link\";\nimport React from \"react\";\nimport {\n buttonInnerClassName,\n buttonRootClas"
},
{
"path": "src/app/(stories)/story/[story_id]/auto_play/page.tsx",
"chars": 1265,
"preview": "import React from \"react\";\nimport StoryWrapper from \"./story_wrapper\";\nimport { notFound } from \"next/navigation\";\nimpor"
},
{
"path": "src/app/(stories)/story/[story_id]/auto_play/story_wrapper.tsx",
"chars": 471,
"preview": "\"use client\";\nimport React from \"react\";\n\nimport StoryAutoPlay from \"@/components/StoryAutoPlay\";\nimport { useQuery } fr"
},
{
"path": "src/app/(stories)/story/[story_id]/getStory.ts",
"chars": 313,
"preview": "import { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\n\nexport async function get_sto"
},
{
"path": "src/app/(stories)/story/[story_id]/loading.tsx",
"chars": 418,
"preview": "import React from \"react\";\n\nimport StoryHeaderProgress from \"@/components/StoryHeaderProgress\";\nimport { Spinner } from "
},
{
"path": "src/app/(stories)/story/[story_id]/not-found.tsx",
"chars": 329,
"preview": "import Link from \"next/link\";\nimport Header from \"../../(main)/header\";\n\nexport default function NotFound() {\n return ("
},
{
"path": "src/app/(stories)/story/[story_id]/page.tsx",
"chars": 3477,
"preview": "import React, { Suspense } from \"react\";\nimport { cookies } from \"next/headers\";\nimport { notFound } from \"next/navigati"
},
{
"path": "src/app/(stories)/story/[story_id]/script/page.tsx",
"chars": 1888,
"preview": "import React from \"react\";\nimport { notFound } from \"next/navigation\";\nimport StoryWrapper from \"./story_wrapper\";\nimpor"
},
{
"path": "src/app/(stories)/story/[story_id]/script/story_wrapper.tsx",
"chars": 1264,
"preview": "\"use client\";\nimport React from \"react\";\n\nimport StoryProgress from \"@/components/StoryProgress\";\nimport { StoryData } f"
},
{
"path": "src/app/(stories)/story/[story_id]/story_wrapper.tsx",
"chars": 5895,
"preview": "\"use client\";\nimport React from \"react\";\n\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useQuer"
},
{
"path": "src/app/(stories)/story/[story_id]/test/page.tsx",
"chars": 363,
"preview": "import React from \"react\";\nimport StoryWrapper from \"./story_wrapper\";\nimport { notFound } from \"next/navigation\";\n\nexpo"
},
{
"path": "src/app/(stories)/story/[story_id]/test/story_wrapper.tsx",
"chars": 1209,
"preview": "\"use client\";\nimport React from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport StoryProgress from \"@"
},
{
"path": "src/app/(stories)/story/layout.tsx",
"chars": 540,
"preview": "import \"@/styles/global.css\";\n\nexport const metadata = {\n title:\n \"Duostories: improve your Duolingo learning with c"
},
{
"path": "src/app/admin/AdminDialogTrigger.tsx",
"chars": 877,
"preview": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport {\n buttonInnerClassName,\n buttonRootClassName,\n} from \"@"
},
{
"path": "src/app/admin/AdminHeader.tsx",
"chars": 2159,
"preview": "import Link from \"next/link\";\nimport React from \"react\";\nimport { requireAdmin } from \"@/lib/userInterface\";\nimport Edit"
},
{
"path": "src/app/admin/FlagName.tsx",
"chars": 592,
"preview": "import React from \"react\";\nimport Flag from \"@/components/ui/flag\";\n\nexport default function FlagName({\n lang,\n langua"
},
{
"path": "src/app/admin/adminDetailStyles.ts",
"chars": 413,
"preview": "export const adminDetailPageClass =\n \"mx-auto my-6 mb-10 w-[min(860px,calc(100vw-32px))]\";\n\nexport const adminDetailCar"
},
{
"path": "src/app/admin/adminTableStyles.ts",
"chars": 372,
"preview": "export const adminTableContainerClass =\n \"relative isolate overflow-auto rounded-xl border border-[color:color-mix(in_s"
},
{
"path": "src/app/admin/courses/courses.tsx",
"chars": 17812,
"preview": "\"use client\";\nimport Link from \"next/link\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport Flag from \"@/compo"
},
{
"path": "src/app/admin/courses/page.tsx",
"chars": 113,
"preview": "import CourseListClient from \"./page_client\";\n\nexport default function Page() {\n return <CourseListClient />;\n}\n"
},
{
"path": "src/app/admin/courses/page_client.tsx",
"chars": 427,
"preview": "\"use client\";\n\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport { CourseLis"
},
{
"path": "src/app/admin/edit_dialog.tsx",
"chars": 4789,
"preview": "import React from \"react\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription as UiDialogDescription"
},
{
"path": "src/app/admin/languages/language_list.tsx",
"chars": 10375,
"preview": "\"use client\";\n\nimport { useInput } from \"@/lib/hooks\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport Flag fr"
},
{
"path": "src/app/admin/languages/page.tsx",
"chars": 117,
"preview": "import LanguageListClient from \"./page_client\";\n\nexport default function Page() {\n return <LanguageListClient />;\n}\n"
},
{
"path": "src/app/admin/languages/page_client.tsx",
"chars": 419,
"preview": "\"use client\";\n\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport LanguageLis"
},
{
"path": "src/app/admin/layout.tsx",
"chars": 278,
"preview": "import React from \"react\";\nimport AdminHeader from \"./AdminHeader\";\n\nexport default async function Layout({\n children,\n"
},
{
"path": "src/app/admin/page.tsx",
"chars": 371,
"preview": "export default function Page({}) {\n return (\n <div className=\"mx-auto my-6 w-[min(860px,calc(100vw-32px))] rounded-2"
},
{
"path": "src/app/admin/story/[story_id]/actions.ts",
"chars": 943,
"preview": "\"use server\";\n\nimport { getUser, isAdmin } from \"@/lib/userInterface\";\nimport { fetchAuthMutation } from \"@/lib/auth-ser"
},
{
"path": "src/app/admin/story/[story_id]/page.tsx",
"chars": 335,
"preview": "import StoryDisplay from \"./story_display\";\n\nexport default async function Page({\n params,\n}: {\n params: Promise<{ sto"
},
{
"path": "src/app/admin/story/[story_id]/story_display.tsx",
"chars": 5254,
"preview": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_gene"
},
{
"path": "src/app/admin/story/page.tsx",
"chars": 1595,
"preview": "\"use client\";\n\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport Input from \"@/compo"
},
{
"path": "src/app/admin/users/[user_id]/actions.ts",
"chars": 1319,
"preview": "\"use server\";\n\nimport { z } from \"zod\";\nimport { fetchAuthMutation } from \"@/lib/auth-server\";\nimport { api } from \"@con"
},
{
"path": "src/app/admin/users/[user_id]/page.tsx",
"chars": 1369,
"preview": "import { notFound } from \"next/navigation\";\nimport UserDisplay from \"./user_display\";\nimport { UserSchema } from \"./sche"
},
{
"path": "src/app/admin/users/[user_id]/schema.ts",
"chars": 844,
"preview": "import { z } from \"zod\";\n\nexport const UserSchema = z.object({\n id: z.number(),\n name: z.string(),\n email: z.string()"
},
{
"path": "src/app/admin/users/[user_id]/user_display.tsx",
"chars": 4379,
"preview": "\"use client\";\n\nimport { useState } from \"react\";\nimport Link from \"next/link\";\nimport Switch from \"@/components/ui/switc"
},
{
"path": "src/app/admin/users/page.tsx",
"chars": 2243,
"preview": "import UserList, { type AdminUserList } from \"./user_list\";\nimport { fetchAuthQuery } from \"@/lib/auth-server\";\nimport {"
},
{
"path": "src/app/admin/users/user_list.tsx",
"chars": 13221,
"preview": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n useTransition,\n type KeyboardEvent,\n} from \"react\";\nimport"
},
{
"path": "src/app/api/auth/[...all]/route.ts",
"chars": 84,
"preview": "import { handler } from \"@/lib/auth-server\";\n\nexport const { GET, POST } = handler;\n"
},
{
"path": "src/app/api/og/route.tsx",
"chars": 2099,
"preview": "import React from \"react\";\nimport { ImageResponse } from \"next/og\";\nimport type { NextRequest } from \"next/server\";\n\nexp"
},
{
"path": "src/app/api/og-course/route.tsx",
"chars": 4158,
"preview": "import React from \"react\";\nimport { ImageResponse } from \"next/og\";\nimport type { NextRequest } from \"next/server\";\nexpo"
},
{
"path": "src/app/api/og-story/route.tsx",
"chars": 2216,
"preview": "import React from \"react\";\nimport { ImageResponse } from \"next/og\";\nimport type { NextRequest } from \"next/server\";\n\nexp"
},
{
"path": "src/app/audio/_lib/audio/azure_tts.ts",
"chars": 4110,
"preview": "import * as sdk from \"microsoft-cognitiveservices-speech-sdk\";\nimport * as fs from \"fs\";\nimport { put } from \"@vercel/bl"
},
{
"path": "src/app/audio/_lib/audio/elevenlabs.ts",
"chars": 6282,
"preview": "import { put } from \"@vercel/blob\";\nimport { decode } from \"base64-arraybuffer\";\nimport WebSocket from \"ws\";\nimport type"
},
{
"path": "src/app/audio/_lib/audio/google.ts",
"chars": 4603,
"preview": "import { put } from \"@vercel/blob\";\nimport type { SynthesisResult, Voice, TTSEngine } from \"./types\";\n\nconst apiKey = pr"
},
{
"path": "src/app/audio/_lib/audio/index.ts",
"chars": 379,
"preview": "import engine_azure from \"./azure_tts\";\nimport engine_google from \"./google\";\nimport engine_polly from \"./polly\";\nimport"
}
]
// ... and 229 more files (download for full content)
About this extraction
This page contains the full source code of the rgerum/unofficial-duolingo-stories GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 429 files (1.6 MB), approximately 430.0k tokens, and a symbol index with 1199 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.