[
  {
    "path": ".codex/environments/environment.toml",
    "content": "# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY\nversion = 1\nname = \"unofficial-duolingo-stories\"\n\n[setup]\nscript = '''\npnpm i\ncp /Users/richard/WebstormProjects/unofficial-duolingo-stories_bugfixes/.env.local .\n'''\n\n[[actions]]\nname = \"Run\"\nicon = \"run\"\ncommand = '''\npnpm i\npnpm run dev\n'''\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]\npatreon: # Replace with a single Patreon username\nopen_collective: duostories\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\notechie: # Replace with a single Otechie username\nlfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "# 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\n# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions\n\nname: Build\n\non:\n  push:\n    branches: [\"master\"]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          cache: 'npm'\n          node-version: 20\n\n      - uses: actions/cache@v3\n        with:\n          # 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\n          path: |\n            ~/.npm\n            ${{ github.workspace }}/.next/cache\n          # Generate a new cache whenever packages or source files change.\n          key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}\n          # If source files changed but packages didn't, rebuild from a prior cache.\n          restore-keys: |\n            ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-\n\n      - name: ssh tunnel to mysql database\n        run: |\n          mkdir -p ~/.ssh/\n          ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts\n          eval `ssh-agent -s`\n          ssh-add - <<< \"${{secrets.SSH_PRIVATE_KEY}}\"\n          ssh -fN -L 3306:127.0.0.1:3306 duostori@ara.uberspace.de\n          ssh -fN -L 5432:127.0.0.1:5432 duostori@ara.uberspace.de\n\n      - name: Install\n        run: npm install\n\n      - run: printf \"${{ secrets.ENV_LOCAL }}\" >> .env.local\n\n      - name: Build\n        run: npm run build\n\n      - name: zip\n        run: zip -r build.zip .next\n\n      - name: Archive production artifacts\n        uses: actions/upload-artifact@v3\n        with:\n          name: build-data\n          path: build.zip\n\n\n  deploy-beta:\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - name: Download build artifacts\n        uses: actions/download-artifact@v4.1.7\n        with:\n          name: build-data\n      - name: ssh tunnel for upload\n        run: |\n          mkdir -p ~/.ssh/\n          ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts\n          eval `ssh-agent -s`\n          ssh-add - <<< \"${{secrets.SSH_PRIVATE_KEY}}\"\n          ssh -fN -L 3306:127.0.0.1:3306 duostori@ara.uberspace.de\n          ssh -fN -L 5432:127.0.0.1:5432 duostori@ara.uberspace.de\n      - name: upload\n        run: |\n          ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts\n          eval `ssh-agent -s`\n          ssh-add - <<< \"${{secrets.SSH_PRIVATE_KEY}}\"\n          scp build.zip duostori@ara.uberspace.de:~/html/HEAD/\n      - name: unzip\n        run: |\n          ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts\n          eval `ssh-agent -s`\n          ssh-add - <<< \"${{secrets.SSH_PRIVATE_KEY}}\"\n          \n          ssh duostori@ara.uberspace.de 'rm -rf ~/html/HEAD/.next'\n          ssh duostori@ara.uberspace.de 'unzip -d ~/html/HEAD/ ~/html/HEAD/build.zip'\n\n      - name: restart\n        run: |\n          ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts\n          eval `ssh-agent -s`\n          ssh-add - <<< \"${{secrets.SSH_PRIVATE_KEY}}\"\n          \n          ssh duostori@ara.uberspace.de 'supervisorctl stop beta'\n          ssh duostori@ara.uberspace.de '/home/duostori/html/kill_rouge_workers.py'\n          ssh duostori@ara.uberspace.de '/home/duostori/html/kill_port_users.py beta'\n          ssh duostori@ara.uberspace.de 'supervisorctl start beta'\n"
  },
  {
    "path": ".github/workflows/build_pr.yml",
    "content": "# 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\n# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions\n\nname: Build Pull Request\n\non:\n  pull_request:\n    branches: [\"master\"]\n\njobs:\n  build_pr:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v3\n        with:\n          node-version: 20\n\n      - name: ssh tunnel to mysql database\n        run: |\n          mkdir -p ~/.ssh/\n          ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts\n          eval `ssh-agent -s`\n          ssh-add - <<< \"${{secrets.SSH_PRIVATE_KEY}}\"\n          ssh -fN -L 3306:127.0.0.1:3306 duostori@ara.uberspace.de\n\n      - name: Install\n        run: npm install\n\n      - run: printf \"${{ secrets.ENV_LOCAL }}\" >> .env.local\n\n      - name: Build\n        run: npm run build\n\n      - name: zip\n        run: zip -r build.zip .next\n\n      - name: upload\n        run: |\n          ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts\n          eval `ssh-agent -s`\n          ssh-add - <<< \"${{secrets.SSH_PRIVATE_KEY}}\"\n          ssh duostori@ara.uberspace.de 'cd html && ./create_deployment.py ${{ github.event.number }}'       \n          scp build.zip duostori@ara.uberspace.de:~/html/deploy_${{ github.event.number  }}/\n          ssh duostori@ara.uberspace.de 'rm -rf ~/html/deploy_${{ github.event.number  }}/.next'\n          ssh duostori@ara.uberspace.de 'unzip -d ~/html/deploy_${{ github.event.number  }}/ ~/html/deploy_${{ github.event.number }}/build.zip'\n          ssh duostori@ara.uberspace.de 'supervisorctl stop deploy_${{ github.event.number  }}'\n          ssh duostori@ara.uberspace.de '/home/duostori/html/kill_rouge_workers.py'\n          ssh duostori@ara.uberspace.de 'supervisorctl start deploy_${{ github.event.number  }}'\n"
  },
  {
    "path": ".github/workflows/ci.yaml",
    "content": "name: CI\n\non: [push]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v5\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v5\n        with:\n          version: 10\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 24\n          cache: pnpm\n\n      - name: Install Dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Copy .env.example files\n        shell: bash\n        run: find . -type f -name \".env.example\" -exec sh -c 'cp \"$1\" \"${1%.*}\"' _ {} \\;\n\n      - name: Typecheck\n        run: pnpm typecheck\n\n      - name: Lint\n        run: pnpm lint\n"
  },
  {
    "path": ".gitignore",
    "content": ".idea/\nsql_dump/\n/audio/rootkey.csv\n/audio/tts/rootkey.csv\nnode_modules/\nrootkey.csv\nlib_swift/\ndist/\n\n# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\nnode_modules\n.pnp\n.pnp.js\n\n# testing\ncoverage\n\n# production\nbuild\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n/duolingo_data/\ntoken.txt\n/import_tools/duolingo_data/\n\nduolingo_data*\nsync.sh\nsync_test.sh\npublishX.sh\n/packages/**/cypress/videos/\n/packages/**/cypress/**/screenshots/\n/src/next-all/test.sql\n\n\n\n# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n\n# next.js\n.next/\nout/\ntmp/\n\n# misc\n.env*\n\nold/\n/test.sqlite\n.vercel\n\n*storybook.log\nnext-env.d.ts\ntsconfig.tsbuildinfo\ndiscord_roles/.cache/\n\n*/skills/*\nskills/*\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Agent Notes\n\n## Formatting\n- Run `pnpm run format` after code edits in this repository.\n- Use `pnpm run format:check` for CI/local validation.\n- Biome is the default formatter/linter for this repo.\n- Biome checks are intentionally scoped to `src/` and `convex/`.\n- Run `pnpm run lint` before finishing when lint-sensitive files changed.\n\n## Type Checking\n- Run `pnpm typecheck` after code edits and before finishing.\n\n## Convex\n- Follow `./convex/convex_rules.md` when making changes in `convex/`.\n- Always deploy Convex after changing files in `convex/` to the dev deployment (for example with `pnpm convex dev --once`), not prod.\n\n<!-- BEGIN:nextjs-agent-rules -->\n\n# Next.js: ALWAYS read docs before coding\n\nBefore 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.\n\n<!-- END:nextjs-agent-rules -->\n\n<!-- convex-ai-start -->\nThis project uses [Convex](https://convex.dev) as its backend.\n\nWhen 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.\n\nConvex agent skills for common tasks can be installed by running `npx convex ai-files install`.\n<!-- convex-ai-end -->\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nUnofficial 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.\n\n## Development Commands\n\n```bash\npnpm run dev          # Development server at http://localhost:3000\npnpm run build        # Production build\npnpm run format       # Biome formatter on src/ and convex/\npnpm run lint         # Biome linter (with format check first)\npnpm run typecheck    # TypeScript type checking (tsc --noEmit)\npnpm exec convex codegen # Regenerate Convex bindings after adding/changing Convex functions\n```\n\nNote: 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.\n\n## Environment Setup\n\nRequires Convex.\n\nNext.js `.env.local` typically includes:\n- `NEXT_PUBLIC_CONVEX_URL` and `CONVEX_URL`\n- `BETTER_AUTH_SECRET`\n- `SITE_URL`\n\nConvex env (`pnpm exec convex env set ...`) typically includes:\n- `GITHUB_REPO_TOKEN` - used by `convex/editorSideEffects.ts`\n- `POSTHOG_KEY` and `POSTHOG_HOST` - used by `convex/editorSideEffects.ts`\n- `RESEND_API_KEY`, `SITE_URL`, `BETTER_AUTH_SECRET`\n\nTest credentials: user/test (normal), editor/test (editor access), admin/test (admin access)\n\n## Architecture\n\n### Directory Structure\n- `src/app/` - Next.js App Router pages\n  - `(stories)/` - Main story browsing (route group, includes story reader, course listing, profile, FAQ)\n  - `admin/` - Admin dashboard\n  - `editor/` - Story editor interface\n  - `auth/` - Authentication (signin, register, password reset)\n  - `api/` - API routes (auth handler, OG image generation)\n- `audio/` - Audio processing endpoints\n- `src/components/` - Reusable React components\n- `src/lib/` - Server utilities, database helpers, auth\n\n### Key Files\n- `src/auth.ts` - Better Auth server configuration (JWT sessions, OAuth providers, email verification)\n- `src/lib/auth-client.ts` - Client-side Better Auth client\n- `convex/editorSideEffects.ts` - GitHub/PostHog side effects scheduled by write mutations\n- `convex/lib/authorization.ts` - shared auth guard helpers for Convex functions\n\n### Authentication\nUses 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.\n\n### Database Access\nApplication reads/writes should go through Convex queries/mutations.\n\n### Write-path Rules\n\n- Prefer direct client/server-action calls to Convex mutations for app writes.\n- Do not add pass-through Next route handlers for simple reads/writes.\n- Use Next route handlers only for server-only concerns (auth entrypoint, file upload, external secrets/integration boundaries).\n- Schedule side effects from Convex mutations using `ctx.scheduler.runAfter(..., internal...)`.\n- Include `operationKey` for retriable writes.\n- Keep side effects non-blocking: DB mutation success should not depend on GitHub/PostHog success.\n\n### Component Pattern\n```\n/ComponentName\n  ├── ComponentName.tsx        # Implementation\n  ├── ComponentName.module.css # CSS Module styles\n  └── index.ts                 # Export\n```\n\n### Styling\n- CSS Modules for scoped styles (primary)\n- Styled Components for dynamic styles (compiler enabled in `next.config.js`)\n- Global styles in `src/styles/global.css`\n\n### Path Alias\n`@/` maps to `src/` (tsconfig baseUrl is `src`, paths `@/*` → `./*`).\n\n## Story Workflow\n\nStories have a status workflow: draft → feedback → finished. Stories belong to courses, which link a learning language to a base language.\n\n## Audio/TTS\n\nMultiple TTS providers in `src/app/audio/_lib/audio/`: Azure, Google Cloud, AWS Polly, ElevenLabs.\n\n<!-- convex-ai-start -->\nThis project uses [Convex](https://convex.dev) as its backend.\n\nWhen 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.\n\nConvex agent skills for common tasks can be installed by running `npx convex ai-files install`.\n<!-- convex-ai-end -->\n"
  },
  {
    "path": "CONTEXT.md",
    "content": "# Unofficial Duolingo Stories\n\nThis context describes the domain language for publishing and editing community-translated Duolingo-style stories.\n\n## Language\n\n**Story**:\nA course-bound learning unit composed of dialogue, prompts, audio, and metadata.\n_Avoid_: Lesson, exercise, activity\n\n**Deleted Story**:\nA story hidden from normal workflows without permanently removing its record.\n_Avoid_: Archived story, permanently deleted story\n\n**Story Title**:\nThe learner-facing title of a story.\n_Avoid_: Story name\n\n**Course**:\nA container for stories for one learning-language/from-language pair.\n_Avoid_: Language, localization, course language\n\n**Course Slug**:\nThe compact course identifier used in URLs and course selection.\n_Avoid_: Short code, language code\n\n**Language**:\nA reusable language record used by courses, voices, localization strings, avatar mappings, and flags.\n_Avoid_: Course\n\n**Language Code**:\nThe compact identifier for a single language.\n_Avoid_: Course slug\n\n**Learning Language**:\nThe language a course teaches.\n_Avoid_: Target language\n\n**From Language**:\nThe language a course assumes the learner already understands.\n_Avoid_: Base language, source language\n\n**Localization**:\nA translated UI/app text string for a language.\n_Avoid_: Story translation, course translation\n\n**Story Content**:\nThe editable body of a story, represented both as source text and structured content.\n_Avoid_: Story metadata\n\n**Story Text**:\nThe author-facing textual representation of story content.\n_Avoid_: Script\n\n**Story Text Syntax**:\nThe markup and conventions contributors use inside story text.\n_Avoid_: Format, parser syntax\n\n**Structured Story Content**:\nThe parsed representation of story content used by editors and readers.\n_Avoid_: Story JSON\n\n**Story Element**:\nOne editable item inside a story body, such as a dialogue line, prompt, or challenge.\n_Avoid_: Line, item\n\n**Line**:\nA text-bearing story element that can have speaker text and audio.\n_Avoid_: Story element, file line\n\n**Character**:\nA visible or narrative persona used in story content.\n_Avoid_: Speaker, avatar, voice\n\n**Character Name**:\nThe display name for a character in a course or language context.\n_Avoid_: Avatar\n\n**Cast**:\nThe set of characters used by a story, with their names, avatars, and voices resolved for the course.\n_Avoid_: Course characters\n\n**Avatar**:\nA reusable visual asset that can represent a character.\n_Avoid_: Character, speaker, voice\n\n**Avatar Mapping**:\nA course- or language-specific assignment of an avatar to a character name and voice.\n_Avoid_: Speaker mapping\n\n**Voice**:\nA text-to-speech voice or configuration used for story audio.\n_Avoid_: Speaker, character, avatar\n\n**Audio File**:\nA media file used for story playback.\n_Avoid_: Audio timing, voice\n\n**Uploaded Audio**:\nAn audio file provided by a contributor.\n_Avoid_: Generated audio\n\n**Generated Audio**:\nAn audio file produced from text using a voice.\n_Avoid_: Uploaded audio\n\n**Audio Timing**:\nTiming data that synchronizes story text with audio playback.\n_Avoid_: Audio file, voice\n\n**Story Header**:\nThe opening story element containing a title, illustration, learning-language content, and optional audio.\n_Avoid_: Header, page header, app header\n\n**Story Illustration**:\nThe visual associated with a story or story header.\n_Avoid_: Story image, image\n\n**Challenge**:\nAn interactive story element that asks the learner to respond.\n_Avoid_: Question, exercise\n\n**Story Status**:\nThe editorial lifecycle state of a story.\n_Avoid_: Publication status\n\n**Draft**:\nA story status for work that is still being prepared.\n_Avoid_: Unpublished\n\n**Feedback**:\nA story status for work that has received one approval and is ready for another contributor to review.\n_Avoid_: Ready for feedback, review\n\n**Finished**:\nA story status for work that has completed the editorial workflow.\n_Avoid_: Done\n\n**Approval**:\nA contributor review signal in the story editorial workflow.\n_Avoid_: Publication, finished\n\n**Publication Visibility**:\nWhether a story is visible to learners.\n_Avoid_: Story status, finished\n\n**Published**:\nA publication visibility value meaning a story is visible to learners.\n_Avoid_: Finished\n\n**Unpublished**:\nA publication visibility value meaning a story is not visible to learners.\n_Avoid_: Draft\n\n**Publish**:\nTo change story visibility to Published.\n_Avoid_: Finish, release\n\n**Story Completion**:\nA learner-side record that a user completed a story.\n_Avoid_: Finished, done\n\n**TODO**:\nA marker written in story text by a contributor to flag something for later attention.\n_Avoid_: Task, issue\n\n**Hint**:\nSupporting text shown to help learners understand a word or phrase.\n_Avoid_: Translation\n\n**Pronunciation Hint**:\nSupporting text shown to help learners pronounce a word or phrase.\n_Avoid_: Hint, pinyin\n\n**Selectable Phrase**:\nA phrase segment the learner can choose or arrange inside a challenge.\n_Avoid_: Button\n\n**Story Set**:\nA group of stories within a course that is usually published together.\n_Avoid_: Course, story status\n\n**Set 0**:\nAn introductory story set outside the main course canon.\n_Avoid_: Main story set\n\n**Source Course**:\nA course used as the reference when importing stories for translation into another course.\n_Avoid_: Main canon\n\n**Official Course**:\nA non-public course imported from Duolingo as raw source material.\n_Avoid_: Public course, endorsed course\n\n**Public Course**:\nA course visible to learners on the site.\n_Avoid_: Published story\n\n**Course Page**:\nThe public learner-facing page for a course and its published stories.\n_Avoid_: Editor course story overview\n\n**Target Course**:\nA course that receives imported or translated stories.\n_Avoid_: Target language\n\n**Story Import**:\nThe act of creating a target-course story from a source-course story.\n_Avoid_: Translation\n\n**Translation**:\nThe linguistic work of adapting story content into the target course's learning language.\n_Avoid_: Story import\n\n**Learner**:\nA user who consumes stories.\n_Avoid_: Contributor\n\n**Contributor**:\nA user with global permission to edit project content.\n_Avoid_: Editor\n\n**Admin**:\nA user with project-wide management permissions beyond normal contribution.\n_Avoid_: Contributor\n\n**Course Contributor**:\nA contributor credited for making a minimum contribution to a course.\n_Avoid_: Course-scoped editor, course permission\n\n**Editor Area**:\nThe authenticated contributor-facing part of the site under `/editor`.\n_Avoid_: Story editor, contributor\n\n**Story Editor**:\nThe editor workspace for one specific story.\n_Avoid_: Editor area, contributor\n\n**Bulk Audio Editor**:\nA story-level workspace for assigning many audio files and audio timings before applying them to a story.\n_Avoid_: Audio cutter, voice editor\n\n**Audio Cutter**:\nA tool or workflow for preparing audio segments from longer audio.\n_Avoid_: Bulk audio editor\n\n**Story Page**:\nThe learner-facing page for reading or playing one published story.\n_Avoid_: Story editor\n\n**Editor Course Story Overview**:\nThe editor-area screen for viewing and working through one course's stories.\n_Avoid_: Course editor, course story overview\n\n**Character Voice Editor**:\nThe editor-area screen for assigning character names and voices in a course context.\n_Avoid_: Voice editor, speaker editor\n\n**Voice Catalog Editor**:\nThe editor-area screen for managing available voices.\n_Avoid_: Character voice editor, speaker editor\n\n**Course Localization Editor**:\nThe editor-area screen for editing localization strings in a course context.\n_Avoid_: Story translation editor\n\n## Relationships\n\n- A **Course** owns zero or more **Stories**.\n- A **Course** owns zero or more **Story Sets**.\n- A **Course** has one **Course Slug** when it is addressable in the editor or site.\n- A **Course** can be a **Public Course** or non-public.\n- A **Public Course** can have one **Course Page**.\n- A **Course** can act as a **Source Course** for story imports.\n- A **Course** can act as a **Target Course** for story imports.\n- An **Official Course** can act as raw material for **Story Imports**.\n- A **Source Course** provides stories to one or more **Target Courses**.\n- A **Story Import** copies a **Story** from a **Source Course** into a **Target Course**.\n- **Translation** can happen after a **Story Import**.\n- A **Learner** can create **Story Completions**.\n- A **Contributor** can edit project content across courses.\n- A **Contributor** can use the **Editor Area**.\n- An **Editor Course Story Overview** belongs to one **Course**.\n- An **Editor Course Story Overview** is part of the **Editor Area**.\n- A **Character Voice Editor** works in one **Course** context.\n- A **Character Voice Editor** is part of the **Editor Area**.\n- A **Voice Catalog Editor** is part of the **Editor Area**.\n- A **Voice Catalog Editor** manages **Voices**.\n- A **Course Localization Editor** belongs to one **Course**.\n- A **Course Localization Editor** edits **Localization** entries.\n- A **Course Localization Editor** is part of the **Editor Area**.\n- A **Story Editor** edits one **Story**.\n- A **Bulk Audio Editor** belongs to one **Story Editor**.\n- A **Bulk Audio Editor** edits **Audio Files** and **Audio Timing** for a **Story**.\n- An **Audio Cutter** can produce **Audio Files** for a **Story**.\n- A **Story Page** presents one **Published** **Story** to **Learners**.\n- An **Admin** can manage content and settings beyond a normal **Contributor**'s scope.\n- A **Course Contributor** is credited on public and internal course pages.\n- A **Story Set** contains one or more **Stories**.\n- A **Course** has exactly one learning **Language**.\n- A **Course** has exactly one from **Language**.\n- A **Language** has one **Language Code**.\n- A **Localization** belongs to exactly one **Language**.\n- A **Story** belongs to exactly one **Course**.\n- A **Story** belongs to exactly one **Story Set**.\n- A **Story** has exactly one **Story Title**.\n- A **Story** has exactly one **Story Status**.\n- A **Story** can be a **Deleted Story**.\n- A **Story** can receive zero or more **Approval** signals.\n- A **Story** becomes **Feedback** when it receives its first **Approval**.\n- A **Story** becomes **Finished** when it receives two **Approval** signals.\n- A **Story** has one **Publication Visibility**.\n- A **Story** can have zero or more **Story Completion** records.\n- A **Story Completion** belongs to one **Learner**.\n- A **Story Text** can contain zero or more **TODO** markers.\n- **Story Content** can include **Hint** entries for learner-facing text.\n- **Story Content** can include **Pronunciation Hint** entries for learner-facing text.\n- A **Challenge** can contain one or more **Selectable Phrase** entries.\n- A regular **Story Set** is published when all four of its **Stories** are **Finished**.\n- An **Approval** can indirectly trigger publication when it causes the final **Story** in a regular **Story Set** to become **Finished**.\n- **Publish** changes one or more **Stories** from **Unpublished** to **Published**.\n- **Set 0** can contain fewer than four introductory **Stories**.\n- A **Story** has exactly one **Story Content** body.\n- **Story Content** has one **Story Text** representation.\n- **Story Text** uses **Story Text Syntax**.\n- **Story Content** has one **Structured Story Content** representation.\n- **Story Content** contains one or more **Story Elements**.\n- A **Story Header** is a kind of **Story Element**.\n- A **Line** is a kind of **Story Element**.\n- A **Challenge** is a kind of **Story Element**.\n- A **Story** can have one **Story Illustration**.\n- A **Story Header** can show one **Story Illustration**.\n- A **Story Header** can have one **Audio File**.\n- A **Story Header** can have **Audio Timing** for its **Audio File**.\n- A **Line** can be associated with one **Character**.\n- A **Character** can have one **Character Name** in a course or language context.\n- A **Character** can use one **Avatar** for display.\n- A **Character** can use one **Voice** for generated audio.\n- A **Story** has one **Cast**.\n- A **Cast** contains one or more **Character** entries.\n- An **Avatar Mapping** connects an **Avatar** to a **Character Name** and **Voice**.\n- **Uploaded Audio** is a kind of **Audio File**.\n- **Generated Audio** is a kind of **Audio File**.\n- A **Line** can have one **Audio File**.\n- A **Line** can have **Audio Timing** for its **Audio File**.\n\n## Example dialogue\n\n> **Dev:** \"Can we create a **Story** before choosing where it belongs?\"\n> **Domain expert:** \"No — every **Story** belongs to a **Course**.\"\n\n## Flagged ambiguities\n\n- \"language\" is often used informally to mean **Course**, but should be reserved for **Language** unless the learning/from-language pair is irrelevant.\n- \"line\" can mean a story **Line** or a file line number; use **Line** only for story content.\n- \"header\" can mean a **Story Header** or a UI layout header; use **Story Header** for story content.\n- \"speaker\" is overloaded in code and UI; use **Voice** for text-to-speech configuration and **Character** for the story persona.\n- \"main canon\" is not established project language; when referring to import/source guidance, discuss the source course explicitly.\n- \"editor\" can mean the **Editor Area**, the **Story Editor**, or the person doing edits; use **Contributor** for the person.\n- \"course contributor\" is attribution, not authorization; contributors currently have global edit access rather than per-course edit permissions.\n- \"done\" appears in learner completion data; use **Story Completion** for learner progress and **Finished** for editorial status.\n- \"official\" means imported from Duolingo as source material; it does not mean this project is endorsed by Duolingo.\n- 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.\n- **Approval** signals currently persist across story edits; edits do not reset or stale existing approvals.\n"
  },
  {
    "path": "README.md",
    "content": "# Unofficial Duolingo Stories\n\n[![Cypress Test](https://img.shields.io/endpoint?url=https://cloud.cypress.io/badge/simple/cvszgh/master&style=flat&logo=cypress)](https://cloud.cypress.io/projects/cvszgh/runs)\n[![chat](https://img.shields.io/discord/726701782075572277)](https://discord.com/invite/4NGVScARR3)\n\nThis project brings the official Duolingo Stories to new languages, translated by a community effort.\n\nIt is _not_ an official product of Duolingo, nor is there any plan to integrate it into their platform or app.\n\nIt is hosted at https://duostories.org and reproduces the story experience from the official Duolingo stories.\n\nThe app is built with Next.js and React.\n\n## Architecture snapshot\n\n- App/UI: Next.js 16 + React 19 (`src/app`, `src/components`)\n- Canonical app data access: Convex queries/mutations (`convex/*`)\n- Write-side side effects (GitHub/PostHog): Convex internal actions in `convex/editorSideEffects.ts`\n- Remaining Next route handlers are intentionally server-only:\n  - Auth entrypoint (`src/app/api/auth/[...all]/route.ts`)\n  - Audio endpoints (`src/app/audio/*/route.ts`)\n\n### Write flow\n\nClient component -> Convex mutation -> schedule internal actions:\n- `editorSideEffects.*` for GitHub/PostHog side effects\n\nThis keeps write authorization, mutation semantics, and side effects centralized in Convex.\n\n## How to run locally\n\nNow create `.env.local` in the project root.\n\nMinimum local values:\n\n```\nNEXT_PUBLIC_CONVEX_URL=<your_convex_dev_url>\nCONVEX_URL=<your_convex_dev_url>\nBETTER_AUTH_SECRET=<your_secret>\nSITE_URL=http://localhost:3000\n```\n\nConvex runtime env (set via `pnpm exec convex env set ...`) should include:\n\n```\nGITHUB_REPO_TOKEN=<optional_for_side_effect_sync>\nPOSTHOG_KEY=<optional_for_server_tracking>\nPOSTHOG_HOST=<optional_for_server_tracking>\nRESEND_API_KEY=<optional_for_email_flows>\nSITE_URL=http://localhost:3000\nBETTER_AUTH_SECRET=<must_match_auth_setup>\n```\n\nInstall dependencies\n\n```\npnpm install\n```\n\nTo develop you can then run and visit http://localhost:3000\n\n```\npnpm run dev\n```\n\nRecommended checks:\n\n```\npnpm run typecheck\npnpm run lint\n```\n\n## How to contribute\n\nTo contribute to the project you should open an issue to discuss your proposed change.\nYou can assign the issue to yourself to show that you want to work on that.\nIf there is a consensus that this bug should be fixed or this feature should be implemented,\nthen follow the following steps:\n\n- create a fork of the repository\n- clone it to your computer\n- create a branch for your feature\n- make the changes to the code\n- commit and push the changes to GitHub\n- create a pull request\n\nPlease make sure to only commit changes to files that are necessary to the issue.\nTry to not commit accidentally other changes, e.g. package-lock.json files.\nThis makes it harder to review and merge the pull request.\n\n### Contribution rules for new backend work\n\n- New app writes should be direct Convex mutations from the client or server action.\n- Avoid adding pass-through Next route handlers for simple reads/writes.\n- Server side effects should be scheduled from Convex mutations via internal actions.\n- Include an `operationKey` for mutation calls that can be retried.\n\nIf everything is fine, I will accept the pull request and I will soon upload it to the website.\n"
  },
  {
    "path": "biome.json",
    "content": "{\n  \"$schema\": \"https://biomejs.dev/schemas/2.4.9/schema.json\",\n  \"vcs\": {\n    \"enabled\": true,\n    \"clientKind\": \"git\",\n    \"useIgnoreFile\": true\n  },\n  \"files\": {\n    \"ignoreUnknown\": true,\n    \"includes\": [\n      \"src/**\",\n      \"convex/**\",\n      \"!convex/_generated/**\",\n      \"!convex/**/_generated/**\"\n    ]\n  },\n  \"formatter\": {\n    \"enabled\": true,\n    \"indentStyle\": \"space\",\n    \"indentWidth\": 2\n  },\n  \"linter\": {\n    \"enabled\": true,\n    \"rules\": {\n      \"recommended\": false,\n      \"a11y\": {\n        \"noAriaUnsupportedElements\": \"warn\",\n        \"useAltText\": \"warn\",\n        \"useAriaPropsForRole\": \"warn\",\n        \"useAriaPropsSupportedByRole\": \"warn\",\n        \"useValidAriaProps\": \"warn\",\n        \"useValidAriaValues\": \"warn\"\n      },\n      \"correctness\": {\n        \"noChildrenProp\": \"error\",\n        \"noNextAsyncClientComponent\": \"warn\",\n        \"useExhaustiveDependencies\": \"warn\",\n        \"useHookAtTopLevel\": \"error\",\n        \"useJsxKeyInIterable\": \"error\"\n      },\n      \"performance\": {\n        \"noImgElement\": \"off\",\n        \"noUnwantedPolyfillio\": \"warn\",\n        \"useGoogleFontPreconnect\": \"warn\"\n      },\n      \"security\": {\n        \"noDangerouslySetInnerHtmlWithChildren\": \"error\"\n      },\n      \"style\": {\n        \"noHeadElement\": \"warn\"\n      },\n      \"suspicious\": {\n        \"noCommentText\": \"error\",\n        \"noDocumentImportInPage\": \"error\",\n        \"noDuplicateJsxProps\": \"error\",\n        \"noHeadImportInDocument\": \"error\",\n        \"useGoogleFontDisplay\": \"warn\"\n      }\n    },\n    \"includes\": [\n      \"src/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}\",\n      \"convex/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}\",\n      \"!convex/_generated/**\",\n      \"!convex/**/_generated/**\"\n    ]\n  },\n  \"javascript\": {\n    \"formatter\": {\n      \"quoteStyle\": \"double\"\n    }\n  },\n  \"css\": {\n    \"parser\": {\n      \"tailwindDirectives\": true\n    }\n  },\n  \"assist\": {\n    \"enabled\": true,\n    \"actions\": {\n      \"source\": {\n        \"organizeImports\": \"on\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": true,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/styles/global.css\",\n    \"baseColor\": \"slate\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"iconLibrary\": \"lucide\",\n  \"rtl\": false,\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"registries\": {}\n}\n"
  },
  {
    "path": "convex/_generated/ai/ai-files.state.json",
    "content": "{\n  \"guidelinesHash\": \"62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57\",\n  \"agentsMdSectionHash\": \"bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2\",\n  \"claudeMdHash\": \"bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2\",\n  \"agentSkillsSha\": \"d0fa8085af313029add5740f67198aa42ca60c8d\",\n  \"installedSkillNames\": [\n    \"convex\",\n    \"convex-create-component\",\n    \"convex-migration-helper\",\n    \"convex-performance-audit\",\n    \"convex-quickstart\",\n    \"convex-setup-auth\"\n  ]\n}\n"
  },
  {
    "path": "convex/_generated/ai/guidelines.md",
    "content": "# Convex guidelines\n\n## Function guidelines\n\n### Http endpoint syntax\n\n- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:\n\n```typescript\nimport { httpRouter } from \"convex/server\";\nimport { httpAction } from \"./_generated/server\";\nconst http = httpRouter();\nhttp.route({\n  path: \"/echo\",\n  method: \"POST\",\n  handler: httpAction(async (ctx, req) => {\n    const body = await req.bytes();\n    return new Response(body, { status: 200 });\n  }),\n});\n```\n\n- 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`.\n\n### Validators\n\n- Below is an example of an array validator:\n\n```typescript\nimport { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nexport default mutation({\n  args: {\n    simpleArray: v.array(v.union(v.string(), v.number())),\n  },\n  handler: async (ctx, args) => {\n    //...\n  },\n});\n```\n\n- Below is an example of a schema with validators that codify a discriminated union type:\n\n```typescript\nimport { defineSchema, defineTable } from \"convex/server\";\nimport { v } from \"convex/values\";\n\nexport default defineSchema({\n  results: defineTable(\n    v.union(\n      v.object({\n        kind: v.literal(\"error\"),\n        errorMessage: v.string(),\n      }),\n      v.object({\n        kind: v.literal(\"success\"),\n        value: v.number(),\n      }),\n    ),\n  ),\n});\n```\n\n- Here are the valid Convex types along with their respective validators:\n  Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |\n  | ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n  | Id | string | `doc._id` | `v.id(tableName)` | |\n  | 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. |\n  | Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |\n  | 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. |\n  | Boolean | boolean | `true` | `v.boolean()` |\n  | 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. |\n  | 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. |\n  | Array | Array | `[1, 3.2, \"abc\"]` | `v.array(values)` | Arrays can have at most 8192 values. |\n  | 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 \"_\". |\n| 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 \"\\_\". |\n\n### Function registration\n\n- 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`.\n- 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.\n- You CANNOT register a function through the `api` or `internal` objects.\n- ALWAYS include argument validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`.\n\n### Function calling\n\n- Use `ctx.runQuery` to call a query from a query, mutation, or action.\n- Use `ctx.runMutation` to call a mutation from a mutation or action.\n- Use `ctx.runAction` to call an action from an action.\n- 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.\n- 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.\n- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.\n- 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,\n\n```\nexport const f = query({\n  args: { name: v.string() },\n  handler: async (ctx, args) => {\n    return \"Hello \" + args.name;\n  },\n});\n\nexport const g = query({\n  args: {},\n  handler: async (ctx, args) => {\n    const result: string = await ctx.runQuery(api.example.f, { name: \"Bob\" });\n    return null;\n  },\n});\n```\n\n### Function references\n\n- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.\n- 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`.\n- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.\n- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.\n- 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`.\n\n### Pagination\n\n- Define pagination using the following syntax:\n\n```ts\nimport { v } from \"convex/values\";\nimport { query, mutation } from \"./_generated/server\";\nimport { paginationOptsValidator } from \"convex/server\";\nexport const listWithExtraArg = query({\n  args: { paginationOpts: paginationOptsValidator, author: v.string() },\n  handler: async (ctx, args) => {\n    return await ctx.db\n      .query(\"messages\")\n      .withIndex(\"by_author\", (q) => q.eq(\"author\", args.author))\n      .order(\"desc\")\n      .paginate(args.paginationOpts);\n  },\n});\n```\n\nNote: `paginationOpts` is an object with the following properties:\n\n- `numItems`: the maximum number of documents to return (the validator is `v.number()`)\n- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)\n- A query that ends in `.paginate()` returns an object that has the following properties:\n- page (contains an array of documents that you fetches)\n- isDone (a boolean that represents whether or not this is the last page of documents)\n- continueCursor (a string that represents the cursor to use to fetch the next page of documents)\n\n## Schema guidelines\n\n- Always define your schema in `convex/schema.ts`.\n- Always import the schema definition functions from `convex/server`.\n- 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)`.\n- 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\".\n- 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.\n- 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.\n- 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.\n\n## Authentication guidelines\n\n- 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`.\n- Example `convex/auth.config.ts`:\n\n```typescript\nexport default {\n  providers: [\n    {\n      domain: \"https://your-auth-provider.com\",\n      applicationID: \"convex\",\n    },\n  ],\n};\n```\n\nThe `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.\n\n- 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.\n- 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.\n- 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()`.\n- When using an external auth provider with Convex on the client, use `ConvexProviderWithAuth` instead of `ConvexProvider`:\n\n```tsx\nimport { ConvexProviderWithAuth, ConvexReactClient } from \"convex/react\";\n\nconst convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);\n\nfunction App({ children }: { children: React.ReactNode }) {\n  return (\n    <ConvexProviderWithAuth client={convex} useAuth={useYourAuthHook}>\n      {children}\n    </ConvexProviderWithAuth>\n  );\n}\n```\n\nThe `useAuth` prop must return `{ isLoading, isAuthenticated, fetchAccessToken }`. Do NOT use plain `ConvexProvider` when authentication is needed — it will not send tokens with requests.\n\n## Typescript guidelines\n\n- 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.\n- Use `Doc<\"tableName\">` from `./_generated/dataModel` to get the full document type for a table.\n- Use `QueryCtx`, `MutationCtx`, `ActionCtx` from `./_generated/server` for typing function contexts. NEVER use `any` for ctx parameters — always use the proper context type.\n- 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:\n\n```ts\nimport { query } from \"./_generated/server\";\nimport { Doc, Id } from \"./_generated/dataModel\";\n\nexport const exampleQuery = query({\n  args: { userIds: v.array(v.id(\"users\")) },\n  handler: async (ctx, args) => {\n    const idToUsername: Record<Id<\"users\">, string> = {};\n    for (const userId of args.userIds) {\n      const user = await ctx.db.get(\"users\", userId);\n      if (user) {\n        idToUsername[user._id] = user.username;\n      }\n    }\n\n    return idToUsername;\n  },\n});\n```\n\n- 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`.\n\n## Full text search guidelines\n\n- A query for \"10 messages in channel '#general' that best match the query 'hello hi' in their body\" would look like:\n\nconst messages = await ctx.db\n.query(\"messages\")\n.withSearchIndex(\"search_body\", (q) =>\nq.search(\"body\", \"hello hi\").eq(\"channel\", \"#general\"),\n)\n.take(10);\n\n## Query guidelines\n\n- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead.\n- 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.\n- 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.\n- 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.\n- 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.\n- 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.\n- 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.\n\n### Ordering\n\n- By default Convex always returns documents in ascending `_creationTime` order.\n- 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.\n- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.\n\n## Mutation guidelines\n\n- 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 })`\n- 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 })`\n\n## Action guidelines\n\n- Always add `\"use node\";` to the top of files containing actions that use Node.js built-in modules.\n- 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.\n- `fetch()` is available in the default Convex runtime. You do NOT need `\"use node\";` just to use `fetch()`.\n- Never use `ctx.db` inside of an action. Actions don't have access to the database.\n- Below is an example of the syntax for an action:\n\n```ts\nimport { action } from \"./_generated/server\";\n\nexport const exampleAction = action({\n  args: {},\n  handler: async (ctx, args) => {\n    console.log(\"This action does not return anything\");\n    return null;\n  },\n});\n```\n\n## Scheduling guidelines\n\n### Cron guidelines\n\n- 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.\n- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods.\n- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example,\n\n```ts\nimport { cronJobs } from \"convex/server\";\nimport { internal } from \"./_generated/api\";\nimport { internalAction } from \"./_generated/server\";\n\nconst empty = internalAction({\n  args: {},\n  handler: async (ctx, args) => {\n    console.log(\"empty\");\n  },\n});\n\nconst crons = cronJobs();\n\n// Run `internal.crons.empty` every two hours.\ncrons.interval(\"delete inactive users\", { hours: 2 }, internal.crons.empty, {});\n\nexport default crons;\n```\n\n- You can register Convex functions within `crons.ts` just like any other file.\n- 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.\n\n## Testing guidelines\n\n- 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`.\n\nTest files go inside the `convex/` directory. You must pass a module map from `import.meta.glob` to `convexTest`:\n\n```typescript\n/// <reference types=\"vite/client\" />\nimport { convexTest } from \"convex-test\";\nimport { expect, test } from \"vitest\";\nimport { api } from \"./_generated/api\";\nimport schema from \"./schema\";\n\nconst modules = import.meta.glob(\"./**/*.ts\");\n\ntest(\"some behavior\", async () => {\n  const t = convexTest(schema, modules);\n  await t.mutation(api.messages.send, { body: \"Hi!\", author: \"Sarah\" });\n  const messages = await t.query(api.messages.list);\n  expect(messages).toMatchObject([{ body: \"Hi!\", author: \"Sarah\" }]);\n});\n```\n\nThe `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`.\n\n## File storage guidelines\n\n- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist.\n- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata.\n\nInstead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<\"_storage\">`.\n\n```\nimport { query } from \"./_generated/server\";\nimport { Id } from \"./_generated/dataModel\";\n\ntype FileMetadata = {\n    _id: Id<\"_storage\">;\n    _creationTime: number;\n    contentType?: string;\n    sha256: string;\n    size: number;\n}\n\nexport const exampleQuery = query({\n    args: { fileId: v.id(\"_storage\") },\n    handler: async (ctx, args) => {\n        const metadata: FileMetadata | null = await ctx.db.system.get(\"_storage\", args.fileId);\n        console.log(metadata);\n        return null;\n    },\n});\n```\n\n- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage.\n"
  },
  {
    "path": "convex/_generated/api.d.ts",
    "content": "/* eslint-disable */\n/**\n * Generated `api` utility.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate, run `npx convex dev`.\n * @module\n */\n\nimport type * as account from \"../account.js\";\nimport type * as adminData from \"../adminData.js\";\nimport type * as adminStoryWrite from \"../adminStoryWrite.js\";\nimport type * as adminWrite from \"../adminWrite.js\";\nimport type * as audioRead from \"../audioRead.js\";\nimport type * as auth from \"../auth.js\";\nimport type * as authFunctions from \"../authFunctions.js\";\nimport type * as authMigration from \"../authMigration.js\";\nimport type * as courseContributorBackfill from \"../courseContributorBackfill.js\";\nimport type * as courseWrite from \"../courseWrite.js\";\nimport type * as discordAvatarSync from \"../discordAvatarSync.js\";\nimport type * as discordBot from \"../discordBot.js\";\nimport type * as discordData from \"../discordData.js\";\nimport type * as discordRoleSync from \"../discordRoleSync.js\";\nimport type * as editorRead from \"../editorRead.js\";\nimport type * as editorSideEffects from \"../editorSideEffects.js\";\nimport type * as http from \"../http.js\";\nimport type * as landing from \"../landing.js\";\nimport type * as languageWrite from \"../languageWrite.js\";\nimport type * as lib_authorization from \"../lib/authorization.js\";\nimport type * as lib_courseContributors from \"../lib/courseContributors.js\";\nimport type * as lib_courseCounts from \"../lib/courseCounts.js\";\nimport type * as lib_discordAvatarSync from \"../lib/discordAvatarSync.js\";\nimport type * as lib_phpbb from \"../lib/phpbb.js\";\nimport type * as lib_publicStoryContent from \"../lib/publicStoryContent.js\";\nimport type * as localization from \"../localization.js\";\nimport type * as localizationWrite from \"../localizationWrite.js\";\nimport type * as lookupTables from \"../lookupTables.js\";\nimport type * as roles from \"../roles.js\";\nimport type * as storyApproval from \"../storyApproval.js\";\nimport type * as storyDone from \"../storyDone.js\";\nimport type * as storyPublicContent from \"../storyPublicContent.js\";\nimport type * as storyRead from \"../storyRead.js\";\nimport type * as storyTables from \"../storyTables.js\";\nimport type * as storyWrite from \"../storyWrite.js\";\nimport type * as userPreferences from \"../userPreferences.js\";\n\nimport type {\n  ApiFromModules,\n  FilterApi,\n  FunctionReference,\n} from \"convex/server\";\n\ndeclare const fullApi: ApiFromModules<{\n  account: typeof account;\n  adminData: typeof adminData;\n  adminStoryWrite: typeof adminStoryWrite;\n  adminWrite: typeof adminWrite;\n  audioRead: typeof audioRead;\n  auth: typeof auth;\n  authFunctions: typeof authFunctions;\n  authMigration: typeof authMigration;\n  courseContributorBackfill: typeof courseContributorBackfill;\n  courseWrite: typeof courseWrite;\n  discordAvatarSync: typeof discordAvatarSync;\n  discordBot: typeof discordBot;\n  discordData: typeof discordData;\n  discordRoleSync: typeof discordRoleSync;\n  editorRead: typeof editorRead;\n  editorSideEffects: typeof editorSideEffects;\n  http: typeof http;\n  landing: typeof landing;\n  languageWrite: typeof languageWrite;\n  \"lib/authorization\": typeof lib_authorization;\n  \"lib/courseContributors\": typeof lib_courseContributors;\n  \"lib/courseCounts\": typeof lib_courseCounts;\n  \"lib/discordAvatarSync\": typeof lib_discordAvatarSync;\n  \"lib/phpbb\": typeof lib_phpbb;\n  \"lib/publicStoryContent\": typeof lib_publicStoryContent;\n  localization: typeof localization;\n  localizationWrite: typeof localizationWrite;\n  lookupTables: typeof lookupTables;\n  roles: typeof roles;\n  storyApproval: typeof storyApproval;\n  storyDone: typeof storyDone;\n  storyPublicContent: typeof storyPublicContent;\n  storyRead: typeof storyRead;\n  storyTables: typeof storyTables;\n  storyWrite: typeof storyWrite;\n  userPreferences: typeof userPreferences;\n}>;\n\n/**\n * A utility for referencing Convex functions in your app's public API.\n *\n * Usage:\n * ```js\n * const myFunctionReference = api.myModule.myFunction;\n * ```\n */\nexport declare const api: FilterApi<\n  typeof fullApi,\n  FunctionReference<any, \"public\">\n>;\n\n/**\n * A utility for referencing Convex functions in your app's internal API.\n *\n * Usage:\n * ```js\n * const myFunctionReference = internal.myModule.myFunction;\n * ```\n */\nexport declare const internal: FilterApi<\n  typeof fullApi,\n  FunctionReference<any, \"internal\">\n>;\n\nexport declare const components: {\n  betterAuth: import(\"../betterAuth/_generated/component.js\").ComponentApi<\"betterAuth\">;\n};\n"
  },
  {
    "path": "convex/_generated/api.js",
    "content": "/* eslint-disable */\n/**\n * Generated `api` utility.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate, run `npx convex dev`.\n * @module\n */\n\nimport { anyApi, componentsGeneric } from \"convex/server\";\n\n/**\n * A utility for referencing Convex functions in your app's API.\n *\n * Usage:\n * ```js\n * const myFunctionReference = api.myModule.myFunction;\n * ```\n */\nexport const api = anyApi;\nexport const internal = anyApi;\nexport const components = componentsGeneric();\n"
  },
  {
    "path": "convex/_generated/dataModel.d.ts",
    "content": "/* eslint-disable */\n/**\n * Generated data model types.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate, run `npx convex dev`.\n * @module\n */\n\nimport type {\n  DataModelFromSchemaDefinition,\n  DocumentByName,\n  TableNamesInDataModel,\n  SystemTableNames,\n} from \"convex/server\";\nimport type { GenericId } from \"convex/values\";\nimport schema from \"../schema.js\";\n\n/**\n * The names of all of your Convex tables.\n */\nexport type TableNames = TableNamesInDataModel<DataModel>;\n\n/**\n * The type of a document stored in Convex.\n *\n * @typeParam TableName - A string literal type of the table name (like \"users\").\n */\nexport type Doc<TableName extends TableNames> = DocumentByName<\n  DataModel,\n  TableName\n>;\n\n/**\n * An identifier for a document in Convex.\n *\n * Convex documents are uniquely identified by their `Id`, which is accessible\n * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).\n *\n * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.\n *\n * IDs are just strings at runtime, but this type can be used to distinguish them from other\n * strings when type checking.\n *\n * @typeParam TableName - A string literal type of the table name (like \"users\").\n */\nexport type Id<TableName extends TableNames | SystemTableNames> =\n  GenericId<TableName>;\n\n/**\n * A type describing your Convex data model.\n *\n * This type includes information about what tables you have, the type of\n * documents stored in those tables, and the indexes defined on them.\n *\n * This type is used to parameterize methods like `queryGeneric` and\n * `mutationGeneric` to make them type-safe.\n */\nexport type DataModel = DataModelFromSchemaDefinition<typeof schema>;\n"
  },
  {
    "path": "convex/_generated/server.d.ts",
    "content": "/* eslint-disable */\n/**\n * Generated utilities for implementing server-side Convex query and mutation functions.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate, run `npx convex dev`.\n * @module\n */\n\nimport {\n  ActionBuilder,\n  HttpActionBuilder,\n  MutationBuilder,\n  QueryBuilder,\n  GenericActionCtx,\n  GenericMutationCtx,\n  GenericQueryCtx,\n  GenericDatabaseReader,\n  GenericDatabaseWriter,\n} from \"convex/server\";\nimport type { DataModel } from \"./dataModel.js\";\n\n/**\n * Define a query in this Convex app's public API.\n *\n * This function will be allowed to read your Convex database and will be accessible from the client.\n *\n * @param func - The query function. It receives a {@link QueryCtx} as its first argument.\n * @returns The wrapped query. Include this as an `export` to name it and make it accessible.\n */\nexport declare const query: QueryBuilder<DataModel, \"public\">;\n\n/**\n * Define a query that is only accessible from other Convex functions (but not from the client).\n *\n * This function will be allowed to read from your Convex database. It will not be accessible from the client.\n *\n * @param func - The query function. It receives a {@link QueryCtx} as its first argument.\n * @returns The wrapped query. Include this as an `export` to name it and make it accessible.\n */\nexport declare const internalQuery: QueryBuilder<DataModel, \"internal\">;\n\n/**\n * Define a mutation in this Convex app's public API.\n *\n * This function will be allowed to modify your Convex database and will be accessible from the client.\n *\n * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.\n * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.\n */\nexport declare const mutation: MutationBuilder<DataModel, \"public\">;\n\n/**\n * Define a mutation that is only accessible from other Convex functions (but not from the client).\n *\n * This function will be allowed to modify your Convex database. It will not be accessible from the client.\n *\n * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.\n * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.\n */\nexport declare const internalMutation: MutationBuilder<DataModel, \"internal\">;\n\n/**\n * Define an action in this Convex app's public API.\n *\n * An action is a function which can execute any JavaScript code, including non-deterministic\n * code and code with side-effects, like calling third-party services.\n * They can be run in Convex's JavaScript environment or in Node.js using the \"use node\" directive.\n * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.\n *\n * @param func - The action. It receives an {@link ActionCtx} as its first argument.\n * @returns The wrapped action. Include this as an `export` to name it and make it accessible.\n */\nexport declare const action: ActionBuilder<DataModel, \"public\">;\n\n/**\n * Define an action that is only accessible from other Convex functions (but not from the client).\n *\n * @param func - The function. It receives an {@link ActionCtx} as its first argument.\n * @returns The wrapped function. Include this as an `export` to name it and make it accessible.\n */\nexport declare const internalAction: ActionBuilder<DataModel, \"internal\">;\n\n/**\n * Define an HTTP action.\n *\n * The wrapped function will be used to respond to HTTP requests received\n * by a Convex deployment if the requests matches the path and method where\n * this action is routed. Be sure to route your httpAction in `convex/http.js`.\n *\n * @param func - The function. It receives an {@link ActionCtx} as its first argument\n * and a Fetch API `Request` object as its second.\n * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.\n */\nexport declare const httpAction: HttpActionBuilder;\n\n/**\n * A set of services for use within Convex query functions.\n *\n * The query context is passed as the first argument to any Convex query\n * function run on the server.\n *\n * This differs from the {@link MutationCtx} because all of the services are\n * read-only.\n */\nexport type QueryCtx = GenericQueryCtx<DataModel>;\n\n/**\n * A set of services for use within Convex mutation functions.\n *\n * The mutation context is passed as the first argument to any Convex mutation\n * function run on the server.\n */\nexport type MutationCtx = GenericMutationCtx<DataModel>;\n\n/**\n * A set of services for use within Convex action functions.\n *\n * The action context is passed as the first argument to any Convex action\n * function run on the server.\n */\nexport type ActionCtx = GenericActionCtx<DataModel>;\n\n/**\n * An interface to read from the database within Convex query functions.\n *\n * The two entry points are {@link DatabaseReader.get}, which fetches a single\n * document by its {@link Id}, or {@link DatabaseReader.query}, which starts\n * building a query.\n */\nexport type DatabaseReader = GenericDatabaseReader<DataModel>;\n\n/**\n * An interface to read from and write to the database within Convex mutation\n * functions.\n *\n * Convex guarantees that all writes within a single mutation are\n * executed atomically, so you never have to worry about partial writes leaving\n * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)\n * for the guarantees Convex provides your functions.\n */\nexport type DatabaseWriter = GenericDatabaseWriter<DataModel>;\n"
  },
  {
    "path": "convex/_generated/server.js",
    "content": "/* eslint-disable */\n/**\n * Generated utilities for implementing server-side Convex query and mutation functions.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate, run `npx convex dev`.\n * @module\n */\n\nimport {\n  actionGeneric,\n  httpActionGeneric,\n  queryGeneric,\n  mutationGeneric,\n  internalActionGeneric,\n  internalMutationGeneric,\n  internalQueryGeneric,\n} from \"convex/server\";\n\n/**\n * Define a query in this Convex app's public API.\n *\n * This function will be allowed to read your Convex database and will be accessible from the client.\n *\n * @param func - The query function. It receives a {@link QueryCtx} as its first argument.\n * @returns The wrapped query. Include this as an `export` to name it and make it accessible.\n */\nexport const query = queryGeneric;\n\n/**\n * Define a query that is only accessible from other Convex functions (but not from the client).\n *\n * This function will be allowed to read from your Convex database. It will not be accessible from the client.\n *\n * @param func - The query function. It receives a {@link QueryCtx} as its first argument.\n * @returns The wrapped query. Include this as an `export` to name it and make it accessible.\n */\nexport const internalQuery = internalQueryGeneric;\n\n/**\n * Define a mutation in this Convex app's public API.\n *\n * This function will be allowed to modify your Convex database and will be accessible from the client.\n *\n * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.\n * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.\n */\nexport const mutation = mutationGeneric;\n\n/**\n * Define a mutation that is only accessible from other Convex functions (but not from the client).\n *\n * This function will be allowed to modify your Convex database. It will not be accessible from the client.\n *\n * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.\n * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.\n */\nexport const internalMutation = internalMutationGeneric;\n\n/**\n * Define an action in this Convex app's public API.\n *\n * An action is a function which can execute any JavaScript code, including non-deterministic\n * code and code with side-effects, like calling third-party services.\n * They can be run in Convex's JavaScript environment or in Node.js using the \"use node\" directive.\n * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.\n *\n * @param func - The action. It receives an {@link ActionCtx} as its first argument.\n * @returns The wrapped action. Include this as an `export` to name it and make it accessible.\n */\nexport const action = actionGeneric;\n\n/**\n * Define an action that is only accessible from other Convex functions (but not from the client).\n *\n * @param func - The function. It receives an {@link ActionCtx} as its first argument.\n * @returns The wrapped function. Include this as an `export` to name it and make it accessible.\n */\nexport const internalAction = internalActionGeneric;\n\n/**\n * Define an HTTP action.\n *\n * The wrapped function will be used to respond to HTTP requests received\n * by a Convex deployment if the requests matches the path and method where\n * this action is routed. Be sure to route your httpAction in `convex/http.js`.\n *\n * @param func - The function. It receives an {@link ActionCtx} as its first argument\n * and a Fetch API `Request` object as its second.\n * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.\n */\nexport const httpAction = httpActionGeneric;\n"
  },
  {
    "path": "convex/account.ts",
    "content": "import { components } from \"./_generated/api\";\nimport { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { requireSessionLegacyUserId } from \"./lib/authorization\";\n\nexport const deleteCurrentUser = mutation({\n  args: {},\n  returns: v.null(),\n  handler: async (ctx) => {\n    const legacyUserId = await requireSessionLegacyUserId(ctx);\n    const user = (await ctx.runQuery(components.betterAuth.adapter.findOne, {\n      model: \"user\",\n      where: [{ field: \"userId\", operator: \"eq\", value: String(legacyUserId) }],\n    })) as { _id?: string | null } | null;\n\n    if (!user?._id) {\n      throw new Error(\"Account not found.\");\n    }\n\n    await ctx.runMutation(components.betterAuth.adapter.deleteOne, {\n      input: {\n        model: \"user\",\n        where: [{ field: \"_id\", value: user._id }],\n      },\n    });\n\n    return null;\n  },\n});\n"
  },
  {
    "path": "convex/adminData.ts",
    "content": "import {\n  mutation,\n  query,\n  type MutationCtx,\n  type QueryCtx,\n} from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport type { Id } from \"./_generated/dataModel\";\nimport { components } from \"./_generated/api\";\n\ntype AuthCtx = MutationCtx | QueryCtx;\n\nasync function isAdmin(ctx: AuthCtx) {\n  const identity = (await ctx.auth.getUserIdentity()) as {\n    role?: string | null;\n  } | null;\n  return identity?.role === \"admin\";\n}\n\nconst adminLanguageValidator = v.object({\n  id: v.number(),\n  name: v.string(),\n  short: v.string(),\n  flag: v.number(),\n  flag_file: v.string(),\n  speaker: v.string(),\n  default_text: v.string(),\n  tts_replace: v.string(),\n  public: v.boolean(),\n  rtl: v.boolean(),\n});\n\nconst adminCourseValidator = v.object({\n  id: v.number(),\n  learning_language: v.number(),\n  from_language: v.number(),\n  public: v.boolean(),\n  official: v.boolean(),\n  name: v.union(v.string(), v.null()),\n  about: v.union(v.string(), v.null()),\n  conlang: v.boolean(),\n  short: v.union(v.string(), v.null()),\n  tags: v.array(v.string()),\n});\n\nconst adminApprovalValidator = v.object({\n  id: v.number(),\n  date: v.number(),\n  name: v.string(),\n});\n\nconst adminStoryValidator = v.object({\n  id: v.number(),\n  name: v.string(),\n  image: v.string(),\n  public: v.boolean(),\n  short: v.string(),\n  approvals: v.array(adminApprovalValidator),\n});\n\nconst yesNoAllFilterValidator = v.union(\n  v.literal(\"all\"),\n  v.literal(\"yes\"),\n  v.literal(\"no\"),\n);\nconst roleFilterValidator = v.union(\n  v.literal(\"all\"),\n  v.literal(\"user\"),\n  v.literal(\"contributor\"),\n  v.literal(\"admin\"),\n);\n\nconst adminUserValidator = v.object({\n  rowKey: v.string(),\n  id: v.number(),\n  name: v.string(),\n  email: v.string(),\n  image: v.union(v.string(), v.null()),\n  regdate: v.union(v.number(), v.null()),\n  activated: v.boolean(),\n  role: v.boolean(),\n  admin: v.boolean(),\n  discordLinked: v.boolean(),\n  discordAccountId: v.union(v.string(), v.null()),\n  discordStoriesRole: v.union(v.string(), v.null()),\n  discordStoriesSyncStatus: v.union(\n    v.literal(\"assigned\"),\n    v.literal(\"up_to_date\"),\n    v.literal(\"no_milestone\"),\n    v.literal(\"not_linked\"),\n    v.literal(\"member_not_found\"),\n    v.literal(\"error\"),\n    v.null(),\n  ),\n  discordStoriesLastSyncedAt: v.union(v.number(), v.null()),\n});\n\nasync function findAuthUserByLegacyId(ctx: AuthCtx, legacyId: number) {\n  return (await ctx.runQuery(components.betterAuth.adapter.findOne, {\n    model: \"user\",\n    where: [{ field: \"userId\", operator: \"eq\", value: String(legacyId) }],\n  })) as {\n    _id: string;\n    userId?: string | null;\n    name?: string | null;\n    email?: string | null;\n    image?: string | null;\n    createdAt?: number | null;\n    role?: string | null;\n    emailVerified?: boolean | null;\n  } | null;\n}\n\nfunction toAdminUser(\n  user: {\n    _id?: string;\n    userId?: string | null;\n    name?: string | null;\n    email?: string | null;\n    image?: string | null;\n    createdAt?: number | null;\n    role?: string | null;\n    emailVerified?: unknown;\n  },\n  discordAccountId?: string | null,\n  storiesRoleSnapshot?: {\n    assignedStoriesCount?: number | null;\n    syncStatus?:\n      | \"assigned\"\n      | \"up_to_date\"\n      | \"no_milestone\"\n      | \"not_linked\"\n      | \"member_not_found\"\n      | \"error\"\n      | null;\n    lastSyncedAt?: number | null;\n  } | null,\n) {\n  const numericId = Number.parseInt(user.userId ?? \"\", 10);\n  const role = user.role ?? null;\n  const assignedStoriesCount =\n    typeof storiesRoleSnapshot?.assignedStoriesCount === \"number\"\n      ? storiesRoleSnapshot.assignedStoriesCount\n      : null;\n  return {\n    rowKey:\n      user._id ??\n      `${user.userId ?? \"\"}-${user.email ?? \"\"}-${user.createdAt ?? 0}`,\n    id: Number.isFinite(numericId) ? numericId : 0,\n    name: user.name ?? \"\",\n    email: user.email ?? \"\",\n    image:\n      typeof user.image === \"string\" && user.image.length > 0\n        ? user.image\n        : null,\n    regdate: typeof user.createdAt === \"number\" ? user.createdAt : null,\n    activated: Boolean(user.emailVerified),\n    role: role === \"contributor\" || role === \"admin\",\n    admin: role === \"admin\",\n    discordLinked:\n      typeof discordAccountId === \"string\" && discordAccountId.length > 0,\n    discordAccountId:\n      typeof discordAccountId === \"string\" && discordAccountId.length > 0\n        ? discordAccountId\n        : null,\n    discordStoriesRole:\n      typeof assignedStoriesCount === \"number\" && assignedStoriesCount > 0\n        ? `${assignedStoriesCount} Stories`\n        : null,\n    discordStoriesSyncStatus: storiesRoleSnapshot?.syncStatus ?? null,\n    discordStoriesLastSyncedAt:\n      typeof storiesRoleSnapshot?.lastSyncedAt === \"number\"\n        ? storiesRoleSnapshot.lastSyncedAt\n        : null,\n  };\n}\n\nasync function getDiscordAccountIdsByAuthUserIds(\n  ctx: AuthCtx,\n  authUserIds: string[],\n) {\n  const uniqueAuthUserIds = Array.from(\n    new Set(authUserIds.filter((value) => value.length > 0)),\n  );\n  if (uniqueAuthUserIds.length === 0) return new Map<string, string>();\n\n  const response = await ctx.runQuery(components.betterAuth.adapter.findMany, {\n    model: \"account\",\n    where: [\n      { field: \"providerId\", operator: \"eq\", value: \"discord\" },\n      { field: \"userId\", operator: \"in\", value: uniqueAuthUserIds },\n    ],\n    paginationOpts: { cursor: null, numItems: uniqueAuthUserIds.length + 20 },\n  });\n\n  const discordAccountIdByAuthUserId = new Map<string, string>();\n  for (const account of response.page as Array<{\n    userId?: string | null;\n    accountId?: string | null;\n  }>) {\n    if (!account.userId || !account.accountId) continue;\n    discordAccountIdByAuthUserId.set(account.userId, account.accountId);\n  }\n  return discordAccountIdByAuthUserId;\n}\n\nasync function getStoriesRoleSnapshotsByLegacyUserIds(\n  ctx: AuthCtx,\n  legacyUserIds: number[],\n) {\n  const wantedIds = new Set(\n    legacyUserIds.filter((legacyUserId) => Number.isFinite(legacyUserId)),\n  );\n  if (wantedIds.size === 0) {\n    return new Map<\n      number,\n      {\n        assignedStoriesCount?: number | null;\n        syncStatus?:\n          | \"assigned\"\n          | \"up_to_date\"\n          | \"no_milestone\"\n          | \"not_linked\"\n          | \"member_not_found\"\n          | \"error\"\n          | null;\n        lastSyncedAt?: number | null;\n      }\n    >();\n  }\n\n  const snapshotByLegacyUserId = new Map<\n    number,\n    {\n      assignedStoriesCount?: number | null;\n      syncStatus?:\n        | \"assigned\"\n        | \"up_to_date\"\n        | \"no_milestone\"\n        | \"not_linked\"\n        | \"member_not_found\"\n        | \"error\"\n        | null;\n      lastSyncedAt?: number | null;\n    }\n  >();\n\n  const rows = await Promise.all(\n    Array.from(wantedIds).map((legacyUserId) =>\n      ctx.db\n        .query(\"discord_stories_role_sync\")\n        .withIndex(\"by_legacy_user_id\", (q) =>\n          q.eq(\"legacyUserId\", legacyUserId),\n        )\n        .unique(),\n    ),\n  );\n\n  for (const row of rows) {\n    if (!row) continue;\n    if (!wantedIds.has(row.legacyUserId)) continue;\n    snapshotByLegacyUserId.set(row.legacyUserId, row);\n  }\n  return snapshotByLegacyUserId;\n}\n\nexport const getAdminUsersPage = query({\n  args: {\n    query: v.string(),\n    limit: v.number(),\n    activatedFilter: yesNoAllFilterValidator,\n    roleFilter: roleFilterValidator,\n  },\n  returns: v.object({\n    users: v.array(adminUserValidator),\n    hasMore: v.boolean(),\n  }),\n  handler: async (ctx, args) => {\n    if (!(await isAdmin(ctx))) {\n      return { users: [], hasMore: false };\n    }\n\n    const limit = Math.max(1, Math.min(500, Math.floor(args.limit)));\n    const queryLimit = limit + 1;\n    const searchTerm = args.query.trim();\n    const searchLower = searchTerm.toLowerCase();\n    const searchMode =\n      searchTerm.length === 0\n        ? \"none\"\n        : /^\\d+$/.test(searchLower)\n          ? \"id\"\n          : searchTerm.includes(\"@\")\n            ? \"email\"\n            : \"username\";\n\n    const matchedUsers =\n      searchMode === \"id\"\n        ? await ctx.runQuery(components.betterAuth.adapter.searchUsersById, {\n            activatedFilter: args.activatedFilter,\n            roleFilter: args.roleFilter,\n            id: searchTerm,\n          })\n        : searchMode === \"email\"\n          ? await ctx.runQuery(\n              components.betterAuth.adapter.searchUsersByEmailPrefix,\n              {\n                activatedFilter: args.activatedFilter,\n                roleFilter: args.roleFilter,\n                prefix: searchTerm,\n                limit: queryLimit,\n              },\n            )\n          : searchMode === \"username\"\n            ? await ctx.runQuery(\n                components.betterAuth.adapter.searchUsersByUsernamePrefix,\n                {\n                  activatedFilter: args.activatedFilter,\n                  roleFilter: args.roleFilter,\n                  prefix: searchTerm,\n                  limit: queryLimit,\n                },\n              )\n            : await ctx.runQuery(components.betterAuth.adapter.searchUsersAll, {\n                activatedFilter: args.activatedFilter,\n                roleFilter: args.roleFilter,\n                limit: queryLimit,\n              });\n    const pageUsers = matchedUsers.slice(0, limit) as Array<{\n      _id?: string;\n      userId?: string | null;\n      name?: string | null;\n      email?: string | null;\n      image?: string | null;\n      createdAt?: number | null;\n      role?: string | null;\n      emailVerified?: unknown;\n    }>;\n    const discordAccountIdByAuthUserId =\n      await getDiscordAccountIdsByAuthUserIds(\n        ctx,\n        pageUsers\n          .map((user) => user._id)\n          .filter((value): value is string => typeof value === \"string\"),\n      );\n    const storiesRoleSnapshotByLegacyUserId =\n      await getStoriesRoleSnapshotsByLegacyUserIds(\n        ctx,\n        pageUsers\n          .map((user) => Number.parseInt(user.userId ?? \"\", 10))\n          .filter((value) => Number.isFinite(value)),\n      );\n    const users = pageUsers.map((user) =>\n      toAdminUser(\n        user,\n        typeof user._id === \"string\"\n          ? (discordAccountIdByAuthUserId.get(user._id) ?? null)\n          : null,\n        (() => {\n          const legacyUserId = Number.parseInt(user.userId ?? \"\", 10);\n          return Number.isFinite(legacyUserId)\n            ? (storiesRoleSnapshotByLegacyUserId.get(legacyUserId) ?? null)\n            : null;\n        })(),\n      ),\n    );\n    return { users, hasMore: matchedUsers.length > limit };\n  },\n});\n\nexport const getAdminUserByLegacyId = query({\n  args: {\n    id: v.number(),\n  },\n  returns: v.union(adminUserValidator, v.null()),\n  handler: async (ctx, args) => {\n    if (!(await isAdmin(ctx))) return null;\n    const user = await findAuthUserByLegacyId(ctx, args.id);\n    if (!user?._id) return null;\n    const discordAccountIdByAuthUserId =\n      await getDiscordAccountIdsByAuthUserIds(ctx, [user._id]);\n    const storiesRoleSnapshotByLegacyUserId =\n      await getStoriesRoleSnapshotsByLegacyUserIds(ctx, [args.id]);\n    return toAdminUser(\n      user,\n      discordAccountIdByAuthUserId.get(user._id) ?? null,\n      storiesRoleSnapshotByLegacyUserId.get(args.id) ?? null,\n    );\n  },\n});\n\nexport const setAdminUserActivated = mutation({\n  args: {\n    id: v.number(),\n    activated: v.boolean(),\n  },\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    if (!(await isAdmin(ctx))) return null;\n    const user = await findAuthUserByLegacyId(ctx, args.id);\n    if (!user?._id) return null;\n\n    await ctx.runMutation(components.betterAuth.adapter.updateOne, {\n      input: {\n        model: \"user\",\n        where: [{ field: \"_id\", value: user._id }],\n        update: { emailVerified: args.activated },\n      },\n    });\n    return null;\n  },\n});\n\nexport const setAdminUserWrite = mutation({\n  args: {\n    id: v.number(),\n    write: v.boolean(),\n  },\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    if (!(await isAdmin(ctx))) return null;\n    const user = await findAuthUserByLegacyId(ctx, args.id);\n    if (!user?._id) return null;\n\n    await ctx.runMutation(components.betterAuth.adapter.updateOne, {\n      input: {\n        model: \"user\",\n        where: [{ field: \"_id\", value: user._id }],\n        update: { role: args.write ? \"contributor\" : \"user\" },\n      },\n    });\n    return null;\n  },\n});\n\nexport const setAdminUserDelete = mutation({\n  args: {\n    id: v.number(),\n  },\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    if (!(await isAdmin(ctx))) return null;\n    const user = await findAuthUserByLegacyId(ctx, args.id);\n    if (!user?._id) return null;\n\n    await ctx.runMutation(components.betterAuth.adapter.deleteOne, {\n      input: {\n        model: \"user\",\n        where: [{ field: \"_id\", value: user._id }],\n      },\n    });\n    return null;\n  },\n});\n\nexport const getAdminLanguages = query({\n  args: {},\n  returns: v.array(adminLanguageValidator),\n  handler: async (ctx) => {\n    if (!(await isAdmin(ctx))) return [];\n\n    const rows = await ctx.db.query(\"languages\").collect();\n    return rows\n      .map((row) => ({\n        id: row.legacyId,\n        name: row.name,\n        short: row.short,\n        flag:\n          typeof row.flag === \"number\"\n            ? row.flag\n            : Number.isFinite(Number(row.flag))\n              ? Number(row.flag)\n              : 0,\n        flag_file: row.flag_file ?? \"\",\n        speaker: row.speaker ?? \"\",\n        default_text: row.default_text ?? \"\",\n        tts_replace: row.tts_replace ?? \"\",\n        public: row.public,\n        rtl: row.rtl,\n      }))\n      .sort((a, b) => a.id - b.id);\n  },\n});\n\nexport const getAdminCourses = query({\n  args: {},\n  returns: v.object({\n    courses: v.array(adminCourseValidator),\n    languages: v.array(adminLanguageValidator),\n  }),\n  handler: async (ctx) => {\n    if (!(await isAdmin(ctx))) {\n      return {\n        courses: [],\n        languages: [],\n      };\n    }\n\n    const [courseRows, languageRows] = await Promise.all([\n      ctx.db.query(\"courses\").collect(),\n      ctx.db.query(\"languages\").collect(),\n    ]);\n\n    const languageIdToLegacy = new Map<Id<\"languages\">, number>();\n    for (const language of languageRows) {\n      languageIdToLegacy.set(language._id, language.legacyId);\n    }\n\n    const courses = courseRows\n      .map((row) => ({\n        id: row.legacyId,\n        learning_language: languageIdToLegacy.get(row.learningLanguageId) ?? 0,\n        from_language: languageIdToLegacy.get(row.fromLanguageId) ?? 0,\n        public: row.public,\n        official: row.official,\n        name: row.name ?? null,\n        about: row.about ?? null,\n        conlang: row.conlang ?? false,\n        short: row.short ?? null,\n        tags: row.tags ?? [],\n      }))\n      .sort((a, b) => a.id - b.id);\n\n    const languages = languageRows\n      .map((row) => ({\n        id: row.legacyId,\n        name: row.name,\n        short: row.short,\n        flag:\n          typeof row.flag === \"number\"\n            ? row.flag\n            : Number.isFinite(Number(row.flag))\n              ? Number(row.flag)\n              : 0,\n        flag_file: row.flag_file ?? \"\",\n        speaker: row.speaker ?? \"\",\n        default_text: row.default_text ?? \"\",\n        tts_replace: row.tts_replace ?? \"\",\n        public: row.public,\n        rtl: row.rtl,\n      }))\n      .sort((a, b) => a.id - b.id);\n\n    return { courses, languages };\n  },\n});\n\nexport const getAdminStoryByLegacyId = query({\n  args: {\n    legacyStoryId: v.number(),\n  },\n  returns: v.union(adminStoryValidator, v.null()),\n  handler: async (ctx, args) => {\n    if (!(await isAdmin(ctx))) return null;\n\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.legacyStoryId))\n      .unique();\n    if (!story || typeof story.legacyId !== \"number\") return null;\n\n    const course = await ctx.db.get(story.courseId);\n    if (!course || !course.short) return null;\n\n    const approvals = await ctx.db\n      .query(\"story_approval\")\n      .withIndex(\"by_story\", (q) => q.eq(\"storyId\", story._id))\n      .collect();\n\n    const legacyIds = approvals\n      .map((approval) => approval.legacyUserId)\n      .filter((id): id is number => typeof id === \"number\");\n\n    const authUsers = await ctx.runQuery(\n      components.betterAuth.adapter.findMany,\n      {\n        model: \"user\",\n        where: [\n          { field: \"userId\", operator: \"in\", value: legacyIds.map(String) },\n        ],\n        paginationOpts: { cursor: null, numItems: legacyIds.length + 10 },\n      },\n    );\n    const userNameByLegacyId = new Map<number, string>();\n    for (const user of authUsers.page as Array<{\n      userId?: string | null;\n      name?: string | null;\n    }>) {\n      const legacyId = Number.parseInt(user.userId ?? \"\", 10);\n      if (!Number.isFinite(legacyId) || !user.name) continue;\n      userNameByLegacyId.set(legacyId, user.name);\n    }\n\n    return {\n      id: story.legacyId,\n      name: story.name,\n      image: story.imageId\n        ? ((await ctx.db.get(story.imageId))?.legacyId ?? \"\")\n        : \"\",\n      public: story.public,\n      short: course.short,\n      approvals: approvals\n        .map((approval) => ({\n          id: approval.legacyId ?? 0,\n          date: approval.date,\n          name:\n            typeof approval.legacyUserId === \"number\"\n              ? (userNameByLegacyId.get(approval.legacyUserId) ?? \"Unknown\")\n              : \"Unknown\",\n        }))\n        .filter((approval) => approval.id > 0),\n    };\n  },\n});\n"
  },
  {
    "path": "convex/adminStoryWrite.ts",
    "content": "import { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { requireAdmin } from \"./lib/authorization\";\nimport { recomputeCoursePublishedCount } from \"./lib/courseCounts\";\n\nexport const togglePublished = mutation({\n  args: {\n    legacyStoryId: v.number(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    await requireAdmin(ctx);\n\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.legacyStoryId))\n      .unique();\n    if (!story || typeof story.legacyId !== \"number\") {\n      throw new Error(`Story ${args.legacyStoryId} not found`);\n    }\n\n    const course = await ctx.db.get(story.courseId);\n    if (!course || typeof course.legacyId !== \"number\") {\n      throw new Error(`Course missing for story ${args.legacyStoryId}`);\n    }\n\n    const nextPublic = !story.public;\n    await ctx.db.patch(story._id, { public: nextPublic });\n    await recomputeCoursePublishedCount(ctx, course._id);\n\n    return null;\n  },\n});\n\nexport const removeApproval = mutation({\n  args: {\n    legacyStoryId: v.number(),\n    legacyApprovalId: v.number(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    await requireAdmin(ctx);\n\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.legacyStoryId))\n      .unique();\n    if (!story || typeof story.legacyId !== \"number\") {\n      throw new Error(`Story ${args.legacyStoryId} not found`);\n    }\n\n    const approval = await ctx.db\n      .query(\"story_approval\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.legacyApprovalId))\n      .unique();\n    if (!approval) {\n      return null;\n    }\n\n    if (approval.storyId !== story._id) {\n      throw new Error(\"Approval does not belong to story\");\n    }\n\n    await ctx.db.delete(approval._id);\n\n    const approvals = await ctx.db\n      .query(\"story_approval\")\n      .withIndex(\"by_story\", (q) => q.eq(\"storyId\", story._id))\n      .collect();\n    const approvalCount = approvals.length;\n    const storyStatus: \"draft\" | \"feedback\" | \"finished\" =\n      approvalCount === 0\n        ? \"draft\"\n        : approvalCount === 1\n          ? \"feedback\"\n          : \"finished\";\n    await ctx.db.patch(story._id, {\n      status: storyStatus,\n      approvalCount,\n    });\n\n    return null;\n  },\n});\n"
  },
  {
    "path": "convex/adminWrite.ts",
    "content": "import { mutation } from \"./_generated/server\";\nimport type { MutationCtx } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { requireAdmin } from \"./lib/authorization\";\n\nfunction toLegacyLanguageResponse(row: {\n  legacyId: number;\n  name: string;\n  short: string;\n  flag?: number | string;\n  flag_file?: string;\n  speaker?: string;\n  default_text?: string;\n  tts_replace?: string;\n  public: boolean;\n  rtl: boolean;\n}) {\n  return {\n    id: row.legacyId,\n    name: row.name,\n    short: row.short,\n    flag:\n      typeof row.flag === \"number\"\n        ? row.flag\n        : Number.isFinite(Number(row.flag))\n          ? Number(row.flag)\n          : 0,\n    flag_file: row.flag_file ?? \"\",\n    speaker: row.speaker ?? \"\",\n    default_text: row.default_text ?? \"\",\n    tts_replace: row.tts_replace ?? \"\",\n    public: row.public,\n    rtl: row.rtl,\n  };\n}\n\nasync function getNextLegacyId(\n  ctx: MutationCtx,\n  table: \"languages\" | \"courses\",\n) {\n  const rows = await ctx.db.query(table).collect();\n  const current = rows.reduce((max, row) => {\n    const legacyId = Number(row.legacyId ?? 0);\n    return legacyId > max ? legacyId : max;\n  }, 0);\n  return Math.max(1, current + 1);\n}\n\nasync function getNextUnusedLegacyId(\n  ctx: MutationCtx,\n  table: \"languages\" | \"courses\",\n) {\n  let candidate = await getNextLegacyId(ctx, table);\n  for (let attempts = 0; attempts < 1000; attempts += 1) {\n    const existing = await ctx.db\n      .query(table)\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", candidate))\n      .unique();\n    if (!existing) return candidate;\n    candidate += 1;\n  }\n  throw new Error(`Failed to allocate unique ${table} legacy ID`);\n}\n\nasync function getLanguageByLegacyId(ctx: MutationCtx, legacyId: number) {\n  return await ctx.db\n    .query(\"languages\")\n    .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", legacyId))\n    .unique();\n}\n\nexport const updateAdminLanguage = mutation({\n  args: {\n    id: v.number(),\n    name: v.string(),\n    short: v.string(),\n    flag: v.number(),\n    flag_file: v.string(),\n    speaker: v.string(),\n    rtl: v.boolean(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    id: v.number(),\n    name: v.string(),\n    short: v.string(),\n    flag: v.number(),\n    flag_file: v.string(),\n    speaker: v.string(),\n    default_text: v.string(),\n    tts_replace: v.string(),\n    public: v.boolean(),\n    rtl: v.boolean(),\n  }),\n  handler: async (ctx, args) => {\n    await requireAdmin(ctx);\n    const language = await getLanguageByLegacyId(ctx, args.id);\n\n    if (!language) {\n      throw new Error(`Language ${args.id} not found`);\n    }\n\n    const operationKey =\n      args.operationKey ?? `language:${args.id}:admin_set:${Date.now()}`;\n\n    await ctx.db.patch(language._id, {\n      name: args.name,\n      short: args.short,\n      flag: args.flag,\n      flag_file: args.flag_file,\n      speaker: args.speaker,\n      rtl: args.rtl,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: operationKey,\n    });\n\n    return toLegacyLanguageResponse({\n      ...language,\n      name: args.name,\n      short: args.short,\n      flag: args.flag,\n      flag_file: args.flag_file,\n      speaker: args.speaker,\n      rtl: args.rtl,\n    });\n  },\n});\n\nexport const createAdminLanguage = mutation({\n  args: {\n    name: v.string(),\n    short: v.string(),\n    flag: v.number(),\n    flag_file: v.string(),\n    speaker: v.string(),\n    rtl: v.boolean(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    id: v.number(),\n    name: v.string(),\n    short: v.string(),\n    flag: v.number(),\n    flag_file: v.string(),\n    speaker: v.string(),\n    default_text: v.string(),\n    tts_replace: v.string(),\n    public: v.boolean(),\n    rtl: v.boolean(),\n  }),\n  handler: async (ctx, args) => {\n    await requireAdmin(ctx);\n    const legacyId = await getNextUnusedLegacyId(ctx, \"languages\");\n    const operationKey =\n      args.operationKey ?? `language:${legacyId}:admin_create:${Date.now()}`;\n\n    await ctx.db.insert(\"languages\", {\n      legacyId,\n      name: args.name,\n      short: args.short,\n      flag: args.flag,\n      flag_file: args.flag_file,\n      speaker: args.speaker,\n      default_text: \"\",\n      tts_replace: \"\",\n      public: false,\n      rtl: args.rtl,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: operationKey,\n    });\n\n    return {\n      id: legacyId,\n      name: args.name,\n      short: args.short,\n      flag: args.flag,\n      flag_file: args.flag_file,\n      speaker: args.speaker,\n      default_text: \"\",\n      tts_replace: \"\",\n      public: false,\n      rtl: args.rtl,\n    };\n  },\n});\n\nexport const updateAdminCourse = mutation({\n  args: {\n    id: v.number(),\n    learning_language: v.number(),\n    from_language: v.number(),\n    public: v.optional(v.boolean()),\n    name: v.optional(v.string()),\n    conlang: v.optional(v.boolean()),\n    tags: v.optional(v.array(v.string())),\n    about: v.optional(v.string()),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    id: v.number(),\n    learning_language: v.number(),\n    from_language: v.number(),\n    public: v.boolean(),\n    official: v.boolean(),\n    name: v.union(v.string(), v.null()),\n    about: v.union(v.string(), v.null()),\n    conlang: v.boolean(),\n    short: v.union(v.string(), v.null()),\n    tags: v.array(v.string()),\n  }),\n  handler: async (ctx, args) => {\n    await requireAdmin(ctx);\n    const [course, learningLanguage, fromLanguage] = await Promise.all([\n      ctx.db\n        .query(\"courses\")\n        .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.id))\n        .unique(),\n      getLanguageByLegacyId(ctx, args.learning_language),\n      getLanguageByLegacyId(ctx, args.from_language),\n    ]);\n\n    if (!course) throw new Error(`Course ${args.id} not found`);\n    if (!learningLanguage || !fromLanguage) {\n      throw new Error(\"Course languages not found\");\n    }\n\n    const short = `${learningLanguage.short}-${fromLanguage.short}`;\n    const operationKey =\n      args.operationKey ?? `course:${args.id}:admin_set:${Date.now()}`;\n    const nextPublic = args.public ?? course.public;\n    const nextName = args.name ?? course.name ?? null;\n    const nextConlang = args.conlang ?? course.conlang ?? false;\n    const nextTags = args.tags ?? course.tags ?? [];\n    const nextAbout = args.about ?? course.about ?? null;\n\n    const patchData: {\n      learningLanguageId: typeof learningLanguage._id;\n      fromLanguageId: typeof fromLanguage._id;\n      learning_language_name: string;\n      from_language_name: string;\n      short: string;\n      public: boolean;\n      mirrorUpdatedAt: number;\n      lastOperationKey: string;\n      name?: string;\n      conlang?: boolean;\n      tags?: string[];\n      about?: string;\n    } = {\n      learningLanguageId: learningLanguage._id,\n      fromLanguageId: fromLanguage._id,\n      learning_language_name: learningLanguage.name,\n      from_language_name: fromLanguage.name,\n      short,\n      public: nextPublic,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: operationKey,\n    };\n    if (nextName !== null) patchData.name = nextName;\n    if (nextAbout !== null) patchData.about = nextAbout;\n    patchData.conlang = nextConlang;\n    patchData.tags = nextTags;\n\n    await ctx.db.patch(course._id, patchData);\n\n    return {\n      id: args.id,\n      learning_language: args.learning_language,\n      from_language: args.from_language,\n      public: nextPublic,\n      official: course.official,\n      name: nextName,\n      about: nextAbout,\n      conlang: nextConlang,\n      short,\n      tags: nextTags,\n    };\n  },\n});\n\nexport const createAdminCourse = mutation({\n  args: {\n    learning_language: v.number(),\n    from_language: v.number(),\n    public: v.optional(v.boolean()),\n    name: v.optional(v.string()),\n    official: v.optional(v.number()),\n    conlang: v.optional(v.boolean()),\n    tags: v.optional(v.array(v.string())),\n    about: v.optional(v.string()),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    id: v.number(),\n    learning_language: v.number(),\n    from_language: v.number(),\n    public: v.boolean(),\n    official: v.boolean(),\n    name: v.union(v.string(), v.null()),\n    about: v.union(v.string(), v.null()),\n    conlang: v.boolean(),\n    short: v.union(v.string(), v.null()),\n    tags: v.array(v.string()),\n  }),\n  handler: async (ctx, args) => {\n    await requireAdmin(ctx);\n    const [learningLanguage, fromLanguage] = await Promise.all([\n      getLanguageByLegacyId(ctx, args.learning_language),\n      getLanguageByLegacyId(ctx, args.from_language),\n    ]);\n    if (!learningLanguage || !fromLanguage) {\n      throw new Error(\"Course languages not found\");\n    }\n\n    const legacyId = await getNextUnusedLegacyId(ctx, \"courses\");\n    const short = `${learningLanguage.short}-${fromLanguage.short}`;\n    const nextPublic = args.public ?? false;\n    const nextOfficial = args.official ?? 0;\n    const nextName = args.name ?? null;\n    const nextConlang = args.conlang ?? false;\n    const nextTags = args.tags ?? [];\n    const nextAbout = args.about ?? null;\n    const operationKey =\n      args.operationKey ?? `course:${legacyId}:admin_create:${Date.now()}`;\n\n    await ctx.db.insert(\"courses\", {\n      legacyId,\n      short,\n      learningLanguageId: learningLanguage._id,\n      fromLanguageId: fromLanguage._id,\n      public: nextPublic,\n      official: nextOfficial !== 0,\n      name: nextName ?? undefined,\n      about: nextAbout ?? undefined,\n      conlang: nextConlang,\n      tags: nextTags,\n      learning_language_name: learningLanguage.name,\n      from_language_name: fromLanguage.name,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: operationKey,\n    });\n\n    return {\n      id: legacyId,\n      learning_language: args.learning_language,\n      from_language: args.from_language,\n      public: nextPublic,\n      official: nextOfficial !== 0,\n      name: nextName,\n      about: nextAbout,\n      conlang: nextConlang,\n      short,\n      tags: nextTags,\n    };\n  },\n});\n"
  },
  {
    "path": "convex/audioRead.ts",
    "content": "import { query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nexport const getSpeakerByName = query({\n  args: {\n    speaker: v.string(),\n  },\n  returns: v.union(\n    v.object({\n      id: v.number(),\n      speaker: v.string(),\n      type: v.string(),\n      gender: v.optional(v.string()),\n      service: v.optional(v.string()),\n    }),\n    v.null(),\n  ),\n  handler: async (ctx, args) => {\n    const row = await ctx.db\n      .query(\"speakers\")\n      .withIndex(\"by_speaker\", (q) => q.eq(\"speaker\", args.speaker))\n      .unique();\n    if (!row) return null;\n\n    return {\n      id: row.legacyId ?? 0,\n      speaker: row.speaker,\n      type: row.type,\n      gender: row.gender,\n      service: row.service,\n    };\n  },\n});\n"
  },
  {
    "path": "convex/auth.config.ts",
    "content": "import { getAuthConfigProvider } from \"@convex-dev/better-auth/auth-config\";\nimport type { AuthConfig } from \"convex/server\";\n\nexport default {\n  providers: [getAuthConfigProvider()],\n} satisfies AuthConfig;\n"
  },
  {
    "path": "convex/auth.ts",
    "content": "import { authComponent } from \"./betterAuth/auth\";\nimport { query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { components } from \"./_generated/api\";\nimport { requireContributorOrAdmin } from \"./lib/authorization\";\n\nconst authClientApi = authComponent.clientApi();\nexport const getAuthUser = authClientApi.getAuthUser;\n\nexport const getCurrentUser = query({\n  args: {},\n  handler: async (ctx) => {\n    const identity = await ctx.auth.getUserIdentity();\n    if (!identity) return null;\n\n    return {\n      ...identity,\n      role: identity.role ?? \"user\",\n    };\n  },\n});\n\nexport const getLinkedProvidersForCurrentUser = query({\n  args: {},\n  handler: async (ctx) => {\n    const identity = (await ctx.auth.getUserIdentity()) as {\n      email?: string | null;\n    } | null;\n    const email = identity?.email?.toLowerCase();\n    if (!email) return [] as string[];\n\n    const authUser = await ctx.runQuery(components.betterAuth.adapter.findOne, {\n      model: \"user\",\n      where: [{ field: \"email\", value: email }],\n    });\n\n    if (!authUser?._id) return [] as string[];\n\n    const accounts = await ctx.runQuery(\n      components.betterAuth.adapter.findMany,\n      {\n        model: \"account\",\n        where: [{ field: \"userId\", value: authUser._id }],\n        paginationOpts: { cursor: null, numItems: 100 },\n      },\n    );\n\n    const providers = (accounts.page as Array<{ providerId?: string | null }>)\n      .map((account) => account.providerId)\n      .filter((provider): provider is string => Boolean(provider));\n\n    return Array.from(new Set(providers));\n  },\n});\n\nexport const getUserNamesByLegacyIds = query({\n  args: {\n    legacyIds: v.array(v.number()),\n  },\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n\n    const uniqueIds = Array.from(new Set(args.legacyIds));\n    if (!uniqueIds.length)\n      return [] as Array<{ legacyId: number; name: string }>;\n\n    const userIds = uniqueIds.map((id) => String(id));\n    const users = await ctx.runQuery(components.betterAuth.adapter.findMany, {\n      model: \"user\",\n      where: [{ field: \"userId\", operator: \"in\", value: userIds }],\n      paginationOpts: { cursor: null, numItems: userIds.length + 10 },\n    });\n\n    return (\n      users.page as Array<{ userId?: string | null; name?: string | null }>\n    )\n      .map((user) => {\n        const legacyId = Number.parseInt(user.userId ?? \"\", 10);\n        if (!Number.isFinite(legacyId) || !user.name) return null;\n        return { legacyId, name: user.name };\n      })\n      .filter((row): row is { legacyId: number; name: string } => row !== null);\n  },\n});\n"
  },
  {
    "path": "convex/authFunctions.ts",
    "content": "import { internalMutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { components } from \"./_generated/api\";\n\nexport const onCreate = internalMutation({\n  args: {\n    model: v.string(),\n    doc: v.any(),\n  },\n  handler: async (ctx, args) => {\n    if (args.model !== \"user\") return;\n    const doc = args.doc as { _id: string; userId?: string | null };\n    if (doc.userId) return;\n\n    let maxId = 0;\n    let cursor: string | null = null;\n    do {\n      const page = (await ctx.runQuery(\n        components.betterAuth.adapter.findMany as any,\n        {\n          model: \"user\",\n          where: [],\n          paginationOpts: { cursor, numItems: 1000 },\n        },\n      )) as any;\n\n      for (const user of page.page as Array<{ userId?: string | null }>) {\n        const parsed = Number.parseInt(user.userId ?? \"\", 10);\n        if (!Number.isNaN(parsed) && parsed > maxId) maxId = parsed;\n      }\n\n      cursor = page.isDone ? null : (page.continueCursor ?? null);\n    } while (cursor);\n\n    const nextId = maxId + 1;\n    await ctx.runMutation(components.betterAuth.adapter.updateOne, {\n      input: {\n        model: \"user\",\n        where: [{ field: \"_id\", value: doc._id }],\n        update: { userId: String(nextId) },\n      },\n    });\n  },\n});\n"
  },
  {
    "path": "convex/authMigration.ts",
    "content": "import { action, query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { api, components } from \"./_generated/api\";\n\nconst PAGE_SIZE = 200;\n\nexport const listLegacyUsersForRoleSync = query({\n  args: {\n    cursor: v.optional(v.string()),\n    limit: v.optional(v.number()),\n  },\n  handler: async () => {\n    // Legacy users table has been removed from app schema.\n    return {\n      page: [],\n      isDone: true,\n      continueCursor: null,\n    };\n  },\n});\n\nexport const syncBetterAuthRoles = action({\n  args: {\n    dryRun: v.optional(v.boolean()),\n    limit: v.optional(v.number()),\n  },\n  handler: async (ctx, args) => {\n    const dryRun = args.dryRun ?? false;\n    const limit = args.limit ?? PAGE_SIZE;\n    let cursor: string | undefined = undefined;\n\n    let total = 0;\n    let updated = 0;\n    let skipped = 0;\n    let missing = 0;\n\n    while (true) {\n      const pageResult = (await ctx.runQuery(\n        api.authMigration.listLegacyUsersForRoleSync,\n        { cursor: cursor ?? undefined, limit },\n      )) as {\n        page: Array<{ email?: string; admin?: boolean; role?: boolean }>;\n        isDone: boolean;\n        continueCursor?: string | null;\n      };\n\n      for (const legacyUser of pageResult.page) {\n        total += 1;\n\n        if (!legacyUser.email) {\n          skipped += 1;\n          continue;\n        }\n\n        let desiredRole: string | null = null;\n        if (legacyUser.admin) {\n          desiredRole = \"admin\";\n        } else if (legacyUser.role) {\n          desiredRole = \"contributor\";\n        }\n\n        if (!desiredRole) {\n          skipped += 1;\n          continue;\n        }\n\n        const authUser = await ctx.runQuery(\n          components.betterAuth.adapter.findOne,\n          {\n            model: \"user\",\n            where: [\n              {\n                field: \"email\",\n                value: legacyUser.email.toLowerCase(),\n              },\n            ],\n          },\n        );\n\n        if (!authUser) {\n          missing += 1;\n          continue;\n        }\n\n        const authRole = Array.isArray(authUser.role)\n          ? authUser.role[0]\n          : authUser.role;\n\n        if (authRole === desiredRole) {\n          skipped += 1;\n          continue;\n        }\n\n        if (!dryRun) {\n          await ctx.runMutation(components.betterAuth.adapter.updateOne, {\n            input: {\n              model: \"user\",\n              where: [\n                {\n                  field: \"_id\",\n                  value: (authUser as { _id: string })._id,\n                },\n              ],\n              update: { role: desiredRole } as any,\n            },\n          });\n        }\n\n        updated += 1;\n      }\n\n      if (pageResult.isDone) {\n        break;\n      }\n\n      cursor = pageResult.continueCursor ?? undefined;\n    }\n\n    return {\n      dryRun,\n      total,\n      updated,\n      skipped,\n      missing,\n    };\n  },\n});\n\nfunction normalizeUsername(input: string): string {\n  const lower = input.trim().toLowerCase();\n  const cleaned = lower.replace(/[^a-z0-9_-]/g, \"_\");\n  if (cleaned.length >= 3) return cleaned;\n  return `${cleaned || \"user\"}_${Math.random().toString(36).slice(2, 6)}`;\n}\n\nexport const migrateLegacyUsersToBetterAuth = action({\n  args: {\n    dryRun: v.optional(v.boolean()),\n    limit: v.optional(v.number()),\n    cursor: v.optional(v.string()),\n  },\n  handler: async (ctx, args) => {\n    const dryRun = args.dryRun ?? false;\n    const limit = args.limit ?? PAGE_SIZE;\n    const cursor = args.cursor ?? undefined;\n\n    let total = 0;\n    let created = 0;\n    let skipped = 0;\n    let missingPassword = 0;\n    let accountsCreated = 0;\n    let accountsFailed = 0;\n    const errors: Array<{\n      email?: string;\n      legacyId?: number;\n      step: string;\n      message: string;\n    }> = [];\n\n    const pageResult = (await ctx.runQuery(\n      api.authMigration.listLegacyUsersForRoleSync,\n      { cursor, limit },\n    )) as {\n      page: Array<{\n        legacyId?: number;\n        name?: string;\n        email?: string;\n        image?: string;\n        emailVerified?: number;\n        password?: string;\n        regdate?: number;\n      }>;\n      isDone: boolean;\n      continueCursor?: string | null;\n    };\n\n    for (const legacyUser of pageResult.page) {\n      total += 1;\n      if (!legacyUser.email) {\n        skipped += 1;\n        continue;\n      }\n\n      const email = legacyUser.email.toLowerCase();\n      const existing = await ctx.runQuery(\n        components.betterAuth.adapter.findOne,\n        {\n          model: \"user\",\n          where: [\n            {\n              field: \"email\",\n              value: email,\n            },\n          ],\n        },\n      );\n\n      if (existing) {\n        skipped += 1;\n        continue;\n      }\n\n      const createdAt = (() => {\n        if (typeof legacyUser.regdate !== \"number\") {\n          return Date.now();\n        }\n        return legacyUser.regdate > 1_000_000_000_000\n          ? legacyUser.regdate\n          : legacyUser.regdate * 1000;\n      })();\n      const displayUsername = legacyUser.name || email.split(\"@\")[0] || email;\n      const baseUsername = normalizeUsername(displayUsername);\n      const suffix = legacyUser.legacyId\n        ? `_${legacyUser.legacyId}`\n        : `_${Math.random().toString(36).slice(2, 10)}`;\n      const username = `${baseUsername}${suffix}`;\n\n      if (!dryRun) {\n        let newUser: { _id: string } | null | undefined = undefined;\n\n        try {\n          newUser = await ctx.runMutation(\n            components.betterAuth.adapter.create,\n            {\n              input: {\n                model: \"user\",\n                data: {\n                  createdAt,\n                  updatedAt: createdAt,\n                  email,\n                  emailVerified: Boolean(legacyUser.emailVerified),\n                  name: displayUsername,\n                  image: legacyUser.image ?? null,\n                  username,\n                  displayUsername,\n                },\n              },\n            },\n          );\n        } catch (error: any) {\n          errors.push({\n            email,\n            legacyId: legacyUser.legacyId,\n            step: \"createUser\",\n            message: String(error?.message || error),\n          });\n        }\n\n        const newUserId =\n          (newUser as { _id?: string } | null | undefined)?._id ??\n          (\n            await ctx.runQuery(components.betterAuth.adapter.findOne, {\n              model: \"user\",\n              where: [{ field: \"email\", value: email }],\n            })\n          )?._id;\n\n        if (!newUserId) {\n          skipped += 1;\n          continue;\n        }\n\n        if (legacyUser.password) {\n          try {\n            await ctx.runMutation(components.betterAuth.adapter.create, {\n              input: {\n                model: \"account\",\n                data: {\n                  accountId: newUserId,\n                  providerId: \"credential\",\n                  password: legacyUser.password,\n                  userId: newUserId,\n                  createdAt,\n                  updatedAt: createdAt,\n                },\n              },\n            });\n          } catch (error: any) {\n            errors.push({\n              email,\n              legacyId: legacyUser.legacyId,\n              step: \"createAccount\",\n              message: String(error?.message || error),\n            });\n          }\n        } else {\n          missingPassword += 1;\n        }\n      } else if (!legacyUser.password) {\n        missingPassword += 1;\n      }\n\n      created += 1;\n    }\n\n    return {\n      dryRun,\n      total,\n      created,\n      skipped,\n      missingPassword,\n      errors,\n      continueCursor: pageResult.continueCursor ?? undefined,\n      isDone: pageResult.isDone,\n    };\n  },\n});\n\nconst betterAuthUserInput = v.object({\n  legacyId: v.number(),\n  email: v.string(),\n  name: v.string(),\n  username: v.string(),\n  displayUsername: v.string(),\n  image: v.optional(v.union(v.null(), v.string())),\n  emailVerified: v.optional(v.boolean()),\n  createdAt: v.number(),\n  password: v.optional(v.string()),\n});\n\nconst betterAuthAccountInput = v.object({\n  legacyAccountId: v.number(),\n  legacyUserId: v.number(),\n  providerId: v.string(),\n  providerAccountId: v.optional(v.union(v.null(), v.string())),\n  accessToken: v.optional(v.string()),\n  refreshToken: v.optional(v.string()),\n  expiresAt: v.optional(v.number()),\n  tokenType: v.optional(v.string()),\n  scope: v.optional(v.string()),\n  idToken: v.optional(v.string()),\n  sessionState: v.optional(v.string()),\n  createdAt: v.number(),\n  updatedAt: v.number(),\n  accountType: v.optional(v.string()),\n});\n\nexport const importBetterAuthUsersBatch = action({\n  args: {\n    users: v.array(betterAuthUserInput),\n    verbose: v.optional(v.boolean()),\n    fastPath: v.optional(v.boolean()),\n  },\n  handler: async (ctx, args) => {\n    const users = args.users;\n    const verbose = args.verbose ?? false;\n    const fastPath = args.fastPath ?? false;\n    if (verbose) {\n      console.log(\n        `[importBetterAuthUsersBatch] received ${users.length} users`,\n        users[0]?.email ? `first=${users[0].email}` : \"\",\n      );\n    }\n    let created = 0;\n    let skipped = 0;\n    let missingPassword = 0;\n    let accountsCreated = 0;\n    let accountsFailed = 0;\n    const errors: Array<{\n      email: string;\n      legacyId: number;\n      step: string;\n      message: string;\n    }> = [];\n\n    const existingEmails = new Set<string>();\n\n    for (const user of users) {\n      const email = user.email.toLowerCase();\n      if (existingEmails.has(email)) {\n        skipped += 1;\n        continue;\n      }\n      existingEmails.add(email);\n\n      let newUserId: string | undefined;\n      let didCreate = false;\n      try {\n        const newUser = await ctx.runMutation(\n          components.betterAuth.adapter.create,\n          {\n            input: {\n              model: \"user\",\n              data: {\n                createdAt: user.createdAt,\n                updatedAt: user.createdAt,\n                email,\n                emailVerified: Boolean(user.emailVerified),\n                name: user.name,\n                image: user.image ?? null,\n                username: user.username,\n                displayUsername: user.displayUsername,\n                userId: String(user.legacyId),\n              },\n            },\n          },\n        );\n        if (verbose) {\n          console.log(\n            `[importBetterAuthUsersBatch] create user email=${email} id=${(newUser as { _id?: string } | null)?._id ?? \"null\"}`,\n          );\n        }\n        newUserId = (newUser as { _id?: string } | null)?._id ?? undefined;\n        didCreate = Boolean(newUserId);\n      } catch (error: any) {\n        const message = String(error?.message || error);\n        if (verbose) {\n          console.log(\n            `[importBetterAuthUsersBatch] create user error email=${email} message=${message}`,\n          );\n        }\n        if (message.includes(\"email already exists\")) {\n          skipped += 1;\n          if (fastPath) {\n            continue;\n          }\n          let existing = await ctx.runQuery(\n            components.betterAuth.adapter.findOne,\n            {\n              model: \"user\",\n              where: [\n                {\n                  field: \"email\",\n                  value: email,\n                },\n              ],\n            },\n          );\n          if (!existing?._id) {\n            existing = await ctx.runQuery(\n              components.betterAuth.adapter.findOne,\n              {\n                model: \"user\",\n                where: [\n                  {\n                    field: \"userId\",\n                    value: String(user.legacyId),\n                  },\n                ],\n              },\n            );\n          }\n          if (existing?._id) {\n            newUserId = existing._id;\n            const existingUserId =\n              (existing as { userId?: string | null }).userId ?? null;\n            if (!existingUserId) {\n              try {\n                await ctx.runMutation(components.betterAuth.adapter.updateOne, {\n                  input: {\n                    model: \"user\",\n                    where: [\n                      {\n                        field: \"_id\",\n                        value: existing._id,\n                      },\n                    ],\n                    update: { userId: String(user.legacyId) } as any,\n                  },\n                });\n              } catch (updateError: any) {\n                errors.push({\n                  email,\n                  legacyId: user.legacyId,\n                  step: \"updateExistingUserId\",\n                  message: String(updateError?.message || updateError),\n                });\n              }\n            }\n          }\n          if (!newUserId) {\n            errors.push({\n              email,\n              legacyId: user.legacyId,\n              step: \"findExistingUser\",\n              message: \"User exists but could not be fetched by email/userId.\",\n            });\n          }\n        } else {\n          errors.push({\n            email,\n            legacyId: user.legacyId,\n            step: \"createUser\",\n            message,\n          });\n        }\n        if (!newUserId) {\n          continue;\n        }\n      }\n\n      if (!newUserId) {\n        continue;\n      }\n\n      if (user.password) {\n        try {\n          if (!fastPath) {\n            const existingAccount = await ctx.runQuery(\n              components.betterAuth.adapter.findOne,\n              {\n                model: \"account\",\n                where: [\n                  {\n                    field: \"userId\",\n                    value: newUserId,\n                  },\n                  {\n                    field: \"providerId\",\n                    value: \"credential\",\n                  },\n                ],\n              },\n            );\n            if (existingAccount) {\n              continue;\n            }\n          }\n          await ctx.runMutation(components.betterAuth.adapter.create, {\n            input: {\n              model: \"account\",\n              data: {\n                accountId: newUserId,\n                providerId: \"credential\",\n                password: user.password,\n                userId: newUserId,\n                createdAt: user.createdAt,\n                updatedAt: user.createdAt,\n              },\n            },\n          });\n          accountsCreated += 1;\n        } catch (error: any) {\n          const message = String(error?.message || error);\n          if (fastPath && message.includes(\"already exists\")) {\n            continue;\n          }\n          accountsFailed += 1;\n          errors.push({\n            email,\n            legacyId: user.legacyId,\n            step: \"createAccount\",\n            message,\n          });\n        }\n      } else {\n        missingPassword += 1;\n      }\n\n      if (didCreate) {\n        created += 1;\n      }\n    }\n\n    return {\n      created,\n      skipped,\n      missingPassword,\n      accountsCreated,\n      accountsFailed,\n      errors,\n    };\n  },\n});\n\nexport const importBetterAuthAccountsBatch = action({\n  args: {\n    accounts: v.array(betterAuthAccountInput),\n    verbose: v.optional(v.boolean()),\n    fastPath: v.optional(v.boolean()),\n  },\n  handler: async (ctx, args) => {\n    const accounts = args.accounts;\n    const verbose = args.verbose ?? false;\n    const fastPath = args.fastPath ?? false;\n    if (verbose) {\n      console.log(\n        `[importBetterAuthAccountsBatch] received ${accounts.length} accounts`,\n      );\n    }\n\n    let created = 0;\n    let skipped = 0;\n    let missingUser = 0;\n    const errors: Array<{\n      providerId: string;\n      legacyUserId: number;\n      legacyAccountId: number;\n      step: string;\n      message: string;\n    }> = [];\n\n    for (const account of accounts) {\n      if (!account.providerAccountId) {\n        skipped += 1;\n        continue;\n      }\n      let authUser = await ctx.runQuery(components.betterAuth.adapter.findOne, {\n        model: \"user\",\n        where: [\n          {\n            field: \"userId\",\n            value: String(account.legacyUserId),\n          },\n        ],\n      });\n\n      if (!authUser?._id) {\n        missingUser += 1;\n        continue;\n      }\n\n      const authUserId = (authUser as { _id: string })._id;\n\n      if (!fastPath) {\n        const existingAccount = await ctx.runQuery(\n          components.betterAuth.adapter.findOne,\n          {\n            model: \"account\",\n            where: [\n              {\n                field: \"userId\",\n                value: authUserId,\n              },\n              {\n                field: \"providerId\",\n                value: account.providerId,\n              },\n              ...(account.providerAccountId\n                ? [\n                    {\n                      field: \"accountId\",\n                      value: account.providerAccountId,\n                    },\n                  ]\n                : []),\n            ],\n          },\n        );\n        if (existingAccount) {\n          skipped += 1;\n          continue;\n        }\n      }\n\n      try {\n        await ctx.runMutation(components.betterAuth.adapter.create, {\n          input: {\n            model: \"account\",\n            data: {\n              userId: authUserId,\n              providerId: account.providerId,\n              accountId: account.providerAccountId,\n              accessToken: account.accessToken,\n              refreshToken: account.refreshToken,\n              accessTokenExpiresAt: account.expiresAt,\n              scope: account.scope,\n              idToken: account.idToken,\n              createdAt: account.createdAt,\n              updatedAt: account.updatedAt,\n            } as any,\n          },\n        });\n        created += 1;\n      } catch (error: any) {\n        const message = String(error?.message || error);\n        if (fastPath && message.includes(\"already exists\")) {\n          skipped += 1;\n          continue;\n        }\n        errors.push({\n          providerId: account.providerId,\n          legacyUserId: account.legacyUserId,\n          legacyAccountId: account.legacyAccountId,\n          step: \"createAccount\",\n          message,\n        });\n      }\n    }\n\n    return { created, skipped, missingUser, errors };\n  },\n});\n\nexport const clearBetterAuthData = action({\n  args: {\n    confirm: v.boolean(),\n  },\n  handler: async (ctx, args) => {\n    if (!args.confirm) {\n      throw new Error(\"clearBetterAuthData requires confirm=true\");\n    }\n\n    const models: Array<\n      \"session\" | \"account\" | \"verification\" | \"jwks\" | \"user\"\n    > = [\"session\", \"account\", \"verification\", \"jwks\", \"user\"];\n\n    for (const model of models) {\n      while (true) {\n        const result = (await ctx.runMutation(\n          components.betterAuth.adapter.deleteMany,\n          {\n            input: { model },\n            paginationOpts: {\n              cursor: null,\n              numItems: 1000,\n            },\n          },\n        )) as { count?: number };\n        if (!result?.count) {\n          break;\n        }\n      }\n    }\n\n    return { ok: true };\n  },\n});\n\nexport const debugBetterAuthAccount = action({\n  args: {\n    email: v.string(),\n  },\n  handler: async (ctx, args) => {\n    const email = args.email.toLowerCase();\n    const user = await ctx.runQuery(components.betterAuth.adapter.findOne, {\n      model: \"user\",\n      where: [{ field: \"email\", value: email }],\n    });\n    if (!user?._id) {\n      return { foundUser: false, foundAccount: false };\n    }\n    const account = await ctx.runQuery(components.betterAuth.adapter.findOne, {\n      model: \"account\",\n      where: [\n        { field: \"userId\", value: user._id },\n        { field: \"providerId\", value: \"credential\" },\n      ],\n    });\n    const hash =\n      (account as { password?: string | null } | null)?.password ?? null;\n    return {\n      foundUser: true,\n      foundAccount: Boolean(account),\n      hashPrefix: hash ? hash.slice(0, 4) : null,\n      hashLength: hash ? hash.length : null,\n    };\n  },\n});\n"
  },
  {
    "path": "convex/betterAuth/_generated/api.ts",
    "content": "/* eslint-disable */\n/**\n * Generated `api` utility.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate, run `npx convex dev`.\n * @module\n */\n\nimport type * as adapter from \"../adapter.js\";\nimport type * as auth from \"../auth.js\";\n\nimport type {\n  ApiFromModules,\n  FilterApi,\n  FunctionReference,\n} from \"convex/server\";\nimport { anyApi, componentsGeneric } from \"convex/server\";\n\nconst fullApi: ApiFromModules<{\n  adapter: typeof adapter;\n  auth: typeof auth;\n}> = anyApi as any;\n\n/**\n * A utility for referencing Convex functions in your app's public API.\n *\n * Usage:\n * ```js\n * const myFunctionReference = api.myModule.myFunction;\n * ```\n */\nexport const api: FilterApi<\n  typeof fullApi,\n  FunctionReference<any, \"public\">\n> = anyApi as any;\n\n/**\n * A utility for referencing Convex functions in your app's internal API.\n *\n * Usage:\n * ```js\n * const myFunctionReference = internal.myModule.myFunction;\n * ```\n */\nexport const internal: FilterApi<\n  typeof fullApi,\n  FunctionReference<any, \"internal\">\n> = anyApi as any;\n\nexport const components = componentsGeneric() as unknown as {};\n"
  },
  {
    "path": "convex/betterAuth/_generated/component.ts",
    "content": "/* eslint-disable */\n/**\n * Generated `ComponentApi` utility.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate, run `npx convex dev`.\n * @module\n */\n\nimport type { FunctionReference } from \"convex/server\";\n\n/**\n * A utility for referencing a Convex component's exposed API.\n *\n * Useful when expecting a parameter like `components.myComponent`.\n * Usage:\n * ```ts\n * async function myFunction(ctx: QueryCtx, component: ComponentApi) {\n *   return ctx.runQuery(component.someFile.someQuery, { ...args });\n * }\n * ```\n */\nexport type ComponentApi<Name extends string | undefined = string | undefined> =\n  {\n    adapter: {\n      create: FunctionReference<\n        \"mutation\",\n        \"internal\",\n        {\n          input:\n            | {\n                data: {\n                  banExpires?: null | number;\n                  banReason?: null | string;\n                  banned?: null | boolean;\n                  createdAt: number;\n                  displayUsername?: null | string;\n                  email: string;\n                  emailVerified: boolean;\n                  image?: null | string;\n                  name: string;\n                  role?: null | string;\n                  updatedAt: number;\n                  userId?: null | string;\n                  username?: null | string;\n                };\n                model: \"user\";\n              }\n            | {\n                data: {\n                  createdAt: number;\n                  expiresAt: number;\n                  impersonatedBy?: null | string;\n                  ipAddress?: null | string;\n                  token: string;\n                  updatedAt: number;\n                  userAgent?: null | string;\n                  userId: string;\n                };\n                model: \"session\";\n              }\n            | {\n                data: {\n                  accessToken?: null | string;\n                  accessTokenExpiresAt?: null | number;\n                  accountId: string;\n                  createdAt: number;\n                  idToken?: null | string;\n                  password?: null | string;\n                  providerId: string;\n                  refreshToken?: null | string;\n                  refreshTokenExpiresAt?: null | number;\n                  scope?: null | string;\n                  updatedAt: number;\n                  userId: string;\n                };\n                model: \"account\";\n              }\n            | {\n                data: {\n                  createdAt: number;\n                  expiresAt: number;\n                  identifier: string;\n                  updatedAt: number;\n                  value: string;\n                };\n                model: \"verification\";\n              }\n            | {\n                data: {\n                  createdAt: number;\n                  expiresAt?: null | number;\n                  privateKey: string;\n                  publicKey: string;\n                };\n                model: \"jwks\";\n              };\n          onCreateHandle?: string;\n          select?: Array<string>;\n        },\n        any,\n        Name\n      >;\n      deleteMany: FunctionReference<\n        \"mutation\",\n        \"internal\",\n        {\n          input:\n            | {\n                model: \"user\";\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"name\"\n                    | \"email\"\n                    | \"emailVerified\"\n                    | \"image\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"userId\"\n                    | \"username\"\n                    | \"displayUsername\"\n                    | \"role\"\n                    | \"banned\"\n                    | \"banReason\"\n                    | \"banExpires\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"session\";\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"expiresAt\"\n                    | \"token\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"ipAddress\"\n                    | \"userAgent\"\n                    | \"userId\"\n                    | \"impersonatedBy\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"account\";\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"accountId\"\n                    | \"providerId\"\n                    | \"userId\"\n                    | \"accessToken\"\n                    | \"refreshToken\"\n                    | \"idToken\"\n                    | \"accessTokenExpiresAt\"\n                    | \"refreshTokenExpiresAt\"\n                    | \"scope\"\n                    | \"password\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"verification\";\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"identifier\"\n                    | \"value\"\n                    | \"expiresAt\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"jwks\";\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"publicKey\"\n                    | \"privateKey\"\n                    | \"createdAt\"\n                    | \"expiresAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              };\n          onDeleteHandle?: string;\n          paginationOpts: {\n            cursor: string | null;\n            endCursor?: string | null;\n            id?: number;\n            maximumBytesRead?: number;\n            maximumRowsRead?: number;\n            numItems: number;\n          };\n        },\n        any,\n        Name\n      >;\n      deleteOne: FunctionReference<\n        \"mutation\",\n        \"internal\",\n        {\n          input:\n            | {\n                model: \"user\";\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"name\"\n                    | \"email\"\n                    | \"emailVerified\"\n                    | \"image\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"userId\"\n                    | \"username\"\n                    | \"displayUsername\"\n                    | \"role\"\n                    | \"banned\"\n                    | \"banReason\"\n                    | \"banExpires\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"session\";\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"expiresAt\"\n                    | \"token\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"ipAddress\"\n                    | \"userAgent\"\n                    | \"userId\"\n                    | \"impersonatedBy\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"account\";\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"accountId\"\n                    | \"providerId\"\n                    | \"userId\"\n                    | \"accessToken\"\n                    | \"refreshToken\"\n                    | \"idToken\"\n                    | \"accessTokenExpiresAt\"\n                    | \"refreshTokenExpiresAt\"\n                    | \"scope\"\n                    | \"password\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"verification\";\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"identifier\"\n                    | \"value\"\n                    | \"expiresAt\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"jwks\";\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"publicKey\"\n                    | \"privateKey\"\n                    | \"createdAt\"\n                    | \"expiresAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              };\n          onDeleteHandle?: string;\n        },\n        any,\n        Name\n      >;\n      findMany: FunctionReference<\n        \"query\",\n        \"internal\",\n        {\n          join?: any;\n          limit?: number;\n          model: \"user\" | \"session\" | \"account\" | \"verification\" | \"jwks\";\n          offset?: number;\n          paginationOpts: {\n            cursor: string | null;\n            endCursor?: string | null;\n            id?: number;\n            maximumBytesRead?: number;\n            maximumRowsRead?: number;\n            numItems: number;\n          };\n          select?: Array<string>;\n          sortBy?: { direction: \"asc\" | \"desc\"; field: string };\n          where?: Array<{\n            connector?: \"AND\" | \"OR\";\n            field: string;\n            mode?: \"sensitive\" | \"insensitive\";\n            operator?:\n              | \"lt\"\n              | \"lte\"\n              | \"gt\"\n              | \"gte\"\n              | \"eq\"\n              | \"in\"\n              | \"not_in\"\n              | \"ne\"\n              | \"contains\"\n              | \"starts_with\"\n              | \"ends_with\";\n            value:\n              | string\n              | number\n              | boolean\n              | Array<string>\n              | Array<number>\n              | null;\n          }>;\n        },\n        any,\n        Name\n      >;\n      findOne: FunctionReference<\n        \"query\",\n        \"internal\",\n        {\n          join?: any;\n          model: \"user\" | \"session\" | \"account\" | \"verification\" | \"jwks\";\n          select?: Array<string>;\n          where?: Array<{\n            connector?: \"AND\" | \"OR\";\n            field: string;\n            mode?: \"sensitive\" | \"insensitive\";\n            operator?:\n              | \"lt\"\n              | \"lte\"\n              | \"gt\"\n              | \"gte\"\n              | \"eq\"\n              | \"in\"\n              | \"not_in\"\n              | \"ne\"\n              | \"contains\"\n              | \"starts_with\"\n              | \"ends_with\";\n            value:\n              | string\n              | number\n              | boolean\n              | Array<string>\n              | Array<number>\n              | null;\n          }>;\n        },\n        any,\n        Name\n      >;\n      get: FunctionReference<\"query\", \"internal\", { id: string }, any, Name>;\n      searchUsersAll: FunctionReference<\n        \"query\",\n        \"internal\",\n        {\n          activatedFilter: \"all\" | \"yes\" | \"no\";\n          limit: number;\n          roleFilter: \"all\" | \"user\" | \"contributor\" | \"admin\";\n        },\n        any,\n        Name\n      >;\n      searchUsersByEmailPrefix: FunctionReference<\n        \"query\",\n        \"internal\",\n        {\n          activatedFilter: \"all\" | \"yes\" | \"no\";\n          limit: number;\n          prefix: string;\n          roleFilter: \"all\" | \"user\" | \"contributor\" | \"admin\";\n        },\n        any,\n        Name\n      >;\n      searchUsersById: FunctionReference<\n        \"query\",\n        \"internal\",\n        {\n          activatedFilter: \"all\" | \"yes\" | \"no\";\n          id: string;\n          roleFilter: \"all\" | \"user\" | \"contributor\" | \"admin\";\n        },\n        any,\n        Name\n      >;\n      searchUsersByUsernamePrefix: FunctionReference<\n        \"query\",\n        \"internal\",\n        {\n          activatedFilter: \"all\" | \"yes\" | \"no\";\n          limit: number;\n          prefix: string;\n          roleFilter: \"all\" | \"user\" | \"contributor\" | \"admin\";\n        },\n        any,\n        Name\n      >;\n      updateMany: FunctionReference<\n        \"mutation\",\n        \"internal\",\n        {\n          input:\n            | {\n                model: \"user\";\n                update: {\n                  banExpires?: null | number;\n                  banReason?: null | string;\n                  banned?: null | boolean;\n                  createdAt?: number;\n                  displayUsername?: null | string;\n                  email?: string;\n                  emailVerified?: boolean;\n                  image?: null | string;\n                  name?: string;\n                  role?: null | string;\n                  updatedAt?: number;\n                  userId?: null | string;\n                  username?: null | string;\n                };\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"name\"\n                    | \"email\"\n                    | \"emailVerified\"\n                    | \"image\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"userId\"\n                    | \"username\"\n                    | \"displayUsername\"\n                    | \"role\"\n                    | \"banned\"\n                    | \"banReason\"\n                    | \"banExpires\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"session\";\n                update: {\n                  createdAt?: number;\n                  expiresAt?: number;\n                  impersonatedBy?: null | string;\n                  ipAddress?: null | string;\n                  token?: string;\n                  updatedAt?: number;\n                  userAgent?: null | string;\n                  userId?: string;\n                };\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"expiresAt\"\n                    | \"token\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"ipAddress\"\n                    | \"userAgent\"\n                    | \"userId\"\n                    | \"impersonatedBy\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"account\";\n                update: {\n                  accessToken?: null | string;\n                  accessTokenExpiresAt?: null | number;\n                  accountId?: string;\n                  createdAt?: number;\n                  idToken?: null | string;\n                  password?: null | string;\n                  providerId?: string;\n                  refreshToken?: null | string;\n                  refreshTokenExpiresAt?: null | number;\n                  scope?: null | string;\n                  updatedAt?: number;\n                  userId?: string;\n                };\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"accountId\"\n                    | \"providerId\"\n                    | \"userId\"\n                    | \"accessToken\"\n                    | \"refreshToken\"\n                    | \"idToken\"\n                    | \"accessTokenExpiresAt\"\n                    | \"refreshTokenExpiresAt\"\n                    | \"scope\"\n                    | \"password\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"verification\";\n                update: {\n                  createdAt?: number;\n                  expiresAt?: number;\n                  identifier?: string;\n                  updatedAt?: number;\n                  value?: string;\n                };\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"identifier\"\n                    | \"value\"\n                    | \"expiresAt\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"jwks\";\n                update: {\n                  createdAt?: number;\n                  expiresAt?: null | number;\n                  privateKey?: string;\n                  publicKey?: string;\n                };\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"publicKey\"\n                    | \"privateKey\"\n                    | \"createdAt\"\n                    | \"expiresAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              };\n          onUpdateHandle?: string;\n          paginationOpts: {\n            cursor: string | null;\n            endCursor?: string | null;\n            id?: number;\n            maximumBytesRead?: number;\n            maximumRowsRead?: number;\n            numItems: number;\n          };\n        },\n        any,\n        Name\n      >;\n      updateOne: FunctionReference<\n        \"mutation\",\n        \"internal\",\n        {\n          input:\n            | {\n                model: \"user\";\n                update: {\n                  banExpires?: null | number;\n                  banReason?: null | string;\n                  banned?: null | boolean;\n                  createdAt?: number;\n                  displayUsername?: null | string;\n                  email?: string;\n                  emailVerified?: boolean;\n                  image?: null | string;\n                  name?: string;\n                  role?: null | string;\n                  updatedAt?: number;\n                  userId?: null | string;\n                  username?: null | string;\n                };\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"name\"\n                    | \"email\"\n                    | \"emailVerified\"\n                    | \"image\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"userId\"\n                    | \"username\"\n                    | \"displayUsername\"\n                    | \"role\"\n                    | \"banned\"\n                    | \"banReason\"\n                    | \"banExpires\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"session\";\n                update: {\n                  createdAt?: number;\n                  expiresAt?: number;\n                  impersonatedBy?: null | string;\n                  ipAddress?: null | string;\n                  token?: string;\n                  updatedAt?: number;\n                  userAgent?: null | string;\n                  userId?: string;\n                };\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"expiresAt\"\n                    | \"token\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"ipAddress\"\n                    | \"userAgent\"\n                    | \"userId\"\n                    | \"impersonatedBy\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"account\";\n                update: {\n                  accessToken?: null | string;\n                  accessTokenExpiresAt?: null | number;\n                  accountId?: string;\n                  createdAt?: number;\n                  idToken?: null | string;\n                  password?: null | string;\n                  providerId?: string;\n                  refreshToken?: null | string;\n                  refreshTokenExpiresAt?: null | number;\n                  scope?: null | string;\n                  updatedAt?: number;\n                  userId?: string;\n                };\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"accountId\"\n                    | \"providerId\"\n                    | \"userId\"\n                    | \"accessToken\"\n                    | \"refreshToken\"\n                    | \"idToken\"\n                    | \"accessTokenExpiresAt\"\n                    | \"refreshTokenExpiresAt\"\n                    | \"scope\"\n                    | \"password\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"verification\";\n                update: {\n                  createdAt?: number;\n                  expiresAt?: number;\n                  identifier?: string;\n                  updatedAt?: number;\n                  value?: string;\n                };\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"identifier\"\n                    | \"value\"\n                    | \"expiresAt\"\n                    | \"createdAt\"\n                    | \"updatedAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              }\n            | {\n                model: \"jwks\";\n                update: {\n                  createdAt?: number;\n                  expiresAt?: null | number;\n                  privateKey?: string;\n                  publicKey?: string;\n                };\n                where?: Array<{\n                  connector?: \"AND\" | \"OR\";\n                  field:\n                    | \"publicKey\"\n                    | \"privateKey\"\n                    | \"createdAt\"\n                    | \"expiresAt\"\n                    | \"_id\";\n                  mode?: \"sensitive\" | \"insensitive\";\n                  operator?:\n                    | \"lt\"\n                    | \"lte\"\n                    | \"gt\"\n                    | \"gte\"\n                    | \"eq\"\n                    | \"in\"\n                    | \"not_in\"\n                    | \"ne\"\n                    | \"contains\"\n                    | \"starts_with\"\n                    | \"ends_with\";\n                  value:\n                    | string\n                    | number\n                    | boolean\n                    | Array<string>\n                    | Array<number>\n                    | null;\n                }>;\n              };\n          onUpdateHandle?: string;\n        },\n        any,\n        Name\n      >;\n    };\n  };\n"
  },
  {
    "path": "convex/betterAuth/_generated/dataModel.ts",
    "content": "/* eslint-disable */\n/**\n * Generated data model types.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate, run `npx convex dev`.\n * @module\n */\n\nimport type {\n  DataModelFromSchemaDefinition,\n  DocumentByName,\n  TableNamesInDataModel,\n  SystemTableNames,\n} from \"convex/server\";\nimport type { GenericId } from \"convex/values\";\nimport schema from \"../schema.js\";\n\n/**\n * The names of all of your Convex tables.\n */\nexport type TableNames = TableNamesInDataModel<DataModel>;\n\n/**\n * The type of a document stored in Convex.\n *\n * @typeParam TableName - A string literal type of the table name (like \"users\").\n */\nexport type Doc<TableName extends TableNames> = DocumentByName<\n  DataModel,\n  TableName\n>;\n\n/**\n * An identifier for a document in Convex.\n *\n * Convex documents are uniquely identified by their `Id`, which is accessible\n * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).\n *\n * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.\n *\n * IDs are just strings at runtime, but this type can be used to distinguish them from other\n * strings when type checking.\n *\n * @typeParam TableName - A string literal type of the table name (like \"users\").\n */\nexport type Id<TableName extends TableNames | SystemTableNames> =\n  GenericId<TableName>;\n\n/**\n * A type describing your Convex data model.\n *\n * This type includes information about what tables you have, the type of\n * documents stored in those tables, and the indexes defined on them.\n *\n * This type is used to parameterize methods like `queryGeneric` and\n * `mutationGeneric` to make them type-safe.\n */\nexport type DataModel = DataModelFromSchemaDefinition<typeof schema>;\n"
  },
  {
    "path": "convex/betterAuth/_generated/server.ts",
    "content": "/* eslint-disable */\n/**\n * Generated utilities for implementing server-side Convex query and mutation functions.\n *\n * THIS CODE IS AUTOMATICALLY GENERATED.\n *\n * To regenerate, run `npx convex dev`.\n * @module\n */\n\nimport type {\n  ActionBuilder,\n  HttpActionBuilder,\n  MutationBuilder,\n  QueryBuilder,\n  GenericActionCtx,\n  GenericMutationCtx,\n  GenericQueryCtx,\n  GenericDatabaseReader,\n  GenericDatabaseWriter,\n} from \"convex/server\";\nimport {\n  actionGeneric,\n  httpActionGeneric,\n  queryGeneric,\n  mutationGeneric,\n  internalActionGeneric,\n  internalMutationGeneric,\n  internalQueryGeneric,\n} from \"convex/server\";\nimport type { DataModel } from \"./dataModel.js\";\n\n/**\n * Define a query in this Convex app's public API.\n *\n * This function will be allowed to read your Convex database and will be accessible from the client.\n *\n * @param func - The query function. It receives a {@link QueryCtx} as its first argument.\n * @returns The wrapped query. Include this as an `export` to name it and make it accessible.\n */\nexport const query: QueryBuilder<DataModel, \"public\"> = queryGeneric;\n\n/**\n * Define a query that is only accessible from other Convex functions (but not from the client).\n *\n * This function will be allowed to read from your Convex database. It will not be accessible from the client.\n *\n * @param func - The query function. It receives a {@link QueryCtx} as its first argument.\n * @returns The wrapped query. Include this as an `export` to name it and make it accessible.\n */\nexport const internalQuery: QueryBuilder<DataModel, \"internal\"> =\n  internalQueryGeneric;\n\n/**\n * Define a mutation in this Convex app's public API.\n *\n * This function will be allowed to modify your Convex database and will be accessible from the client.\n *\n * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.\n * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.\n */\nexport const mutation: MutationBuilder<DataModel, \"public\"> = mutationGeneric;\n\n/**\n * Define a mutation that is only accessible from other Convex functions (but not from the client).\n *\n * This function will be allowed to modify your Convex database. It will not be accessible from the client.\n *\n * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.\n * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.\n */\nexport const internalMutation: MutationBuilder<DataModel, \"internal\"> =\n  internalMutationGeneric;\n\n/**\n * Define an action in this Convex app's public API.\n *\n * An action is a function which can execute any JavaScript code, including non-deterministic\n * code and code with side-effects, like calling third-party services.\n * They can be run in Convex's JavaScript environment or in Node.js using the \"use node\" directive.\n * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.\n *\n * @param func - The action. It receives an {@link ActionCtx} as its first argument.\n * @returns The wrapped action. Include this as an `export` to name it and make it accessible.\n */\nexport const action: ActionBuilder<DataModel, \"public\"> = actionGeneric;\n\n/**\n * Define an action that is only accessible from other Convex functions (but not from the client).\n *\n * @param func - The function. It receives an {@link ActionCtx} as its first argument.\n * @returns The wrapped function. Include this as an `export` to name it and make it accessible.\n */\nexport const internalAction: ActionBuilder<DataModel, \"internal\"> =\n  internalActionGeneric;\n\n/**\n * Define an HTTP action.\n *\n * The wrapped function will be used to respond to HTTP requests received\n * by a Convex deployment if the requests matches the path and method where\n * this action is routed. Be sure to route your httpAction in `convex/http.js`.\n *\n * @param func - The function. It receives an {@link ActionCtx} as its first argument\n * and a Fetch API `Request` object as its second.\n * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.\n */\nexport const httpAction: HttpActionBuilder = httpActionGeneric;\n\n/**\n * A set of services for use within Convex query functions.\n *\n * The query context is passed as the first argument to any Convex query\n * function run on the server.\n *\n * If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead.\n */\nexport type QueryCtx = GenericQueryCtx<DataModel>;\n\n/**\n * A set of services for use within Convex mutation functions.\n *\n * The mutation context is passed as the first argument to any Convex mutation\n * function run on the server.\n *\n * If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead.\n */\nexport type MutationCtx = GenericMutationCtx<DataModel>;\n\n/**\n * A set of services for use within Convex action functions.\n *\n * The action context is passed as the first argument to any Convex action\n * function run on the server.\n */\nexport type ActionCtx = GenericActionCtx<DataModel>;\n\n/**\n * An interface to read from the database within Convex query functions.\n *\n * The two entry points are {@link DatabaseReader.get}, which fetches a single\n * document by its {@link Id}, or {@link DatabaseReader.query}, which starts\n * building a query.\n */\nexport type DatabaseReader = GenericDatabaseReader<DataModel>;\n\n/**\n * An interface to read from and write to the database within Convex mutation\n * functions.\n *\n * Convex guarantees that all writes within a single mutation are\n * executed atomically, so you never have to worry about partial writes leaving\n * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)\n * for the guarantees Convex provides your functions.\n */\nexport type DatabaseWriter = GenericDatabaseWriter<DataModel>;\n"
  },
  {
    "path": "convex/betterAuth/adapter.ts",
    "content": "import { createApi } from \"@convex-dev/better-auth\";\nimport { createAuthOptions } from \"./auth\";\nimport schema from \"./schema\";\nimport { query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nconst activatedFilterValidator = v.union(\n  v.literal(\"all\"),\n  v.literal(\"yes\"),\n  v.literal(\"no\"),\n);\nconst roleFilterValidator = v.union(\n  v.literal(\"all\"),\n  v.literal(\"user\"),\n  v.literal(\"contributor\"),\n  v.literal(\"admin\"),\n);\n\nexport const get = query({\n  args: {\n    id: v.id(\"user\"),\n  },\n  handler: async (ctx, args) => {\n    return await ctx.db.get(\"user\", args.id);\n  },\n});\n\nexport const searchUsersById = query({\n  args: {\n    activatedFilter: activatedFilterValidator,\n    roleFilter: roleFilterValidator,\n    id: v.string(),\n  },\n  handler: async (ctx, args) => {\n    let query = ctx.db\n      .query(\"user\")\n      .withIndex(\"userId\", (q) => q.eq(\"userId\", args.id));\n    if (args.roleFilter === \"admin\") {\n      query = query.filter((q) => q.eq(q.field(\"role\"), \"admin\"));\n    } else if (args.roleFilter === \"contributor\") {\n      query = query.filter((q) => q.eq(q.field(\"role\"), \"contributor\"));\n    } else if (args.roleFilter === \"user\") {\n      query = query.filter((q) =>\n        q.and(\n          q.neq(q.field(\"role\"), \"admin\"),\n          q.neq(q.field(\"role\"), \"contributor\"),\n        ),\n      );\n    }\n    if (args.activatedFilter === \"yes\") {\n      query = query.filter((q) => q.eq(q.field(\"emailVerified\"), true));\n    } else if (args.activatedFilter === \"no\") {\n      query = query.filter((q) => q.eq(q.field(\"emailVerified\"), false));\n    }\n    return query.take(1);\n  },\n});\n\nexport const searchUsersByEmailPrefix = query({\n  args: {\n    activatedFilter: activatedFilterValidator,\n    roleFilter: roleFilterValidator,\n    limit: v.number(),\n    prefix: v.string(),\n  },\n  handler: async (ctx, args) => {\n    const search = args.prefix.trim();\n    if (search.length === 0) return [];\n    let query = ctx.db.query(\"user\").withSearchIndex(\"search_email\", (q) => {\n      let built = q.search(\"email\", search);\n      if (args.roleFilter === \"admin\" || args.roleFilter === \"contributor\") {\n        built = built.eq(\"role\", args.roleFilter);\n      }\n      if (args.activatedFilter === \"yes\") {\n        built = built.eq(\"emailVerified\", true);\n      } else if (args.activatedFilter === \"no\") {\n        built = built.eq(\"emailVerified\", false);\n      }\n      return built;\n    });\n    if (args.roleFilter === \"user\") {\n      query = query.filter((q) =>\n        q.and(\n          q.neq(q.field(\"role\"), \"admin\"),\n          q.neq(q.field(\"role\"), \"contributor\"),\n        ),\n      );\n    }\n    return query.take(args.limit);\n  },\n});\n\nexport const searchUsersByUsernamePrefix = query({\n  args: {\n    activatedFilter: activatedFilterValidator,\n    roleFilter: roleFilterValidator,\n    limit: v.number(),\n    prefix: v.string(),\n  },\n  handler: async (ctx, args) => {\n    const search = args.prefix.trim();\n    if (search.length === 0) return [];\n    let query = ctx.db.query(\"user\").withSearchIndex(\"search_username\", (q) => {\n      let built = q.search(\"username\", search);\n      if (args.roleFilter === \"admin\" || args.roleFilter === \"contributor\") {\n        built = built.eq(\"role\", args.roleFilter);\n      }\n      if (args.activatedFilter === \"yes\") {\n        built = built.eq(\"emailVerified\", true);\n      } else if (args.activatedFilter === \"no\") {\n        built = built.eq(\"emailVerified\", false);\n      }\n      return built;\n    });\n    if (args.roleFilter === \"user\") {\n      query = query.filter((q) =>\n        q.and(\n          q.neq(q.field(\"role\"), \"admin\"),\n          q.neq(q.field(\"role\"), \"contributor\"),\n        ),\n      );\n    }\n    return query.take(args.limit);\n  },\n});\n\nexport const searchUsersAll = query({\n  args: {\n    activatedFilter: activatedFilterValidator,\n    roleFilter: roleFilterValidator,\n    limit: v.number(),\n  },\n  handler: async (ctx, args) => {\n    if (args.roleFilter === \"admin\" || args.roleFilter === \"contributor\") {\n      let query = ctx.db\n        .query(\"user\")\n        .withIndex(\"role\", (q) => q.eq(\"role\", args.roleFilter))\n        .order(\"desc\");\n      if (args.activatedFilter === \"yes\") {\n        query = query.filter((q) => q.eq(q.field(\"emailVerified\"), true));\n      } else if (args.activatedFilter === \"no\") {\n        query = query.filter((q) => q.eq(q.field(\"emailVerified\"), false));\n      }\n      return query.take(args.limit);\n    }\n    let query = ctx.db.query(\"user\").order(\"desc\");\n    if (args.roleFilter === \"user\") {\n      query = query.filter((q) =>\n        q.and(\n          q.neq(q.field(\"role\"), \"admin\"),\n          q.neq(q.field(\"role\"), \"contributor\"),\n        ),\n      );\n    }\n    if (args.activatedFilter === \"yes\") {\n      query = query.filter((q) => q.eq(q.field(\"emailVerified\"), true));\n    } else if (args.activatedFilter === \"no\") {\n      query = query.filter((q) => q.eq(q.field(\"emailVerified\"), false));\n    }\n    return query.take(args.limit);\n  },\n});\n\nexport const {\n  create,\n  findOne,\n  findMany,\n  updateOne,\n  updateMany,\n  deleteOne,\n  deleteMany,\n} = createApi(schema, createAuthOptions);\n"
  },
  {
    "path": "convex/betterAuth/auth.ts",
    "content": "import { createClient } from \"@convex-dev/better-auth\";\nimport { convex } from \"@convex-dev/better-auth/plugins\";\nimport type { GenericCtx } from \"@convex-dev/better-auth/utils\";\nimport type { BetterAuthOptions } from \"better-auth\";\nimport type { DataModel } from \"./_generated/dataModel\";\nimport { betterAuth } from \"better-auth\";\nimport { admin, username } from \"better-auth/plugins\";\nimport { defaultRoles, userAc } from \"better-auth/plugins/admin/access\";\nimport { components, internal } from \"../_generated/api\";\nimport authConfig from \"../auth.config\";\nimport { syncDiscordAvatarFromAccount } from \"../lib/discordAvatarSync\";\nimport { phpbbCheckHash, phpbbHash } from \"../lib/phpbb\";\nimport schema from \"./schema\";\n\nconst typedCreateClient = createClient<DataModel, typeof schema>;\n\nconst getEnv = (...keys: string[]) =>\n  keys.map((key) => process.env[key]).find((value) => value);\n\nconst getSocialProvider = (idKeys: string[], secretKeys: string[]) => {\n  const clientId = getEnv(...idKeys);\n  const clientSecret = getEnv(...secretKeys);\n  if (!clientId || !clientSecret) return undefined;\n  return { clientId, clientSecret };\n};\n\nconst socialProviders = Object.fromEntries(\n  Object.entries({\n    github: getSocialProvider(\n      [\"GITHUB_CLIENT_ID\", \"GITHUB_ID\", \"AUTH_GITHUB_ID\"],\n      [\"GITHUB_CLIENT_SECRET\", \"GITHUB_SECRET\", \"AUTH_GITHUB_SECRET\"],\n    ),\n    google: getSocialProvider(\n      [\"GOOGLE_CLIENT_ID\", \"AUTH_GOOGLE_ID\"],\n      [\"GOOGLE_CLIENT_SECRET\", \"AUTH_GOOGLE_SECRET\"],\n    ),\n    discord: getSocialProvider(\n      [\"DISCORD_CLIENT_ID\", \"AUTH_DISCORD_CLIENT_ID\"],\n      [\"DISCORD_CLIENT_SECRET\", \"AUTH_DISCORD_CLIENT_SECRET\"],\n    ),\n    facebook: getSocialProvider(\n      [\"FACEBOOK_CLIENT_ID\", \"AUTH_FACEBOOK_ID\"],\n      [\"FACEBOOK_CLIENT_SECRET\", \"AUTH_FACEBOOK_SECRET\"],\n    ),\n  }).filter(([, value]) => value),\n);\n\nconst sendEmail = async ({\n  to,\n  subject,\n  html,\n}: {\n  to: string;\n  subject: string;\n  html: string;\n}) => {\n  const apiKey = process.env.RESEND_API_KEY;\n  if (!apiKey) {\n    throw new Error(\"RESEND_API_KEY is not set\");\n  }\n  const response = await fetch(\"https://api.resend.com/emails\", {\n    method: \"POST\",\n    headers: {\n      Authorization: `Bearer ${apiKey}`,\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify({\n      from: \"Unofficial Duolingo Stories <register@duostories.org>\",\n      to,\n      subject,\n      html,\n    }),\n  });\n  if (!response.ok) {\n    const text = await response.text();\n    throw new Error(`Resend email failed: ${response.status} ${text}`);\n  }\n};\n\nfunction normalizeExpiresAt(value: number | Date | null | undefined) {\n  if (typeof value === \"number\") return value;\n  if (value instanceof Date) return value.getTime();\n  return null;\n}\n\nasync function syncDiscordAccountAvatarFromHook(\n  account: {\n    id?: string;\n    userId?: string | null;\n    providerId?: string | null;\n    accountId?: string | null;\n    accessToken?: string | null;\n    refreshToken?: string | null;\n    accessTokenExpiresAt?: number | Date | null;\n    scope?: string | null;\n  } | null,\n  hookContext?: {\n    context: {\n      internalAdapter: {\n        updateUser: (\n          userId: string,\n          update: Record<string, string | number | null>,\n        ) => Promise<unknown>;\n        updateAccount: (\n          accountId: string,\n          update: Record<string, string | number | null>,\n        ) => Promise<unknown>;\n      };\n      logger: {\n        error: (message: string, error?: unknown) => void;\n      };\n    };\n  } | null,\n) {\n  if (!account?.userId || !account.providerId || !hookContext) {\n    return;\n  }\n\n  try {\n    const result = await syncDiscordAvatarFromAccount(account);\n    if (!result.ok) return;\n\n    if (result.imageUrl) {\n      await hookContext.context.internalAdapter.updateUser(account.userId, {\n        image: result.imageUrl,\n      });\n    }\n\n    if (!account.id) return;\n\n    const accountUpdate: Record<string, string | number | null> = {};\n    if (result.accessToken !== account.accessToken) {\n      accountUpdate.accessToken = result.accessToken;\n    }\n    if (result.refreshToken !== account.refreshToken) {\n      accountUpdate.refreshToken = result.refreshToken;\n    }\n    if (\n      result.accessTokenExpiresAt !==\n      normalizeExpiresAt(account.accessTokenExpiresAt)\n    ) {\n      accountUpdate.accessTokenExpiresAt = result.accessTokenExpiresAt;\n    }\n    if (result.scope !== account.scope) {\n      accountUpdate.scope = result.scope;\n    }\n\n    if (Object.keys(accountUpdate).length > 0) {\n      await hookContext.context.internalAdapter.updateAccount(\n        account.id,\n        accountUpdate,\n      );\n    }\n  } catch (error) {\n    hookContext.context.logger.error(\"Failed to sync Discord avatar\", error);\n  }\n}\n\n// Better Auth Component\nexport const authComponent: ReturnType<typeof typedCreateClient> =\n  typedCreateClient(components.betterAuth, {\n    local: { schema },\n    verbose: false,\n    authFunctions: {\n      onCreate: internal.authFunctions.onCreate,\n    },\n    triggers: {\n      user: {\n        onCreate: async () => {},\n      },\n    },\n  });\n\nconst authBaseUrl =\n  process.env.SITE_URL ??\n  process.env.BETTER_AUTH_URL ??\n  process.env.NEXTAUTH_URL;\n\n// Better Auth Options\nexport const createAuthOptions = (ctx: GenericCtx<DataModel>) => {\n  return {\n    appName: \"Duostories\",\n    baseURL: authBaseUrl,\n    trustedOrigins: async (req) => {\n      const host =\n        req?.headers.get(\"x-forwarded-host\") ?? req?.headers.get(\"host\");\n      const proto = req?.headers.get(\"x-forwarded-proto\") ?? \"https\";\n      const origin = host ? `${proto}://${host}` : null;\n\n      const allowed = [\n        \"http://localhost:3000\",\n        authBaseUrl,\n        \"https://*-duostories-team.vercel.app\",\n      ].filter(Boolean) as string[];\n\n      if (host?.endsWith(\"-duostories-team.vercel.app\") && origin) {\n        allowed.push(origin);\n      }\n\n      return allowed;\n    },\n    secret: process.env.BETTER_AUTH_SECRET,\n    socialProviders,\n    database: authComponent.adapter(ctx),\n    databaseHooks: {\n      account: {\n        create: {\n          after: async (account, hookContext) => {\n            await syncDiscordAccountAvatarFromHook(account, hookContext);\n          },\n        },\n        update: {\n          after: async (account, hookContext) => {\n            await syncDiscordAccountAvatarFromHook(account, hookContext);\n          },\n        },\n      },\n    },\n    emailAndPassword: {\n      enabled: true,\n      sendResetPassword: async ({ user, url }) => {\n        await sendEmail({\n          to: user.email,\n          subject: \"[Unofficial Duolingo Stories] Reset Password\",\n          html: `Hey ${user.name ?? \"there\"},<br/>\n            <br/>\n            You have requested to reset your password for 'Unofficial Duolingo Stories'.<br/>\n            Use the following link to reset your password.<br/>\n            <a href='${url}'>Reset Password</a>\n            <br/><br/>\n            Happy learning.`,\n        });\n      },\n      password: {\n        hash: async (password) => phpbbHash(password),\n        verify: async ({ password, hash }) => phpbbCheckHash(password, hash),\n      },\n    },\n    emailVerification: {\n      sendOnSignUp: true,\n      sendVerificationEmail: async ({ user, url }) => {\n        await sendEmail({\n          to: user.email,\n          subject: \"[Unofficial Duolingo Stories] Verify Email\",\n          html: `Hey ${user.name ?? \"there\"},<br/>\n            <br/>\n            Please verify your email address by clicking the link below.<br/>\n            <a href='${url}'>Verify Email</a>\n            <br/><br/>\n            Happy learning.`,\n        });\n      },\n    },\n    user: {\n      changeEmail: {\n        enabled: true,\n        updateEmailWithoutVerification: false,\n        sendChangeEmailConfirmation: async ({ user, newEmail, url }) => {\n          await sendEmail({\n            to: user.email,\n            subject: \"[Unofficial Duolingo Stories] Confirm Email Change\",\n            html: `Hey ${user.name ?? \"there\"},<br/>\n              <br/>\n              You requested to change your account email to <b>${newEmail}</b>.<br/>\n              Confirm this change using the link below.<br/>\n              <a href='${url}'>Confirm Email Change</a>\n              <br/><br/>\n              If this was not you, you can ignore this email.`,\n          });\n        },\n      },\n    },\n    plugins: [\n      convex({ authConfig }),\n      username(),\n      admin({\n        adminRoles: [\"admin\"],\n        roles: {\n          ...defaultRoles,\n          contributor: userAc,\n        },\n      }),\n    ],\n  } satisfies BetterAuthOptions;\n};\n\n// For `@better-auth/cli`\nconst options = createAuthOptions({} as GenericCtx<DataModel>);\n\n// Better Auth Instance\nexport const createAuth = (ctx: GenericCtx<DataModel>) => {\n  return betterAuth(createAuthOptions(ctx));\n};\n"
  },
  {
    "path": "convex/betterAuth/convex.config.ts",
    "content": "import { defineComponent } from \"convex/server\";\n\nconst component = defineComponent(\"betterAuth\");\n\nexport default component;\n"
  },
  {
    "path": "convex/betterAuth/schema.ts",
    "content": "/**\n * This file is auto-generated. Do not edit this file manually.\n * To regenerate the schema, run:\n * `npx @better-auth/cli generate --output ./convex/betterAuth/schema.ts -y`\n *\n * To customize the schema, generate to an alternate file and import\n * the table definitions to your schema file. See\n * https://labs.convex.dev/better-auth/features/local-install#adding-custom-indexes.\n */\n\nimport { defineSchema, defineTable } from \"convex/server\";\nimport { v } from \"convex/values\";\n\nconst tables = {\n  user: defineTable({\n    name: v.string(),\n    email: v.string(),\n    emailVerified: v.boolean(),\n    image: v.optional(v.union(v.null(), v.string())),\n    createdAt: v.number(),\n    updatedAt: v.number(),\n    userId: v.optional(v.union(v.null(), v.string())),\n    username: v.optional(v.union(v.null(), v.string())),\n    displayUsername: v.optional(v.union(v.null(), v.string())),\n    role: v.optional(v.union(v.null(), v.string())),\n    banned: v.optional(v.union(v.null(), v.boolean())),\n    banReason: v.optional(v.union(v.null(), v.string())),\n    banExpires: v.optional(v.union(v.null(), v.number())),\n  })\n    .index(\"email\", [\"email\"])\n    .index(\"email_name\", [\"email\", \"name\"])\n    .index(\"name\", [\"name\"])\n    .index(\"role\", [\"role\"])\n    .index(\"role_createdAt\", [\"role\", \"createdAt\"])\n    .index(\"userId\", [\"userId\"])\n    .index(\"username\", [\"username\"])\n    .searchIndex(\"search_email\", {\n      searchField: \"email\",\n      filterFields: [\"role\", \"emailVerified\"],\n    })\n    .searchIndex(\"search_username\", {\n      searchField: \"username\",\n      filterFields: [\"role\", \"emailVerified\"],\n    }),\n  session: defineTable({\n    expiresAt: v.number(),\n    token: v.string(),\n    createdAt: v.number(),\n    updatedAt: v.number(),\n    ipAddress: v.optional(v.union(v.null(), v.string())),\n    userAgent: v.optional(v.union(v.null(), v.string())),\n    userId: v.string(),\n    impersonatedBy: v.optional(v.union(v.null(), v.string())),\n  })\n    .index(\"expiresAt\", [\"expiresAt\"])\n    .index(\"expiresAt_userId\", [\"expiresAt\", \"userId\"])\n    .index(\"token\", [\"token\"])\n    .index(\"userId\", [\"userId\"]),\n  account: defineTable({\n    accountId: v.string(),\n    providerId: v.string(),\n    userId: v.string(),\n    accessToken: v.optional(v.union(v.null(), v.string())),\n    refreshToken: v.optional(v.union(v.null(), v.string())),\n    idToken: v.optional(v.union(v.null(), v.string())),\n    accessTokenExpiresAt: v.optional(v.union(v.null(), v.number())),\n    refreshTokenExpiresAt: v.optional(v.union(v.null(), v.number())),\n    scope: v.optional(v.union(v.null(), v.string())),\n    password: v.optional(v.union(v.null(), v.string())),\n    createdAt: v.number(),\n    updatedAt: v.number(),\n  })\n    .index(\"accountId\", [\"accountId\"])\n    .index(\"accountId_providerId\", [\"accountId\", \"providerId\"])\n    .index(\"providerId_userId\", [\"providerId\", \"userId\"])\n    .index(\"userId\", [\"userId\"]),\n  verification: defineTable({\n    identifier: v.string(),\n    value: v.string(),\n    expiresAt: v.number(),\n    createdAt: v.number(),\n    updatedAt: v.number(),\n  })\n    .index(\"expiresAt\", [\"expiresAt\"])\n    .index(\"identifier\", [\"identifier\"]),\n  jwks: defineTable({\n    publicKey: v.string(),\n    privateKey: v.string(),\n    createdAt: v.number(),\n    expiresAt: v.optional(v.union(v.null(), v.number())),\n  }),\n};\n\nconst schema = defineSchema(tables);\n\nexport default schema;\n"
  },
  {
    "path": "convex/convex-env.d.ts",
    "content": "declare namespace NodeJS {\n  interface ProcessEnv {\n    [key: string]: string | undefined;\n  }\n\n  interface Process {\n    env: ProcessEnv;\n    exit(code?: number): never;\n  }\n}\n\ndeclare var process: NodeJS.Process;\n"
  },
  {
    "path": "convex/convex.config.ts",
    "content": "import { defineApp } from \"convex/server\";\nimport betterAuth from \"./betterAuth/convex.config\";\n\nconst app = defineApp();\n\napp.use(betterAuth);\n\nexport default app;\n"
  },
  {
    "path": "convex/convex_rules.md",
    "content": "# Convex guidelines\n\n## Function guidelines\n\n### New function syntax\n\n- ALWAYS use the new function syntax for Convex functions. For example:\n\n```typescript\nimport { query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nexport const f = query({\n  args: {},\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    // Function body\n  },\n});\n```\n\n### Http endpoint syntax\n\n- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:\n\n```typescript\nimport { httpRouter } from \"convex/server\";\nimport { httpAction } from \"./_generated/server\";\nconst http = httpRouter();\nhttp.route({\n  path: \"/echo\",\n  method: \"POST\",\n  handler: httpAction(async (ctx, req) => {\n    const body = await req.bytes();\n    return new Response(body, { status: 200 });\n  }),\n});\n```\n\n- 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`.\n\n### Validators\n\n- Below is an example of an array validator:\n\n```typescript\nimport { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nexport default mutation({\n  args: {\n    simpleArray: v.array(v.union(v.string(), v.number())),\n  },\n  handler: async (ctx, args) => {\n    //...\n  },\n});\n```\n\n- Below is an example of a schema with validators that codify a discriminated union type:\n\n```typescript\nimport { defineSchema, defineTable } from \"convex/server\";\nimport { v } from \"convex/values\";\n\nexport default defineSchema({\n  results: defineTable(\n    v.union(\n      v.object({\n        kind: v.literal(\"error\"),\n        errorMessage: v.string(),\n      }),\n      v.object({\n        kind: v.literal(\"success\"),\n        value: v.number(),\n      }),\n    ),\n  ),\n});\n```\n\n- Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value:\n\n```typescript\nimport { query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nexport const exampleQuery = query({\n  args: {},\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    console.log(\"This query returns a null value\");\n    return null;\n  },\n});\n```\n\n- Here are the valid Convex types along with their respective validators:\n  Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |\n  | ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n  | Id | string | `doc._id` | `v.id(tableName)` | |\n  | 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. |\n  | Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |\n  | 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. |\n  | Boolean | boolean | `true` | `v.boolean()` |\n  | 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. |\n  | 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. |\n  | Array | Array | `[1, 3.2, \"abc\"]` | `v.array(values)` | Arrays can have at most 8192 values. |\n  | 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 \"_\". |\n| 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 \"\\_\". |\n\n### Function registration\n\n- 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`.\n- 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.\n- You CANNOT register a function through the `api` or `internal` objects.\n- 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.\n- If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`.\n\n### Function calling\n\n- Use `ctx.runQuery` to call a query from a query, mutation, or action.\n- Use `ctx.runMutation` to call a mutation from a mutation or action.\n- Use `ctx.runAction` to call an action from an action.\n- 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.\n- 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.\n- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.\n- 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,\n\n```\nexport const f = query({\n  args: { name: v.string() },\n  returns: v.string(),\n  handler: async (ctx, args) => {\n    return \"Hello \" + args.name;\n  },\n});\n\nexport const g = query({\n  args: {},\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    const result: string = await ctx.runQuery(api.example.f, { name: \"Bob\" });\n    return null;\n  },\n});\n```\n\n### Function references\n\n- Function references are pointers to registered Convex functions.\n- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.\n- 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`.\n- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.\n- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.\n- 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`.\n\n### Api design\n\n- Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory.\n- Use `query`, `mutation`, and `action` to define public functions.\n- Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions.\n\n### Pagination\n\n- Paginated queries are queries that return a list of results in incremental pages.\n- You can define pagination using the following syntax:\n\n```ts\nimport { v } from \"convex/values\";\nimport { query, mutation } from \"./_generated/server\";\nimport { paginationOptsValidator } from \"convex/server\";\nexport const listWithExtraArg = query({\n  args: { paginationOpts: paginationOptsValidator, author: v.string() },\n  handler: async (ctx, args) => {\n    return await ctx.db\n      .query(\"messages\")\n      .withIndex(\"by_author\", (q) => q.eq(\"author\", args.author))\n      .order(\"desc\")\n      .paginate(args.paginationOpts);\n  },\n});\n```\n\nNote: `paginationOpts` is an object with the following properties:\n\n- `numItems`: the maximum number of documents to return (the validator is `v.number()`)\n- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)\n- A query that ends in `.paginate()` returns an object that has the following properties:\n- page (contains an array of documents that you fetches)\n- isDone (a boolean that represents whether or not this is the last page of documents)\n- continueCursor (a string that represents the cursor to use to fetch the next page of documents)\n\n## Validator guidelines\n\n- `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` instead.\n- Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not supported.\n\n## Schema guidelines\n\n- Always define your schema in `convex/schema.ts`.\n- Always import the schema definition functions from `convex/server`.\n- 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)`.\n- 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\".\n- 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.\n\n## Typescript guidelines\n\n- 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.\n- 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:\n\n```ts\nimport { query } from \"./_generated/server\";\nimport { Doc, Id } from \"./_generated/dataModel\";\n\nexport const exampleQuery = query({\n  args: { userIds: v.array(v.id(\"users\")) },\n  returns: v.record(v.id(\"users\"), v.string()),\n  handler: async (ctx, args) => {\n    const idToUsername: Record<Id<\"users\">, string> = {};\n    for (const userId of args.userIds) {\n      const user = await ctx.db.get(\"users\", userId);\n      if (user) {\n        idToUsername[user._id] = user.username;\n      }\n    }\n\n    return idToUsername;\n  },\n});\n```\n\n- 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`.\n- Always use `as const` for string literals in discriminated union types.\n- When using the `Array` type, make sure to always define your arrays as `const array: Array<T> = [...];`\n- When using the `Record` type, make sure to always define your records as `const record: Record<KeyType, ValueType> = {...};`\n- Always add `@types/node` to your `package.json` when using any Node.js built-in modules.\n\n## Full text search guidelines\n\n- A query for \"10 messages in channel '#general' that best match the query 'hello hi' in their body\" would look like:\n\nconst messages = await ctx.db\n.query(\"messages\")\n.withSearchIndex(\"search_body\", (q) =>\nq.search(\"body\", \"hello hi\").eq(\"channel\", \"#general\"),\n)\n.take(10);\n\n## Query guidelines\n\n- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead.\n- Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate over them, and call `ctx.db.delete(row._id)` on each result.\n- 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.\n- 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.\n\n### Ordering\n\n- By default Convex always returns documents in ascending `_creationTime` order.\n- 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.\n- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.\n\n## Mutation guidelines\n\n- 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 })`\n- 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 })`\n\n## Action guidelines\n\n- Always add `\"use node\";` to the top of files containing actions that use Node.js built-in modules.\n- Never use `ctx.db` inside of an action. Actions don't have access to the database.\n- Below is an example of the syntax for an action:\n\n```ts\nimport { action } from \"./_generated/server\";\n\nexport const exampleAction = action({\n  args: {},\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    console.log(\"This action does not return anything\");\n    return null;\n  },\n});\n```\n\n## Scheduling guidelines\n\n### Cron guidelines\n\n- 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.\n- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods.\n- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example,\n\n```ts\nimport { cronJobs } from \"convex/server\";\nimport { internal } from \"./_generated/api\";\nimport { internalAction } from \"./_generated/server\";\n\nconst empty = internalAction({\n  args: {},\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    console.log(\"empty\");\n  },\n});\n\nconst crons = cronJobs();\n\n// Run `internal.crons.empty` every two hours.\ncrons.interval(\"delete inactive users\", { hours: 2 }, internal.crons.empty, {});\n\nexport default crons;\n```\n\n- You can register Convex functions within `crons.ts` just like any other file.\n- 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.\n\n## File storage guidelines\n\n- Convex includes file storage for large files like images, videos, and PDFs.\n- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist.\n- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata.\n\nInstead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<\"_storage\">`.\n\n```\nimport { query } from \"./_generated/server\";\nimport { Id } from \"./_generated/dataModel\";\n\ntype FileMetadata = {\n    _id: Id<\"_storage\">;\n    _creationTime: number;\n    contentType?: string;\n    sha256: string;\n    size: number;\n}\n\nexport const exampleQuery = query({\n    args: { fileId: v.id(\"_storage\") },\n    returns: v.null(),\n    handler: async (ctx, args) => {\n        const metadata: FileMetadata | null = await ctx.db.system.get(\"_storage\", args.fileId);\n        console.log(metadata);\n        return null;\n    },\n});\n```\n\n- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage.\n\n# Examples:\n\n## Example: chat-app\n\n### Task\n\n```\nCreate a real-time chat application backend with AI responses. The app should:\n- Allow creating users with names\n- Support multiple chat channels\n- Enable users to send messages to channels\n- Automatically generate AI responses to user messages\n- Show recent message history\n\nThe backend should provide APIs for:\n1. User management (creation)\n2. Channel management (creation)\n3. Message operations (sending, listing)\n4. AI response generation using OpenAI's GPT-4\n\nMessages should be stored with their channel, author, and content. The system should maintain message order\nand limit history display to the 10 most recent messages per channel.\n\n```\n\n### Analysis\n\n1. Task Requirements Summary:\n\n- Build a real-time chat backend with AI integration\n- Support user creation\n- Enable channel-based conversations\n- Store and retrieve messages with proper ordering\n- Generate AI responses automatically\n\n2. Main Components Needed:\n\n- Database tables: users, channels, messages\n- Public APIs for user/channel management\n- Message handling functions\n- Internal AI response generation system\n- Context loading for AI responses\n\n3. Public API and Internal Functions Design:\n   Public Mutations:\n\n- createUser:\n  - file path: convex/index.ts\n  - arguments: {name: v.string()}\n  - returns: v.object({userId: v.id(\"users\")})\n  - purpose: Create a new user with a given name\n- createChannel:\n  - file path: convex/index.ts\n  - arguments: {name: v.string()}\n  - returns: v.object({channelId: v.id(\"channels\")})\n  - purpose: Create a new channel with a given name\n- sendMessage:\n  - file path: convex/index.ts\n  - arguments: {channelId: v.id(\"channels\"), authorId: v.id(\"users\"), content: v.string()}\n  - returns: v.null()\n  - purpose: Send a message to a channel and schedule a response from the AI\n\nPublic Queries:\n\n- listMessages:\n  - file path: convex/index.ts\n  - arguments: {channelId: v.id(\"channels\")}\n  - returns: v.array(v.object({\n    \\_id: v.id(\"messages\"),\n    \\_creationTime: v.number(),\n    channelId: v.id(\"channels\"),\n    authorId: v.optional(v.id(\"users\")),\n    content: v.string(),\n    }))\n  - purpose: List the 10 most recent messages from a channel in descending creation order\n\nInternal Functions:\n\n- generateResponse:\n  - file path: convex/index.ts\n  - arguments: {channelId: v.id(\"channels\")}\n  - returns: v.null()\n  - purpose: Generate a response from the AI for a given channel\n- loadContext:\n  - file path: convex/index.ts\n  - arguments: {channelId: v.id(\"channels\")}\n  - returns: v.array(v.object({\n    \\_id: v.id(\"messages\"),\n    \\_creationTime: v.number(),\n    channelId: v.id(\"channels\"),\n    authorId: v.optional(v.id(\"users\")),\n    content: v.string(),\n    }))\n- writeAgentResponse:\n  - file path: convex/index.ts\n  - arguments: {channelId: v.id(\"channels\"), content: v.string()}\n  - returns: v.null()\n  - purpose: Write an AI response to a given channel\n\n4. Schema Design:\n\n- users\n  - validator: { name: v.string() }\n  - indexes: <none>\n- channels\n  - validator: { name: v.string() }\n  - indexes: <none>\n- messages\n  - validator: { channelId: v.id(\"channels\"), authorId: v.optional(v.id(\"users\")), content: v.string() }\n  - indexes\n    - by_channel: [\"channelId\"]\n\n5. Background Processing:\n\n- AI response generation runs asynchronously after each user message\n- Uses OpenAI's GPT-4 to generate contextual responses\n- Maintains conversation context using recent message history\n\n### Implementation\n\n#### package.json\n\n```typescript\n{\n  \"name\": \"chat-app\",\n  \"description\": \"This example shows how to build a chat app without authentication.\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"convex\": \"^1.31.2\",\n    \"openai\": \"^4.79.0\"\n  },\n  \"devDependencies\": {\n    \"typescript\": \"^5.7.3\"\n  }\n}\n```\n\n#### tsconfig.json\n\n```typescript\n{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"lib\": [\"DOM\", \"DOM.Iterable\", \"ESNext\"],\n    \"skipLibCheck\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"allowImportingTsExtensions\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\"\n  },\n  \"exclude\": [\"convex\"],\n  \"include\": [\"**/src/**/*.tsx\", \"**/src/**/*.ts\", \"vite.config.ts\"]\n}\n```\n\n#### convex/index.ts\n\n```typescript\nimport {\n  query,\n  mutation,\n  internalQuery,\n  internalMutation,\n  internalAction,\n} from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport OpenAI from \"openai\";\nimport { internal } from \"./_generated/api\";\n\n/**\n * Create a user with a given name.\n */\nexport const createUser = mutation({\n  args: {\n    name: v.string(),\n  },\n  returns: v.id(\"users\"),\n  handler: async (ctx, args) => {\n    return await ctx.db.insert(\"users\", { name: args.name });\n  },\n});\n\n/**\n * Create a channel with a given name.\n */\nexport const createChannel = mutation({\n  args: {\n    name: v.string(),\n  },\n  returns: v.id(\"channels\"),\n  handler: async (ctx, args) => {\n    return await ctx.db.insert(\"channels\", { name: args.name });\n  },\n});\n\n/**\n * List the 10 most recent messages from a channel in descending creation order.\n */\nexport const listMessages = query({\n  args: {\n    channelId: v.id(\"channels\"),\n  },\n  returns: v.array(\n    v.object({\n      _id: v.id(\"messages\"),\n      _creationTime: v.number(),\n      channelId: v.id(\"channels\"),\n      authorId: v.optional(v.id(\"users\")),\n      content: v.string(),\n    }),\n  ),\n  handler: async (ctx, args) => {\n    const messages = await ctx.db\n      .query(\"messages\")\n      .withIndex(\"by_channel\", (q) => q.eq(\"channelId\", args.channelId))\n      .order(\"desc\")\n      .take(10);\n    return messages;\n  },\n});\n\n/**\n * Send a message to a channel and schedule a response from the AI.\n */\nexport const sendMessage = mutation({\n  args: {\n    channelId: v.id(\"channels\"),\n    authorId: v.id(\"users\"),\n    content: v.string(),\n  },\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    const channel = await ctx.db.get(args.channelId);\n    if (!channel) {\n      throw new Error(\"Channel not found\");\n    }\n    const user = await ctx.db.get(args.authorId);\n    if (!user) {\n      throw new Error(\"User not found\");\n    }\n    await ctx.db.insert(\"messages\", {\n      channelId: args.channelId,\n      authorId: args.authorId,\n      content: args.content,\n    });\n    await ctx.scheduler.runAfter(0, internal.index.generateResponse, {\n      channelId: args.channelId,\n    });\n    return null;\n  },\n});\n\nconst openai = new OpenAI();\n\nexport const generateResponse = internalAction({\n  args: {\n    channelId: v.id(\"channels\"),\n  },\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    const context = await ctx.runQuery(internal.index.loadContext, {\n      channelId: args.channelId,\n    });\n    const response = await openai.chat.completions.create({\n      model: \"gpt-4o\",\n      messages: context,\n    });\n    const content = response.choices[0].message.content;\n    if (!content) {\n      throw new Error(\"No content in response\");\n    }\n    await ctx.runMutation(internal.index.writeAgentResponse, {\n      channelId: args.channelId,\n      content,\n    });\n    return null;\n  },\n});\n\nexport const loadContext = internalQuery({\n  args: {\n    channelId: v.id(\"channels\"),\n  },\n  returns: v.array(\n    v.object({\n      role: v.union(v.literal(\"user\"), v.literal(\"assistant\")),\n      content: v.string(),\n    }),\n  ),\n  handler: async (ctx, args) => {\n    const channel = await ctx.db.get(args.channelId);\n    if (!channel) {\n      throw new Error(\"Channel not found\");\n    }\n    const messages = await ctx.db\n      .query(\"messages\")\n      .withIndex(\"by_channel\", (q) => q.eq(\"channelId\", args.channelId))\n      .order(\"desc\")\n      .take(10);\n\n    const result = [];\n    for (const message of messages) {\n      if (message.authorId) {\n        const user = await ctx.db.get(message.authorId);\n        if (!user) {\n          throw new Error(\"User not found\");\n        }\n        result.push({\n          role: \"user\" as const,\n          content: `${user.name}: ${message.content}`,\n        });\n      } else {\n        result.push({ role: \"assistant\" as const, content: message.content });\n      }\n    }\n    return result;\n  },\n});\n\nexport const writeAgentResponse = internalMutation({\n  args: {\n    channelId: v.id(\"channels\"),\n    content: v.string(),\n  },\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    await ctx.db.insert(\"messages\", {\n      channelId: args.channelId,\n      content: args.content,\n    });\n    return null;\n  },\n});\n```\n\n#### convex/schema.ts\n\n```typescript\nimport { defineSchema, defineTable } from \"convex/server\";\nimport { v } from \"convex/values\";\n\nexport default defineSchema({\n  channels: defineTable({\n    name: v.string(),\n  }),\n\n  users: defineTable({\n    name: v.string(),\n  }),\n\n  messages: defineTable({\n    channelId: v.id(\"channels\"),\n    authorId: v.optional(v.id(\"users\")),\n    content: v.string(),\n  }).index(\"by_channel\", [\"channelId\"]),\n});\n```\n\n#### convex/tsconfig.json\n\n```typescript\n{\n  /* This TypeScript project config describes the environment that\n   * Convex functions run in and is used to typecheck them.\n   * You can modify it, but some settings required to use Convex.\n   */\n  \"compilerOptions\": {\n    /* These settings are not required by Convex and can be modified. */\n    \"allowJs\": true,\n    \"strict\": true,\n    \"moduleResolution\": \"Bundler\",\n    \"jsx\": \"react-jsx\",\n    \"skipLibCheck\": true,\n    \"allowSyntheticDefaultImports\": true,\n\n    /* These compiler options are required by Convex */\n    \"target\": \"ESNext\",\n    \"lib\": [\"ES2021\", \"dom\"],\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"ESNext\",\n    \"isolatedModules\": true,\n    \"noEmit\": true\n  },\n  \"include\": [\"./**/*\"],\n  \"exclude\": [\"./_generated\"]\n}\n```\n\n#### src/App.tsx\n\n```typescript\nexport default function App() {\n  return <div>Hello World</div>;\n}\n```\n"
  },
  {
    "path": "convex/courseContributorBackfill.ts",
    "content": "import { v } from \"convex/values\";\nimport { internal } from \"./_generated/api\";\nimport { httpAction, internalMutation } from \"./_generated/server\";\nimport {\n  getRankedCourseContributors,\n  partitionCourseContributors,\n} from \"./lib/courseContributors\";\n\nconst DEFAULT_BATCH_SIZE = 10;\nconst MAX_BATCH_SIZE = 25;\n\nfunction normalizeBatchSize(value: number | undefined) {\n  if (typeof value !== \"number\" || !Number.isFinite(value)) {\n    return DEFAULT_BATCH_SIZE;\n  }\n\n  return Math.max(1, Math.min(MAX_BATCH_SIZE, Math.floor(value)));\n}\n\nfunction json(data: unknown, status = 200) {\n  return new Response(JSON.stringify(data), {\n    status,\n    headers: { \"content-type\": \"application/json; charset=utf-8\" },\n  });\n}\n\nasync function requireCourseContributorBackfillSecret(req: Request) {\n  const expectedSecret = process.env.COURSE_CONTRIBUTOR_BACKFILL_SECRET;\n  if (!expectedSecret) {\n    return {\n      ok: false,\n      response: json(\n        {\n          ok: false,\n          error: \"Missing COURSE_CONTRIBUTOR_BACKFILL_SECRET env var\",\n        },\n        500,\n      ),\n    } as const;\n  }\n\n  let body: unknown;\n  try {\n    body = await req.json();\n  } catch {\n    return {\n      ok: false,\n      response: json({ ok: false, error: \"Invalid JSON body\" }, 400),\n    } as const;\n  }\n\n  const parsed = body as { secret?: unknown };\n  if (parsed.secret !== expectedSecret) {\n    return {\n      ok: false,\n      response: json({ ok: false, error: \"Unauthorized\" }, 401),\n    } as const;\n  }\n\n  return { ok: true, body } as const;\n}\n\nconst backfillResultValidator = v.object({\n  processed: v.number(),\n  updatedCourses: v.number(),\n  nextCursor: v.union(v.string(), v.null()),\n  isDone: v.boolean(),\n  errors: v.array(\n    v.object({\n      courseId: v.number(),\n      message: v.string(),\n    }),\n  ),\n});\n\nexport const backfillCourseContributorDetailsBatchInternal = internalMutation({\n  args: {\n    batchSize: v.optional(v.number()),\n    cursor: v.optional(v.union(v.string(), v.null())),\n    dryRun: v.optional(v.boolean()),\n  },\n  returns: backfillResultValidator,\n  handler: async (ctx, args) => {\n    const batchSize = normalizeBatchSize(args.batchSize);\n    const page = await ctx.db.query(\"courses\").paginate({\n      cursor: args.cursor ?? null,\n      numItems: batchSize,\n    });\n\n    let updatedCourses = 0;\n    const errors: Array<{ courseId: number; message: string }> = [];\n\n    for (const course of page.page) {\n      try {\n        const contributorLists = partitionCourseContributors(\n          await getRankedCourseContributors(ctx, course._id),\n        );\n\n        if (!args.dryRun) {\n          await ctx.db.patch(course._id, {\n            contributors: contributorLists.contributors.map((row) => row.name),\n            contributors_past: contributorLists.contributors_past.map(\n              (row) => row.name,\n            ),\n            contributorDetails: contributorLists.contributors,\n            contributorDetailsPast: contributorLists.contributors_past,\n          });\n        }\n\n        updatedCourses += 1;\n      } catch (error: unknown) {\n        const message = error instanceof Error ? error.message : String(error);\n        errors.push({\n          courseId: course.legacyId,\n          message,\n        });\n      }\n    }\n\n    return {\n      processed: page.page.length,\n      updatedCourses,\n      nextCursor: page.continueCursor,\n      isDone: page.isDone,\n      errors,\n    };\n  },\n});\n\nexport const backfillCourseContributorDetailsHttp = httpAction(\n  async (ctx, req) => {\n    if (req.method !== \"POST\") {\n      return json({ ok: false, error: \"Method not allowed\" }, 405);\n    }\n\n    const auth = await requireCourseContributorBackfillSecret(req);\n    if (!auth.ok) return auth.response;\n\n    const body = auth.body as {\n      batchSize?: unknown;\n      cursor?: unknown;\n      dryRun?: unknown;\n    };\n\n    if (body.batchSize !== undefined && typeof body.batchSize !== \"number\") {\n      return json({ ok: false, error: \"batchSize must be a number\" }, 400);\n    }\n    if (\n      body.cursor !== undefined &&\n      body.cursor !== null &&\n      typeof body.cursor !== \"string\"\n    ) {\n      return json({ ok: false, error: \"cursor must be a string or null\" }, 400);\n    }\n    if (body.dryRun !== undefined && typeof body.dryRun !== \"boolean\") {\n      return json({ ok: false, error: \"dryRun must be a boolean\" }, 400);\n    }\n\n    const result = await ctx.runMutation(\n      internal.courseContributorBackfill\n        .backfillCourseContributorDetailsBatchInternal,\n      {\n        batchSize:\n          typeof body.batchSize === \"number\" ? body.batchSize : undefined,\n        cursor:\n          typeof body.cursor === \"string\" || body.cursor === null\n            ? body.cursor\n            : undefined,\n        dryRun: typeof body.dryRun === \"boolean\" ? body.dryRun : undefined,\n      },\n    );\n\n    return json(result);\n  },\n);\n"
  },
  {
    "path": "convex/courseWrite.ts",
    "content": "import { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { requireContributorOrAdmin } from \"./lib/authorization\";\nimport { recomputeCoursePublishedCount } from \"./lib/courseCounts\";\n\nexport const recomputePublishedCount = mutation({\n  args: {\n    legacyCourseId: v.number(),\n  },\n  returns: v.object({\n    count: v.number(),\n    updated: v.boolean(),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n\n    const course = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.legacyCourseId))\n      .unique();\n    if (!course) {\n      throw new Error(`Course ${args.legacyCourseId} not found`);\n    }\n\n    const previousCount = course.count ?? 0;\n    const count = await recomputeCoursePublishedCount(ctx, course._id);\n\n    return {\n      count,\n      updated: previousCount !== count,\n    };\n  },\n});\n"
  },
  {
    "path": "convex/discordAvatarSync.ts",
    "content": "import {\n  action,\n  httpAction,\n  internalAction,\n  type ActionCtx,\n} from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { components, internal } from \"./_generated/api\";\nimport { syncDiscordAvatarFromAccount } from \"./lib/discordAvatarSync\";\n\ntype AdapterWhere = Array<{\n  field: string;\n  operator?: \"eq\" | \"in\";\n  value: string | Array<string>;\n}>;\n\ntype AuthAccountRow = {\n  _id?: string | null;\n  userId?: string | null;\n  providerId?: string | null;\n  accountId?: string | null;\n  accessToken?: string | null;\n  refreshToken?: string | null;\n  accessTokenExpiresAt?: number | null;\n  scope?: string | null;\n};\n\ntype PaginatedAdapterResponse<T> = {\n  page: T[];\n  isDone?: boolean;\n  continueCursor?: string | null;\n};\n\ntype BackfillArgs = {\n  batchSize?: number;\n  cursor?: string | null;\n  dryRun?: boolean;\n};\n\ntype BackfillResult = {\n  processed: number;\n  updatedUsers: number;\n  updatedAccounts: number;\n  skipped: number;\n  nextCursor: string | null;\n  isDone: boolean;\n  errors: Array<{\n    accountId: string | null;\n    userId: string | null;\n    message: string;\n  }>;\n};\n\nconst DEFAULT_BATCH_SIZE = 25;\nconst MAX_BATCH_SIZE = 100;\n\nfunction json(data: unknown, status = 200) {\n  return new Response(JSON.stringify(data), {\n    status,\n    headers: { \"content-type\": \"application/json; charset=utf-8\" },\n  });\n}\n\nasync function requireDiscordAvatarSyncSecret(req: Request) {\n  const expectedSecret = process.env.DISCORD_AVATAR_SYNC_SECRET;\n  if (!expectedSecret) {\n    return {\n      ok: false,\n      response: json(\n        { ok: false, error: \"Missing DISCORD_AVATAR_SYNC_SECRET env var\" },\n        500,\n      ),\n    } as const;\n  }\n\n  let body: unknown;\n  try {\n    body = await req.json();\n  } catch {\n    return {\n      ok: false,\n      response: json({ ok: false, error: \"Invalid JSON body\" }, 400),\n    } as const;\n  }\n\n  const parsed = body as { secret?: unknown };\n  if (parsed.secret !== expectedSecret) {\n    return {\n      ok: false,\n      response: json({ ok: false, error: \"Unauthorized\" }, 401),\n    } as const;\n  }\n\n  return { ok: true, body } as const;\n}\n\nfunction normalizeBatchSize(value: number | undefined) {\n  if (typeof value !== \"number\" || !Number.isFinite(value)) {\n    return DEFAULT_BATCH_SIZE;\n  }\n\n  return Math.max(1, Math.min(MAX_BATCH_SIZE, Math.floor(value)));\n}\n\nfunction dedupeAccounts(accounts: AuthAccountRow[]) {\n  const seen = new Set<string>();\n  const unique: AuthAccountRow[] = [];\n\n  for (const account of accounts) {\n    const key = `${account.userId ?? \"\"}:${account.accountId ?? \"\"}`;\n    if (seen.has(key)) continue;\n    seen.add(key);\n    unique.push(account);\n  }\n\n  return unique;\n}\n\nasync function findManyPage<T>(\n  ctx: ActionCtx,\n  model: \"account\",\n  where: AdapterWhere,\n  cursor: string | null,\n  batchSize: number,\n): Promise<PaginatedAdapterResponse<T>> {\n  return (await ctx.runQuery(components.betterAuth.adapter.findMany, {\n    model,\n    where,\n    paginationOpts: { cursor, numItems: batchSize },\n  })) as PaginatedAdapterResponse<T>;\n}\n\nasync function runDiscordAvatarBackfill(\n  ctx: ActionCtx,\n  args: BackfillArgs,\n): Promise<BackfillResult> {\n  const batchSize = normalizeBatchSize(args.batchSize);\n  const page = await findManyPage<AuthAccountRow>(\n    ctx,\n    \"account\",\n    [{ field: \"providerId\", operator: \"eq\", value: \"discord\" }],\n    args.cursor ?? null,\n    batchSize,\n  );\n\n  let processed = 0;\n  let updatedUsers = 0;\n  let updatedAccounts = 0;\n  let skipped = 0;\n  const errors: Array<{\n    accountId: string | null;\n    userId: string | null;\n    message: string;\n  }> = [];\n\n  for (const account of dedupeAccounts(page.page)) {\n    processed += 1;\n\n    if (\n      typeof account._id !== \"string\" ||\n      typeof account.userId !== \"string\" ||\n      typeof account.providerId !== \"string\"\n    ) {\n      skipped += 1;\n      continue;\n    }\n\n    try {\n      const result = await syncDiscordAvatarFromAccount(account);\n      if (!result.ok) {\n        skipped += 1;\n        continue;\n      }\n\n      if (!args.dryRun) {\n        if (result.imageUrl) {\n          await ctx.runMutation(components.betterAuth.adapter.updateOne, {\n            input: {\n              model: \"user\",\n              where: [{ field: \"_id\", value: account.userId }],\n              update: { image: result.imageUrl },\n            },\n          });\n          updatedUsers += 1;\n        }\n\n        const accountUpdate: Record<string, string | number | null> = {};\n        if (result.accessToken !== account.accessToken) {\n          accountUpdate.accessToken = result.accessToken;\n        }\n        if (result.refreshToken !== account.refreshToken) {\n          accountUpdate.refreshToken = result.refreshToken;\n        }\n        if (result.accessTokenExpiresAt !== account.accessTokenExpiresAt) {\n          accountUpdate.accessTokenExpiresAt = result.accessTokenExpiresAt;\n        }\n        if (result.scope !== account.scope) {\n          accountUpdate.scope = result.scope;\n        }\n\n        if (Object.keys(accountUpdate).length > 0) {\n          await ctx.runMutation(components.betterAuth.adapter.updateOne, {\n            input: {\n              model: \"account\",\n              where: [{ field: \"_id\", value: account._id }],\n              update: accountUpdate,\n            },\n          });\n          updatedAccounts += 1;\n        }\n      } else {\n        if (result.imageUrl) updatedUsers += 1;\n        if (\n          result.accessToken !== account.accessToken ||\n          result.refreshToken !== account.refreshToken ||\n          result.accessTokenExpiresAt !== account.accessTokenExpiresAt ||\n          result.scope !== account.scope\n        ) {\n          updatedAccounts += 1;\n        }\n      }\n    } catch (error: unknown) {\n      const message = error instanceof Error ? error.message : String(error);\n      errors.push({\n        accountId: account.accountId ?? null,\n        userId: account.userId ?? null,\n        message,\n      });\n    }\n  }\n\n  return {\n    processed,\n    updatedUsers,\n    updatedAccounts,\n    skipped,\n    nextCursor: page.continueCursor ?? null,\n    isDone: page.isDone ?? true,\n    errors,\n  };\n}\n\nconst backfillArgsValidator = {\n  batchSize: v.optional(v.number()),\n  cursor: v.optional(v.union(v.string(), v.null())),\n  dryRun: v.optional(v.boolean()),\n};\n\nconst backfillReturnsValidator = v.object({\n  processed: v.number(),\n  updatedUsers: v.number(),\n  updatedAccounts: v.number(),\n  skipped: v.number(),\n  nextCursor: v.union(v.string(), v.null()),\n  isDone: v.boolean(),\n  errors: v.array(\n    v.object({\n      accountId: v.union(v.string(), v.null()),\n      userId: v.union(v.string(), v.null()),\n      message: v.string(),\n    }),\n  ),\n});\n\nexport const backfillDiscordUserImagesInternal = internalAction({\n  args: backfillArgsValidator,\n  returns: backfillReturnsValidator,\n  handler: async (ctx, args) => {\n    return await runDiscordAvatarBackfill(ctx, args);\n  },\n});\n\nexport const backfillDiscordUserImages = action({\n  args: backfillArgsValidator,\n  returns: backfillReturnsValidator,\n  handler: async (ctx, args) => {\n    const identity = (await ctx.auth.getUserIdentity()) as {\n      role?: string | null;\n    } | null;\n    if (identity?.role !== \"admin\") {\n      throw new Error(\"Unauthorized\");\n    }\n    return await runDiscordAvatarBackfill(ctx, args);\n  },\n});\n\nexport const backfillDiscordUserImagesHttp = httpAction(async (ctx, req) => {\n  if (req.method !== \"POST\") {\n    return json({ ok: false, error: \"Method not allowed\" }, 405);\n  }\n\n  const auth = await requireDiscordAvatarSyncSecret(req);\n  if (!auth.ok) return auth.response;\n\n  const body = auth.body as {\n    batchSize?: unknown;\n    cursor?: unknown;\n    dryRun?: unknown;\n  };\n\n  if (body.batchSize !== undefined && typeof body.batchSize !== \"number\") {\n    return json({ ok: false, error: \"batchSize must be a number\" }, 400);\n  }\n  if (\n    body.cursor !== undefined &&\n    body.cursor !== null &&\n    typeof body.cursor !== \"string\"\n  ) {\n    return json({ ok: false, error: \"cursor must be a string or null\" }, 400);\n  }\n  if (body.dryRun !== undefined && typeof body.dryRun !== \"boolean\") {\n    return json({ ok: false, error: \"dryRun must be a boolean\" }, 400);\n  }\n\n  const result: BackfillResult = await ctx.runAction(\n    internal.discordAvatarSync.backfillDiscordUserImagesInternal,\n    {\n      batchSize:\n        typeof body.batchSize === \"number\" ? body.batchSize : undefined,\n      cursor:\n        typeof body.cursor === \"string\" || body.cursor === null\n          ? body.cursor\n          : undefined,\n      dryRun: typeof body.dryRun === \"boolean\" ? body.dryRun : undefined,\n    },\n  );\n  return json(result);\n});\n"
  },
  {
    "path": "convex/discordBot.ts",
    "content": "import { components, internal } from \"./_generated/api\";\nimport { httpAction } from \"./_generated/server\";\n\ntype Role = \"user\" | \"contributor\" | \"admin\";\n\nfunction json(data: unknown, status = 200) {\n  return new Response(JSON.stringify(data), {\n    status,\n    headers: { \"content-type\": \"application/json; charset=utf-8\" },\n  });\n}\n\nasync function requireDiscordSyncSecret(req: Request) {\n  const expectedSecret = process.env.DISCORD_ROLE_SYNC_SECRET;\n  if (!expectedSecret) {\n    return {\n      ok: false,\n      response: json(\n        { ok: false, error: \"Missing DISCORD_ROLE_SYNC_SECRET env var\" },\n        500,\n      ),\n    } as const;\n  }\n\n  let body: unknown;\n  try {\n    body = await req.json();\n  } catch {\n    return {\n      ok: false,\n      response: json({ ok: false, error: \"Invalid JSON body\" }, 400),\n    } as const;\n  }\n\n  const parsed = body as { secret?: unknown };\n  if (parsed.secret !== expectedSecret) {\n    return {\n      ok: false,\n      response: json({ ok: false, error: \"Unauthorized\" }, 401),\n    } as const;\n  }\n\n  return { ok: true, body } as const;\n}\n\ntype CombineKind = \"users\" | \"publicStories\" | \"approvals\";\ntype StoriesRoleSyncStatus =\n  | \"assigned\"\n  | \"up_to_date\"\n  | \"no_milestone\"\n  | \"not_linked\"\n  | \"member_not_found\"\n  | \"error\";\n\nfunction parseStoriesRoleSyncStatus(\n  value: unknown,\n): StoriesRoleSyncStatus | null {\n  if (\n    value === \"assigned\" ||\n    value === \"up_to_date\" ||\n    value === \"no_milestone\" ||\n    value === \"not_linked\" ||\n    value === \"member_not_found\" ||\n    value === \"error\"\n  ) {\n    return value;\n  }\n  return null;\n}\n\nfunction parseNumItems(value: unknown) {\n  if (typeof value !== \"number\" || !Number.isInteger(value)) return 200;\n  return Math.max(1, Math.min(500, value));\n}\n\nfunction parseKind(value: unknown): CombineKind | null {\n  if (value === \"users\" || value === \"publicStories\" || value === \"approvals\") {\n    return value;\n  }\n  return null;\n}\n\nexport const setContributorWriteByDiscordAccountId = httpAction(\n  async (ctx, req) => {\n    if (req.method !== \"POST\") {\n      return json({ ok: false, error: \"Method not allowed\" }, 405);\n    }\n\n    const auth = await requireDiscordSyncSecret(req);\n    if (!auth.ok) return auth.response;\n\n    const parsed = auth.body as {\n      discordAccountId?: unknown;\n      write?: unknown;\n    };\n\n    if (typeof parsed.discordAccountId !== \"string\") {\n      return json(\n        { ok: false, error: \"discordAccountId must be a string\" },\n        400,\n      );\n    }\n    if (typeof parsed.write !== \"boolean\" && parsed.write !== null) {\n      return json({ ok: false, error: \"write must be a boolean or null\" }, 400);\n    }\n\n    const account = (await ctx.runQuery(components.betterAuth.adapter.findOne, {\n      model: \"account\",\n      where: [\n        { field: \"providerId\", value: \"discord\" },\n        { field: \"accountId\", value: parsed.discordAccountId },\n      ],\n    })) as { userId?: string | null } | null;\n\n    if (!account?.userId) {\n      return json({ ok: true, linked: false });\n    }\n\n    const user = (await ctx.runQuery(components.betterAuth.adapter.findOne, {\n      model: \"user\",\n      where: [{ field: \"_id\", value: account.userId }],\n    })) as {\n      _id?: string;\n      userId?: string | null;\n      name?: string | null;\n      role?: string | null;\n    } | null;\n\n    if (!user?._id) {\n      return json({ ok: true, linked: false });\n    }\n\n    const currentRole: Role =\n      user.role === \"admin\" || user.role === \"contributor\" ? user.role : \"user\";\n    let nextRole: Role = currentRole;\n    if (typeof parsed.write === \"boolean\") {\n      if (parsed.write && currentRole === \"user\") nextRole = \"contributor\";\n      if (!parsed.write && currentRole === \"contributor\") nextRole = \"user\";\n\n      if (nextRole !== currentRole) {\n        await ctx.runMutation(components.betterAuth.adapter.updateOne, {\n          input: {\n            model: \"user\",\n            where: [{ field: \"_id\", value: user._id }],\n            update: { role: nextRole },\n          },\n        });\n      }\n    }\n\n    const legacyId = Number.parseInt(user.userId ?? \"\", 10);\n    return json({\n      ok: true,\n      linked: true,\n      updated: nextRole !== currentRole,\n      user: {\n        id: Number.isFinite(legacyId) ? legacyId : null,\n        name: user.name ?? \"\",\n        role: nextRole,\n      },\n    });\n  },\n);\n\nexport const getDiscordCombineData = httpAction(async (ctx, req) => {\n  if (req.method !== \"POST\") {\n    return json({ ok: false, error: \"Method not allowed\" }, 405);\n  }\n\n  const auth = await requireDiscordSyncSecret(req);\n  if (!auth.ok) return auth.response;\n\n  const body = auth.body as {\n    kind?: unknown;\n    cursor?: unknown;\n    numItems?: unknown;\n    sinceDate?: unknown;\n  };\n\n  const kind = parseKind(body.kind);\n  if (!kind) {\n    return json(\n      {\n        ok: false,\n        error: \"kind must be one of users, publicStories, or approvals\",\n      },\n      400,\n    );\n  }\n\n  const paginationOpts = {\n    cursor: typeof body.cursor === \"string\" ? body.cursor : null,\n    numItems: parseNumItems(body.numItems),\n  };\n\n  if (kind === \"users\") {\n    const users = await ctx.runQuery(\n      internal.discordData.getContributorDiscordLinks,\n      {},\n    );\n    return json({ ok: true, users });\n  }\n\n  if (kind === \"publicStories\") {\n    const page = await ctx.runQuery(\n      internal.discordData.getPublicStoryIdsPage,\n      {\n        paginationOpts,\n      },\n    );\n    return json({ ok: true, ...page });\n  }\n\n  if (\n    body.sinceDate !== undefined &&\n    (typeof body.sinceDate !== \"number\" || !Number.isInteger(body.sinceDate))\n  ) {\n    return json({ ok: false, error: \"sinceDate must be an integer\" }, 400);\n  }\n\n  const page = await ctx.runQuery(internal.discordData.getApprovalPage, {\n    paginationOpts,\n    sinceDate: typeof body.sinceDate === \"number\" ? body.sinceDate : undefined,\n  });\n  return json({ ok: true, ...page });\n});\n\nexport const setStoriesRoleSyncStatus = httpAction(async (ctx, req) => {\n  if (req.method !== \"POST\") {\n    return json({ ok: false, error: \"Method not allowed\" }, 405);\n  }\n\n  const auth = await requireDiscordSyncSecret(req);\n  if (!auth.ok) return auth.response;\n\n  const body = auth.body as {\n    snapshots?: unknown;\n  };\n\n  if (!Array.isArray(body.snapshots)) {\n    return json({ ok: false, error: \"snapshots must be an array\" }, 400);\n  }\n\n  const snapshots: Array<{\n    legacyUserId: number;\n    discordAccountId: string | null;\n    eligibleStoriesCount: number | null;\n    assignedStoriesCount: number | null;\n    syncStatus: StoriesRoleSyncStatus;\n    lastSyncedAt: number;\n    lastError: string | null;\n  }> = [];\n\n  for (const snapshot of body.snapshots) {\n    if (!snapshot || typeof snapshot !== \"object\") {\n      return json(\n        { ok: false, error: \"snapshot entries must be objects\" },\n        400,\n      );\n    }\n\n    const parsed = snapshot as {\n      legacyUserId?: unknown;\n      discordAccountId?: unknown;\n      eligibleStoriesCount?: unknown;\n      assignedStoriesCount?: unknown;\n      syncStatus?: unknown;\n      lastSyncedAt?: unknown;\n      lastError?: unknown;\n    };\n    const syncStatus = parseStoriesRoleSyncStatus(parsed.syncStatus);\n    if (\n      typeof parsed.legacyUserId !== \"number\" ||\n      !Number.isInteger(parsed.legacyUserId)\n    ) {\n      return json({ ok: false, error: \"legacyUserId must be an integer\" }, 400);\n    }\n    if (\n      parsed.discordAccountId !== null &&\n      parsed.discordAccountId !== undefined &&\n      typeof parsed.discordAccountId !== \"string\"\n    ) {\n      return json(\n        { ok: false, error: \"discordAccountId must be a string or null\" },\n        400,\n      );\n    }\n    if (\n      parsed.eligibleStoriesCount !== null &&\n      parsed.eligibleStoriesCount !== undefined &&\n      (typeof parsed.eligibleStoriesCount !== \"number\" ||\n        !Number.isInteger(parsed.eligibleStoriesCount))\n    ) {\n      return json(\n        { ok: false, error: \"eligibleStoriesCount must be an integer or null\" },\n        400,\n      );\n    }\n    if (\n      parsed.assignedStoriesCount !== null &&\n      parsed.assignedStoriesCount !== undefined &&\n      (typeof parsed.assignedStoriesCount !== \"number\" ||\n        !Number.isInteger(parsed.assignedStoriesCount))\n    ) {\n      return json(\n        { ok: false, error: \"assignedStoriesCount must be an integer or null\" },\n        400,\n      );\n    }\n    if (!syncStatus) {\n      return json({ ok: false, error: \"syncStatus is invalid\" }, 400);\n    }\n    if (\n      typeof parsed.lastSyncedAt !== \"number\" ||\n      !Number.isInteger(parsed.lastSyncedAt)\n    ) {\n      return json({ ok: false, error: \"lastSyncedAt must be an integer\" }, 400);\n    }\n    if (\n      parsed.lastError !== null &&\n      parsed.lastError !== undefined &&\n      typeof parsed.lastError !== \"string\"\n    ) {\n      return json(\n        { ok: false, error: \"lastError must be a string or null\" },\n        400,\n      );\n    }\n\n    snapshots.push({\n      legacyUserId: parsed.legacyUserId,\n      discordAccountId:\n        typeof parsed.discordAccountId === \"string\"\n          ? parsed.discordAccountId\n          : null,\n      eligibleStoriesCount:\n        typeof parsed.eligibleStoriesCount === \"number\"\n          ? parsed.eligibleStoriesCount\n          : null,\n      assignedStoriesCount:\n        typeof parsed.assignedStoriesCount === \"number\"\n          ? parsed.assignedStoriesCount\n          : null,\n      syncStatus,\n      lastSyncedAt: parsed.lastSyncedAt,\n      lastError: typeof parsed.lastError === \"string\" ? parsed.lastError : null,\n    });\n  }\n\n  await ctx.runMutation(\n    internal.discordRoleSync.replaceStoriesRoleSyncSnapshots,\n    {\n      snapshots,\n    },\n  );\n  return json({ ok: true, count: snapshots.length });\n});\n"
  },
  {
    "path": "convex/discordData.ts",
    "content": "import { paginationOptsValidator } from \"convex/server\";\nimport { v } from \"convex/values\";\nimport { components } from \"./_generated/api\";\nimport { internalQuery, type QueryCtx } from \"./_generated/server\";\nimport type { Id } from \"./_generated/dataModel\";\n\nconst contributorUserValidator = v.object({\n  legacyUserId: v.number(),\n  author: v.string(),\n  discordAccountId: v.union(v.string(), v.null()),\n});\n\nconst publicStoriesPageValidator = v.object({\n  page: v.array(v.number()),\n  isDone: v.boolean(),\n  continueCursor: v.string(),\n});\n\nconst approvalsPageValidator = v.object({\n  page: v.array(\n    v.object({\n      id: v.id(\"story_approval\"),\n      legacyUserId: v.number(),\n      storyId: v.number(),\n      date: v.number(),\n    }),\n  ),\n  isDone: v.boolean(),\n  continueCursor: v.string(),\n});\n\ntype AdapterWhere = Array<{\n  field: string;\n  operator?: \"eq\" | \"in\";\n  value: string | Array<string>;\n}>;\ntype BetterAuthModel = \"user\" | \"account\";\n\ntype AuthUserRow = {\n  _id?: string | null;\n  userId?: string | null;\n  name?: string | null;\n  role?: string | null;\n};\n\ntype AuthAccountRow = {\n  userId?: string | null;\n  accountId?: string | null;\n};\n\ntype PaginatedAdapterResponse<T> = {\n  page: T[];\n  isDone?: boolean;\n  continueCursor?: string | null;\n};\n\nasync function findManyAll<T>(\n  ctx: QueryCtx,\n  model: BetterAuthModel,\n  where: AdapterWhere,\n): Promise<T[]> {\n  let cursor: string | null = null;\n  const rows: T[] = [];\n\n  while (true) {\n    const page = (await ctx.runQuery(components.betterAuth.adapter.findMany, {\n      model,\n      where,\n      paginationOpts: { cursor, numItems: 200 },\n    })) as PaginatedAdapterResponse<T>;\n\n    rows.push(...page.page);\n    if (page.isDone) break;\n    cursor = page.continueCursor ?? null;\n    if (!cursor) break;\n  }\n\n  return rows;\n}\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n  const chunks: T[][] = [];\n  for (let index = 0; index < items.length; index += size) {\n    chunks.push(items.slice(index, index + size));\n  }\n  return chunks;\n}\n\nasync function getContributorAndAdminUsers(ctx: QueryCtx) {\n  const [contributors, admins] = await Promise.all([\n    findManyAll<AuthUserRow>(ctx, \"user\", [\n      { field: \"role\", operator: \"eq\", value: \"contributor\" },\n    ]),\n    findManyAll<AuthUserRow>(ctx, \"user\", [\n      { field: \"role\", operator: \"eq\", value: \"admin\" },\n    ]),\n  ]);\n\n  const usersByAuthId = new Map<string, AuthUserRow>();\n  for (const user of [...contributors, ...admins]) {\n    if (typeof user._id !== \"string\" || user._id.length === 0) continue;\n    usersByAuthId.set(user._id, user);\n  }\n  return usersByAuthId;\n}\n\nexport const getContributorDiscordLinks = internalQuery({\n  args: {},\n  returns: v.array(contributorUserValidator),\n  handler: async (ctx) => {\n    const usersByAuthId = await getContributorAndAdminUsers(ctx);\n    const authUserIds = Array.from(usersByAuthId.keys());\n    if (authUserIds.length === 0) return [];\n\n    const accounts: AuthAccountRow[] = [];\n    for (const userIds of chunk(authUserIds, 100)) {\n      const rows = await findManyAll<AuthAccountRow>(ctx, \"account\", [\n        { field: \"providerId\", operator: \"eq\", value: \"discord\" },\n        { field: \"userId\", operator: \"in\", value: userIds },\n      ]);\n      accounts.push(...rows);\n    }\n\n    const discordAccountIdByUserId = new Map<string, string>();\n    for (const account of accounts) {\n      if (\n        typeof account.userId !== \"string\" ||\n        typeof account.accountId !== \"string\"\n      ) {\n        continue;\n      }\n      discordAccountIdByUserId.set(account.userId, account.accountId);\n    }\n\n    return authUserIds\n      .map((authUserId) => {\n        const user = usersByAuthId.get(authUserId);\n        const discordAccountId = discordAccountIdByUserId.get(authUserId);\n        if (!user || typeof user.name !== \"string\") {\n          return null;\n        }\n\n        const legacyUserId = Number.parseInt(user.userId ?? \"\", 10);\n        if (!Number.isFinite(legacyUserId)) {\n          return null;\n        }\n\n        return {\n          legacyUserId,\n          author: user.name,\n          discordAccountId:\n            typeof discordAccountId === \"string\" ? discordAccountId : null,\n        };\n      })\n      .filter(\n        (\n          user,\n        ): user is {\n          legacyUserId: number;\n          author: string;\n          discordAccountId: string | null;\n        } => user !== null,\n      );\n  },\n});\n\nexport const getPublicStoryIdsPage = internalQuery({\n  args: {\n    paginationOpts: paginationOptsValidator,\n  },\n  returns: publicStoriesPageValidator,\n  handler: async (ctx, args) => {\n    const page = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_public\", (q) => q.eq(\"public\", true).eq(\"deleted\", false))\n      .paginate(args.paginationOpts);\n\n    return {\n      page: page.page\n        .map((story) => story.legacyId)\n        .filter((legacyId): legacyId is number => typeof legacyId === \"number\"),\n      isDone: page.isDone,\n      continueCursor: page.continueCursor,\n    };\n  },\n});\n\nexport const getApprovalPage = internalQuery({\n  args: {\n    paginationOpts: paginationOptsValidator,\n    sinceDate: v.optional(v.number()),\n  },\n  returns: approvalsPageValidator,\n  handler: async (ctx, args) => {\n    const page = await ctx.db\n      .query(\"story_approval\")\n      .withIndex(\"by_date\", (q) => q.gte(\"date\", args.sinceDate ?? 0))\n      .paginate(args.paginationOpts);\n\n    const legacyUserIds = Array.from(\n      new Set(\n        page.page\n          .map((approval) => approval.legacyUserId)\n          .filter(\n            (legacyUserId): legacyUserId is number =>\n              typeof legacyUserId === \"number\",\n          ),\n      ),\n    );\n\n    const allowedLegacyUserIds = new Set<number>();\n    for (const legacyIds of chunk(legacyUserIds, 100)) {\n      const users = (await ctx.runQuery(\n        components.betterAuth.adapter.findMany,\n        {\n          model: \"user\",\n          where: [\n            { field: \"userId\", operator: \"in\", value: legacyIds.map(String) },\n          ],\n          paginationOpts: { cursor: null, numItems: legacyIds.length + 20 },\n        },\n      )) as PaginatedAdapterResponse<AuthUserRow>;\n\n      for (const user of users.page) {\n        if (user.role !== \"contributor\" && user.role !== \"admin\") continue;\n        const legacyUserId = Number.parseInt(user.userId ?? \"\", 10);\n        if (Number.isFinite(legacyUserId)) {\n          allowedLegacyUserIds.add(legacyUserId);\n        }\n      }\n    }\n\n    const storyIds = Array.from(\n      new Set(page.page.map((approval) => approval.storyId)),\n    );\n    const storyMetaById = new Map<Id<\"stories\">, number>();\n    for (const storyId of storyIds) {\n      const story = await ctx.db.get(storyId);\n      if (story && typeof story.legacyId === \"number\") {\n        storyMetaById.set(storyId, story.legacyId);\n      }\n    }\n\n    return {\n      page: page.page\n        .map((approval) => {\n          if (\n            typeof approval.legacyUserId !== \"number\" ||\n            !allowedLegacyUserIds.has(approval.legacyUserId)\n          ) {\n            return null;\n          }\n\n          const storyLegacyId = storyMetaById.get(approval.storyId);\n          if (typeof storyLegacyId !== \"number\") return null;\n\n          return {\n            id: approval._id,\n            legacyUserId: approval.legacyUserId,\n            storyId: storyLegacyId,\n            date: approval.date,\n          };\n        })\n        .filter(\n          (\n            approval,\n          ): approval is {\n            id: Id<\"story_approval\">;\n            legacyUserId: number;\n            storyId: number;\n            date: number;\n          } => approval !== null,\n        ),\n      isDone: page.isDone,\n      continueCursor: page.continueCursor,\n    };\n  },\n});\n"
  },
  {
    "path": "convex/discordRoleSync.ts",
    "content": "import { internalMutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nconst syncStatusValidator = v.union(\n  v.literal(\"assigned\"),\n  v.literal(\"up_to_date\"),\n  v.literal(\"no_milestone\"),\n  v.literal(\"not_linked\"),\n  v.literal(\"member_not_found\"),\n  v.literal(\"error\"),\n);\n\nconst storiesRoleSnapshotValidator = v.object({\n  legacyUserId: v.number(),\n  discordAccountId: v.union(v.string(), v.null()),\n  eligibleStoriesCount: v.union(v.number(), v.null()),\n  assignedStoriesCount: v.union(v.number(), v.null()),\n  syncStatus: syncStatusValidator,\n  lastSyncedAt: v.number(),\n  lastError: v.union(v.string(), v.null()),\n});\n\nexport const replaceStoriesRoleSyncSnapshots = internalMutation({\n  args: {\n    snapshots: v.array(storiesRoleSnapshotValidator),\n  },\n  returns: v.null(),\n  handler: async (ctx, args) => {\n    const existingRows = await ctx.db\n      .query(\"discord_stories_role_sync\")\n      .collect();\n    const existingByLegacyUserId = new Map(\n      existingRows.map((row) => [row.legacyUserId, row]),\n    );\n\n    for (const snapshot of args.snapshots) {\n      const existing = existingByLegacyUserId.get(snapshot.legacyUserId);\n\n      if (existing) {\n        await ctx.db.patch(existing._id, snapshot);\n        continue;\n      }\n\n      await ctx.db.insert(\"discord_stories_role_sync\", snapshot);\n    }\n\n    return null;\n  },\n});\n"
  },
  {
    "path": "convex/editorRead.ts",
    "content": "import { query, type QueryCtx } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { components } from \"./_generated/api\";\nimport type { Doc, Id } from \"./_generated/dataModel\";\nimport { courseContributorValidator } from \"./lib/courseContributors\";\n\ntype LanguageDoc = Doc<\"languages\">;\ntype CourseDoc = Doc<\"courses\">;\ntype StoryDoc = Doc<\"stories\">;\ntype AvatarDoc = Doc<\"avatars\">;\ntype AvatarMappingDoc = Doc<\"avatar_mappings\">;\n\nconst editorCourseValidator = v.union(\n  v.object({\n    id: v.number(),\n    short: v.union(v.string(), v.null()),\n    about: v.union(v.string(), v.null()),\n    official: v.boolean(),\n    count: v.number(),\n    public: v.boolean(),\n    fromLanguageId: v.id(\"languages\"),\n    from_language: v.number(),\n    from_language_short: v.string(),\n    from_language_name: v.string(),\n    learningLanguageId: v.id(\"languages\"),\n    learning_language: v.number(),\n    learning_language_short: v.string(),\n    learning_language_name: v.string(),\n    contributors: v.array(courseContributorValidator),\n    contributors_past: v.array(courseContributorValidator),\n    todo_count: v.number(),\n  }),\n  v.null(),\n);\n\nfunction toNumber(value: unknown): number | undefined {\n  if (typeof value === \"number\" && Number.isFinite(value)) return value;\n  if (typeof value === \"string\") {\n    const parsed = Number(value);\n    if (Number.isFinite(parsed)) return parsed;\n  }\n  return undefined;\n}\n\nfunction toLanguage(language: LanguageDoc) {\n  return {\n    languageId: language._id,\n    id: language.legacyId,\n    name: language.name,\n    short: language.short,\n    speaker: language.speaker ?? null,\n    default_text: language.default_text ?? \"\",\n    tts_replace: language.tts_replace ?? null,\n    public: language.public,\n    rtl: language.rtl,\n  };\n}\n\nfunction toCourse(\n  course: CourseDoc,\n  languageById: Map<Id<\"languages\">, LanguageDoc>,\n) {\n  const learningLanguage = languageById.get(course.learningLanguageId);\n  const fromLanguage = languageById.get(course.fromLanguageId);\n\n  return {\n    id: course.legacyId,\n    short: course.short ?? null,\n    about: course.about ?? null,\n    official: course.official,\n    count: course.count ?? 0,\n    public: course.public,\n    fromLanguageId: course.fromLanguageId,\n    from_language: fromLanguage?.legacyId ?? 0,\n    from_language_short: fromLanguage?.short ?? \"\",\n    from_language_name: fromLanguage?.name ?? course.from_language_name ?? \"\",\n    learningLanguageId: course.learningLanguageId,\n    learning_language: learningLanguage?.legacyId ?? 0,\n    learning_language_short: learningLanguage?.short ?? \"\",\n    learning_language_name:\n      learningLanguage?.name ?? course.learning_language_name ?? \"\",\n    contributors: course.contributors ?? [],\n    contributors_past: course.contributors_past ?? [],\n    todo_count: course.todo_count ?? 0,\n  };\n}\n\nasync function getCourseByIdentifier(ctx: QueryCtx, identifier: string) {\n  const isNumericIdentifier = /^\\d+$/.test(identifier);\n  if (isNumericIdentifier) {\n    const numeric = Number(identifier);\n    const byId = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", numeric))\n      .unique();\n    if (byId) return byId;\n  }\n\n  return await ctx.db\n    .query(\"courses\")\n    .withIndex(\"by_short\", (q) => q.eq(\"short\", identifier))\n    .unique();\n}\n\nasync function getUserNameByLegacyId(ctx: QueryCtx, legacyIds: number[]) {\n  const uniqueLegacyIds = Array.from(new Set(legacyIds));\n  if (!uniqueLegacyIds.length) return new Map<number, string>();\n\n  const userIds = uniqueLegacyIds.map((legacyId) => String(legacyId));\n  const users = (await ctx.runQuery(components.betterAuth.adapter.findMany, {\n    model: \"user\",\n    where: [{ field: \"userId\", operator: \"in\", value: userIds }],\n    paginationOpts: { cursor: null, numItems: userIds.length + 10 },\n  })) as {\n    page: Array<{ userId?: string | null; name?: string | null }>;\n  };\n\n  const map = new Map<number, string>();\n  for (const user of users.page) {\n    const legacyId = Number.parseInt(user.userId ?? \"\", 10);\n    if (!Number.isFinite(legacyId) || !user.name) continue;\n    map.set(legacyId, user.name);\n  }\n\n  return map;\n}\n\nasync function getUserNameByAuthDocId(ctx: QueryCtx, authDocIds: string[]) {\n  const uniqueAuthDocIds = Array.from(\n    new Set(authDocIds.map((id) => id.trim()).filter(Boolean)),\n  );\n  if (!uniqueAuthDocIds.length) return new Map<string, string>();\n\n  const map = new Map<string, string>();\n  const byUserId = (await ctx.runQuery(components.betterAuth.adapter.findMany, {\n    model: \"user\",\n    where: [{ field: \"userId\", operator: \"in\", value: uniqueAuthDocIds }],\n    paginationOpts: { cursor: null, numItems: uniqueAuthDocIds.length + 10 },\n  })) as {\n    page: Array<{ userId?: string | null; name?: string | null }>;\n  };\n\n  for (const user of byUserId.page) {\n    const userId = user.userId?.trim();\n    if (!userId || !user.name) continue;\n    map.set(userId, user.name);\n  }\n\n  const unresolvedIds = uniqueAuthDocIds.filter((id) => !map.has(id));\n  if (!unresolvedIds.length) return map;\n\n  // Fallback for legacy rows that still store Better Auth document IDs.\n  const byDocId = await Promise.all(\n    unresolvedIds.map(async (id) => {\n      try {\n        const user = (await ctx.runQuery(components.betterAuth.adapter.get, {\n          id,\n        })) as { name?: string | null } | null;\n        return { id, name: user?.name ?? null };\n      } catch {\n        return { id, name: null };\n      }\n    }),\n  );\n\n  for (const result of byDocId) {\n    if (!result.name) continue;\n    map.set(result.id, result.name);\n  }\n\n  return map;\n}\n\nasync function buildAvatarRows(\n  ctx: QueryCtx,\n  language: LanguageDoc,\n  avatars?: AvatarDoc[],\n  mappings?: AvatarMappingDoc[],\n) {\n  const avatarRows = avatars ?? (await ctx.db.query(\"avatars\").collect());\n  const mappingRows =\n    mappings ??\n    (await ctx.db\n      .query(\"avatar_mappings\")\n      .withIndex(\"by_language_id\", (q) => q.eq(\"languageId\", language._id))\n      .collect());\n\n  const mappingByAvatar = new Map<Id<\"avatars\">, AvatarMappingDoc>();\n  for (const mapping of mappingRows) {\n    mappingByAvatar.set(mapping.avatarId, mapping);\n  }\n\n  return avatarRows\n    .filter((avatar: AvatarDoc) => avatar.link !== \"[object Object]\")\n    .map((avatar: AvatarDoc) => {\n      const mapping = mappingByAvatar.get(avatar._id);\n      return {\n        id: mapping?.legacyId ?? null,\n        avatar_id: avatar.legacyId,\n        language_id: language.legacyId,\n        name: mapping?.name ?? avatar.name ?? \"\",\n        link: avatar.link,\n        speaker: mapping?.speaker ?? \"\",\n      };\n    })\n    .sort(\n      (a: { avatar_id: number }, b: { avatar_id: number }) =>\n        a.avatar_id - b.avatar_id,\n    );\n}\n\nexport const getEditorSidebarData = query({\n  args: {},\n  handler: async (ctx) => {\n    const courseRows = await ctx.db.query(\"courses\").collect();\n\n    const referencedLanguageIds = Array.from(\n      new Set(\n        courseRows.flatMap((course) => [\n          course.learningLanguageId,\n          course.fromLanguageId,\n        ]),\n      ),\n    );\n    const languageRows = await Promise.all(\n      referencedLanguageIds.map((languageId) => ctx.db.get(languageId)),\n    );\n\n    const languageById = new Map<Id<\"languages\">, LanguageDoc>();\n    for (const language of languageRows) {\n      if (!language) continue;\n      languageById.set(language._id, language);\n    }\n\n    const courses = courseRows\n      .map((course) => toCourse(course, languageById))\n      .sort((a, b) => b.count - a.count);\n\n    return { courses };\n  },\n});\n\nexport const getEditorCourseByIdentifier = query({\n  args: { identifier: v.string() },\n  returns: editorCourseValidator,\n  handler: async (ctx, args) => {\n    const course = await getCourseByIdentifier(ctx, args.identifier);\n    if (!course) return null;\n\n    const [learningLanguage, fromLanguage] = await Promise.all([\n      ctx.db.get(course.learningLanguageId) as Promise<LanguageDoc | null>,\n      ctx.db.get(course.fromLanguageId) as Promise<LanguageDoc | null>,\n    ]);\n    const contributorLists = {\n      contributors: course.contributorDetails ?? [],\n      contributors_past: course.contributorDetailsPast ?? [],\n    };\n\n    return {\n      id: course.legacyId,\n      short: course.short ?? null,\n      about: course.about ?? null,\n      official: course.official,\n      count: course.count ?? 0,\n      public: course.public,\n      fromLanguageId: course.fromLanguageId,\n      from_language: fromLanguage?.legacyId ?? 0,\n      from_language_short: fromLanguage?.short ?? \"\",\n      from_language_name: fromLanguage?.name ?? course.from_language_name ?? \"\",\n      learningLanguageId: course.learningLanguageId,\n      learning_language: learningLanguage?.legacyId ?? 0,\n      learning_language_short: learningLanguage?.short ?? \"\",\n      learning_language_name:\n        learningLanguage?.name ?? course.learning_language_name ?? \"\",\n      contributors: contributorLists.contributors,\n      contributors_past: contributorLists.contributors_past,\n      todo_count: course.todo_count ?? 0,\n    };\n  },\n});\n\nexport const getEditorStoriesByCourseLegacyId = query({\n  args: { identifier: v.string() },\n  handler: async (ctx, args) => {\n    const timerBase = `editorRead:getEditorStoriesByCourseLegacyId:course:${args.identifier}`;\n    const storiesTimer = `${timerBase}:stories`;\n    const imagesTimer = `${timerBase}:images`;\n    const authorsTimer = `${timerBase}:authors`;\n    console.time(timerBase);\n\n    const course = await getCourseByIdentifier(ctx, args.identifier);\n    if (!course) {\n      console.timeEnd(timerBase);\n      console.log(\n        `[editorRead:getEditorStoriesByCourseLegacyId] course=${args.identifier} not_found`,\n      );\n      return [];\n    }\n\n    console.time(storiesTimer);\n    const storyRows = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_set\", (q) => q.eq(\"courseId\", course._id))\n      .collect();\n    console.timeEnd(storiesTimer);\n\n    const stories = storyRows.filter((story) => !story.deleted);\n\n    console.time(imagesTimer);\n    const imageIds = Array.from(\n      new Set(\n        stories\n          .map((story) => story.imageId)\n          .filter((id): id is Id<\"images\"> => Boolean(id)),\n      ),\n    );\n    const images = await Promise.all(imageIds.map((id) => ctx.db.get(id)));\n    const imageById = new Map<Id<\"images\">, Doc<\"images\">>();\n    images.forEach((image) => {\n      if (!image) return;\n      imageById.set(image._id, image);\n    });\n    console.timeEnd(imagesTimer);\n\n    console.time(authorsTimer);\n    const authorLegacyIds = Array.from(\n      new Set(\n        stories\n          .flatMap((story) => [\n            toNumber(story.authorId),\n            toNumber(story.authorChangeId),\n          ])\n          .filter((id): id is number => id !== undefined),\n      ),\n    );\n    const authorAuthDocIds = Array.from(\n      new Set(\n        stories\n          .flatMap((story) => [story.authorId, story.authorChangeId])\n          .filter(\n            (id): id is string =>\n              typeof id === \"string\" &&\n              id.trim().length > 0 &&\n              toNumber(id) === undefined,\n          ),\n      ),\n    );\n\n    const [nameByLegacyId, nameByAuthDocId] = await Promise.all([\n      getUserNameByLegacyId(ctx, authorLegacyIds),\n      getUserNameByAuthDocId(ctx, authorAuthDocIds),\n    ]);\n    console.timeEnd(authorsTimer);\n\n    const result = stories.map((story: StoryDoc) => {\n      const authorId = toNumber(story.authorId);\n      const authorChangeId = toNumber(story.authorChangeId);\n      const rawAuthorId =\n        typeof story.authorId === \"string\"\n          ? story.authorId.trim()\n          : story.authorId;\n      const rawAuthorChangeId =\n        typeof story.authorChangeId === \"string\"\n          ? story.authorChangeId.trim()\n          : story.authorChangeId;\n      const image = story.imageId ? imageById.get(story.imageId) : undefined;\n      const approvalCount = story.approvalCount;\n      const derivedStatus =\n        approvalCount === undefined\n          ? story.status\n          : approvalCount === 0\n            ? \"draft\"\n            : approvalCount === 1\n              ? \"feedback\"\n              : \"finished\";\n      return {\n        id: story.legacyId ?? 0,\n        name: story.name,\n        course_id: course.legacyId,\n        image: image?.legacyId ?? \"\",\n        set_id: story.set_id ?? 0,\n        set_index: story.set_index ?? 0,\n        date: story.date ?? story._creationTime,\n        change_date: story.change_date,\n        status: derivedStatus,\n        public: story.public,\n        todo_count: story.todo_count ?? 0,\n        approvalCount: approvalCount ?? 0,\n        author:\n          typeof authorId === \"number\"\n            ? (nameByLegacyId.get(authorId) ?? `User ${authorId}`)\n            : typeof rawAuthorId === \"string\" && rawAuthorId.length > 0\n              ? (nameByAuthDocId.get(rawAuthorId) ?? `User ${rawAuthorId}`)\n              : \"Unknown\",\n        author_change:\n          typeof authorChangeId === \"number\"\n            ? (nameByLegacyId.get(authorChangeId) ?? `User ${authorChangeId}`)\n            : typeof rawAuthorChangeId === \"string\" &&\n                rawAuthorChangeId.length > 0\n              ? (nameByAuthDocId.get(rawAuthorChangeId) ??\n                `User ${rawAuthorChangeId}`)\n              : null,\n      };\n    });\n\n    console.timeEnd(timerBase);\n    console.log(\n      `[editorRead:getEditorStoriesByCourseLegacyId] course=${args.identifier} totalStories=${storyRows.length} visibleStories=${stories.length} uniqueImages=${imageIds.length} uniqueLegacyAuthors=${authorLegacyIds.length} uniqueAuthDocAuthors=${authorAuthDocIds.length}`,\n    );\n\n    return result;\n  },\n});\n\nexport const getEditorCourseImport = query({\n  args: {\n    courseLegacyId: v.number(),\n    fromLegacyId: v.number(),\n  },\n  handler: async (ctx, args) => {\n    const [toCourse, fromCourse] = await Promise.all([\n      ctx.db\n        .query(\"courses\")\n        .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.courseLegacyId))\n        .unique(),\n      ctx.db\n        .query(\"courses\")\n        .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.fromLegacyId))\n        .unique(),\n    ]);\n    if (!toCourse || !fromCourse) return [];\n\n    const [sourceStories, targetStories] = await Promise.all([\n      ctx.db\n        .query(\"stories\")\n        .withIndex(\"by_course\", (q) => q.eq(\"courseId\", fromCourse._id))\n        .collect(),\n      ctx.db\n        .query(\"stories\")\n        .withIndex(\"by_course\", (q) => q.eq(\"courseId\", toCourse._id))\n        .collect(),\n    ]);\n\n    const activeSourceStories = sourceStories\n      .filter((story) => !story.deleted)\n      .sort((a, b) => {\n        const setA = a.set_id ?? 0;\n        const setB = b.set_id ?? 0;\n        if (setA !== setB) return setA - setB;\n        return (a.set_index ?? 0) - (b.set_index ?? 0);\n      });\n\n    const targetCountByDuoId = new Map<string, number>();\n    for (const story of targetStories) {\n      if (story.deleted) continue;\n      if (!story.duo_id) continue;\n      targetCountByDuoId.set(\n        story.duo_id,\n        (targetCountByDuoId.get(story.duo_id) ?? 0) + 1,\n      );\n    }\n\n    const imageIds = Array.from(\n      new Set(\n        activeSourceStories\n          .map((story) => story.imageId)\n          .filter((id): id is Id<\"images\"> => Boolean(id)),\n      ),\n    );\n    const images = await Promise.all(imageIds.map((id) => ctx.db.get(id)));\n    const imageById = new Map<Id<\"images\">, Doc<\"images\">>();\n    for (const image of images) {\n      if (!image) continue;\n      imageById.set(image._id, image);\n    }\n\n    return activeSourceStories.map((story) => {\n      const image = story.imageId ? imageById.get(story.imageId) : undefined;\n      return {\n        id: story.legacyId ?? 0,\n        set_id: story.set_id ?? 0,\n        set_index: story.set_index ?? 0,\n        name: story.name,\n        image_done: image?.gilded ?? \"\",\n        image: image?.active ?? \"\",\n        copies: String(targetCountByDuoId.get(story.duo_id ?? \"\") ?? 0),\n      };\n    });\n  },\n});\n\nexport const resolveEditorLanguage = query({\n  args: { identifier: v.string() },\n  handler: async (ctx, args) => {\n    const numeric = toNumber(args.identifier);\n\n    if (numeric !== undefined) {\n      const language = await ctx.db\n        .query(\"languages\")\n        .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", numeric))\n        .unique();\n      if (!language) return null;\n      return {\n        language: toLanguage(language),\n        course: null,\n        language2: null,\n      };\n    }\n\n    const course = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_short\", (q) => q.eq(\"short\", args.identifier))\n      .unique();\n\n    if (course) {\n      const [language, language2] = await Promise.all([\n        ctx.db.get(course.learningLanguageId),\n        ctx.db.get(course.fromLanguageId),\n      ]);\n      if (!language) return null;\n      return {\n        language: toLanguage(language),\n        course: {\n          learning_language: language.legacyId,\n          from_language: language2?.legacyId ?? 0,\n          short: course.short ?? \"\",\n        },\n        language2: language2 ? toLanguage(language2) : null,\n      };\n    }\n\n    const language = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_short\", (q) => q.eq(\"short\", args.identifier))\n      .unique();\n    if (!language) return null;\n\n    return {\n      language: toLanguage(language),\n      course: null,\n      language2: null,\n    };\n  },\n});\n\nexport const getEditorSpeakersByLanguageLegacyId = query({\n  args: { languageLegacyId: v.number() },\n  handler: async (ctx, args) => {\n    const language = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.languageLegacyId))\n      .unique();\n    if (!language) return [];\n\n    const speakers = await ctx.db\n      .query(\"speakers\")\n      .withIndex(\"by_language_id\", (q) => q.eq(\"languageId\", language._id))\n      .collect();\n\n    return speakers\n      .map((speaker) => ({\n        id: speaker.legacyId ?? 0,\n        language_id: language.legacyId,\n        speaker: speaker.speaker,\n        gender: speaker.gender,\n        type: speaker.type,\n        service: speaker.service,\n      }))\n      .sort((a, b) => a.speaker.localeCompare(b.speaker));\n  },\n});\n\nexport const getEditorAvatarNamesByLanguageLegacyId = query({\n  args: { languageLegacyId: v.number() },\n  handler: async (ctx, args) => {\n    const language = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.languageLegacyId))\n      .unique();\n    if (!language) return [];\n\n    return await buildAvatarRows(ctx, language);\n  },\n});\n\nexport const getEditorLocalizationRowsByLanguageLegacyId = query({\n  args: { languageLegacyId: v.number() },\n  handler: async (ctx, args) => {\n    const [englishLanguage, targetLanguage] = await Promise.all([\n      ctx.db\n        .query(\"languages\")\n        .withIndex(\"by_short\", (q) => q.eq(\"short\", \"en\"))\n        .unique(),\n      ctx.db\n        .query(\"languages\")\n        .withIndex(\"by_id_value\", (q) =>\n          q.eq(\"legacyId\", args.languageLegacyId),\n        )\n        .unique(),\n    ]);\n\n    if (!englishLanguage || !targetLanguage) return [];\n\n    const englishRows = await ctx.db\n      .query(\"localizations\")\n      .withIndex(\"by_language_id_and_tag\", (q) =>\n        q.eq(\"languageId\", englishLanguage._id),\n      )\n      .collect();\n\n    const targetRows =\n      englishLanguage._id === targetLanguage._id\n        ? englishRows\n        : await ctx.db\n            .query(\"localizations\")\n            .withIndex(\"by_language_id_and_tag\", (q) =>\n              q.eq(\"languageId\", targetLanguage._id),\n            )\n            .collect();\n\n    const targetByTag = new Map<string, string>();\n    for (const row of targetRows) {\n      if (!row.tag) continue;\n      targetByTag.set(row.tag, row.text);\n    }\n\n    return englishRows\n      .filter((row) => Boolean(row.tag))\n      .map((row) => ({\n        tag: row.tag,\n        text_en: row.text,\n        text: targetByTag.get(row.tag) ?? null,\n      }));\n  },\n});\n\nexport const getEditorStoryPageData = query({\n  args: { storyId: v.number() },\n  handler: async (ctx, args) => {\n    const identity = (await ctx.auth.getUserIdentity()) as {\n      role?: string | null;\n    } | null;\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.storyId))\n      .unique();\n    if (!story || story.deleted) return null;\n\n    const [course, storyContent] = await Promise.all([\n      ctx.db.get(story.courseId),\n      ctx.db\n        .query(\"story_content\")\n        .withIndex(\"by_story\", (q) => q.eq(\"storyId\", story._id))\n        .unique(),\n    ]);\n    if (!course || !storyContent) return null;\n\n    const [learningLanguage, fromLanguage, image] = await Promise.all([\n      ctx.db.get(course.learningLanguageId),\n      ctx.db.get(course.fromLanguageId),\n      story.imageId ? ctx.db.get(story.imageId) : Promise.resolve(null),\n    ]);\n    if (!learningLanguage || !fromLanguage) return null;\n\n    return {\n      isAdmin: identity?.role === \"admin\",\n      story_data: {\n        id: story.legacyId ?? 0,\n        official: course.official,\n        course_id: course.legacyId,\n        duo_id: story.duo_id ?? \"\",\n        image: image?.legacyId ?? \"\",\n        name: story.name,\n        set_id: story.set_id ?? 0,\n        set_index: story.set_index ?? 0,\n        text: storyContent.text,\n        short: course.short ?? \"\",\n        learning_language: learningLanguage.legacyId,\n        from_language: fromLanguage.legacyId,\n      },\n    };\n  },\n});\n\nexport const getEditorImageByLegacyId = query({\n  args: { legacyImageId: v.string() },\n  handler: async (ctx, args) => {\n    const image = await ctx.db\n      .query(\"images\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.legacyImageId))\n      .unique();\n    if (!image) return null;\n\n    return {\n      id: image.legacyId,\n      active: image.active,\n      gilded: image.gilded,\n      locked: image.locked,\n      active_lip: image.active_lip,\n      gilded_lip: image.gilded_lip,\n    };\n  },\n});\n\nexport const getEditorLanguageByLegacyId = query({\n  args: { legacyLanguageId: v.number() },\n  handler: async (ctx, args) => {\n    const language = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.legacyLanguageId))\n      .unique();\n    if (!language) return null;\n\n    return toLanguage(language);\n  },\n});\n"
  },
  {
    "path": "convex/editorSideEffects.ts",
    "content": "\"use node\";\n\nimport { Octokit } from \"@octokit/rest\";\nimport { PostHog } from \"posthog-node\";\nimport { internalAction } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nconst CONTENT_REPOSITORY = \"rgerum/unofficial-duolingo-stories-content\";\n\nlet octokitClient: Octokit | null = null;\n\nfunction toBase64Utf8(value: string) {\n  const bytes = new TextEncoder().encode(value);\n  let binary = \"\";\n  const chunkSize = 0x8000;\n  for (let i = 0; i < bytes.length; i += chunkSize) {\n    binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));\n  }\n  return btoa(binary);\n}\n\nfunction getOctokit() {\n  const token = process.env.GITHUB_REPO_TOKEN;\n  if (!token) return null;\n  if (!octokitClient) {\n    octokitClient = new Octokit({ auth: token });\n  }\n  return octokitClient;\n}\n\nasync function uploadWithDiffToGithub(args: {\n  repository: string;\n  content?: string;\n  path: string;\n  authorName: string;\n  authorEmail: string;\n  gitMessage: string;\n}) {\n  const octokit = getOctokit();\n  if (!octokit) return { ok: false as const, reason: \"missing_token\" as const };\n\n  const [owner, repo] = args.repository.split(\"/\");\n  let fileSha: string | undefined;\n\n  try {\n    const { data } = await octokit.repos.getContent({\n      owner,\n      repo,\n      path: args.path,\n    });\n    if (!Array.isArray(data) && \"sha\" in data) {\n      fileSha = data.sha;\n    }\n  } catch {\n    // Missing file is expected for first write.\n  }\n\n  const author = {\n    name: args.authorName,\n    email: args.authorEmail,\n  };\n\n  if (!args.content) {\n    if (!fileSha) return { ok: true as const };\n    await octokit.repos.deleteFile({\n      owner,\n      repo,\n      path: args.path,\n      message: args.gitMessage,\n      sha: fileSha,\n      committer: author,\n      author,\n    });\n    return { ok: true as const };\n  }\n\n  await octokit.repos.createOrUpdateFileContents({\n    owner,\n    repo,\n    path: args.path,\n    message: args.gitMessage,\n    content: toBase64Utf8(args.content),\n    sha: fileSha,\n    committer: author,\n    author,\n  });\n\n  return { ok: true as const };\n}\n\nfunction getPosthogClient() {\n  const apiKey = process.env.POSTHOG_KEY ?? process.env.NEXT_PUBLIC_POSTHOG_KEY;\n  if (!apiKey) return null;\n  return new PostHog(apiKey, {\n    host: process.env.POSTHOG_HOST ?? process.env.NEXT_PUBLIC_POSTHOG_HOST,\n    flushAt: 1,\n    flushInterval: 0,\n  });\n}\n\nexport const onStorySaved = internalAction({\n  args: {\n    operationKey: v.string(),\n    storyId: v.number(),\n    storyName: v.string(),\n    courseId: v.number(),\n    text: v.string(),\n    todoCount: v.number(),\n    actorName: v.string(),\n    actorLegacyUserId: v.number(),\n  },\n  returns: v.object({\n    githubUploaded: v.boolean(),\n    posthogTracked: v.boolean(),\n  }),\n  handler: async (_ctx, args) => {\n    let githubUploaded = false;\n    let posthogTracked = false;\n\n    const uploadResult = await uploadWithDiffToGithub({\n      repository: CONTENT_REPOSITORY,\n      content: args.text,\n      path: `${args.courseId}/${args.storyId}.txt`,\n      authorName: args.actorName,\n      authorEmail: `${args.actorName}@carex.uberspace.de`,\n      gitMessage: `updated ${args.storyName} in course ${args.courseId}`,\n    }).catch((error) => {\n      console.error(\"editorSideEffects:onStorySaved:github\", error);\n      return { ok: false as const };\n    });\n    githubUploaded = uploadResult.ok;\n\n    const posthog = getPosthogClient();\n    if (posthog) {\n      try {\n        posthog.capture({\n          distinctId: args.actorName || `user_${args.actorLegacyUserId}`,\n          event: \"story_saved\",\n          properties: {\n            story_id: args.storyId,\n            story_name: args.storyName,\n            course_id: args.courseId,\n            todo_count: args.todoCount,\n            editor_username: args.actorName,\n          },\n        });\n        await posthog.shutdown();\n        posthogTracked = true;\n      } catch (error) {\n        console.error(\"editorSideEffects:onStorySaved:posthog\", error);\n      }\n    }\n\n    return { githubUploaded, posthogTracked };\n  },\n});\n\nexport const onStoryDeleted = internalAction({\n  args: {\n    operationKey: v.string(),\n    storyId: v.number(),\n    storyName: v.string(),\n    courseId: v.number(),\n    actorName: v.string(),\n    actorLegacyUserId: v.number(),\n  },\n  returns: v.object({\n    githubUploaded: v.boolean(),\n    posthogTracked: v.boolean(),\n  }),\n  handler: async (_ctx, args) => {\n    let githubUploaded = false;\n    let posthogTracked = false;\n\n    const uploadResult = await uploadWithDiffToGithub({\n      repository: CONTENT_REPOSITORY,\n      path: `${args.courseId}/${args.storyId}.txt`,\n      authorName: args.actorName,\n      authorEmail: `${args.actorName}@carex.uberspace.de`,\n      gitMessage: `delete ${args.storyName} from course ${args.courseId}`,\n    }).catch((error) => {\n      console.error(\"editorSideEffects:onStoryDeleted:github\", error);\n      return { ok: false as const };\n    });\n    githubUploaded = uploadResult.ok;\n\n    const posthog = getPosthogClient();\n    if (posthog) {\n      try {\n        posthog.capture({\n          distinctId: args.actorName || `user_${args.actorLegacyUserId}`,\n          event: \"story_deleted\",\n          properties: {\n            story_id: args.storyId,\n            story_name: args.storyName,\n            course_id: args.courseId,\n            editor_username: args.actorName,\n          },\n        });\n        await posthog.shutdown();\n        posthogTracked = true;\n      } catch (error) {\n        console.error(\"editorSideEffects:onStoryDeleted:posthog\", error);\n      }\n    }\n\n    return { githubUploaded, posthogTracked };\n  },\n});\n\nexport const onStoryImported = internalAction({\n  args: {\n    operationKey: v.string(),\n    storyId: v.number(),\n    storyName: v.string(),\n    courseId: v.number(),\n    text: v.string(),\n    actorName: v.string(),\n    actorLegacyUserId: v.number(),\n  },\n  returns: v.object({\n    githubUploaded: v.boolean(),\n  }),\n  handler: async (_ctx, args) => {\n    const uploadResult = await uploadWithDiffToGithub({\n      repository: CONTENT_REPOSITORY,\n      content: args.text,\n      path: `${args.courseId}/${args.storyId}.txt`,\n      authorName: args.actorName,\n      authorEmail: `${args.actorName}@carex.uberspace.de`,\n      gitMessage: `added ${args.storyName} in course ${args.courseId}`,\n    }).catch((error) => {\n      console.error(\"editorSideEffects:onStoryImported:github\", error);\n      return { ok: false as const };\n    });\n\n    return { githubUploaded: uploadResult.ok };\n  },\n});\n\nexport const onStoryApprovalToggled = internalAction({\n  args: {\n    operationKey: v.string(),\n    storyId: v.number(),\n    action: v.union(v.literal(\"added\"), v.literal(\"deleted\")),\n    count: v.number(),\n    storyStatus: v.union(\n      v.literal(\"draft\"),\n      v.literal(\"feedback\"),\n      v.literal(\"finished\"),\n    ),\n    finishedInSet: v.number(),\n    publishedCount: v.number(),\n    actorName: v.string(),\n    actorLegacyUserId: v.number(),\n  },\n  returns: v.object({\n    posthogTracked: v.boolean(),\n  }),\n  handler: async (_ctx, args) => {\n    const posthog = getPosthogClient();\n    if (!posthog) return { posthogTracked: false };\n\n    try {\n      posthog.capture({\n        distinctId: args.actorName || `user_${args.actorLegacyUserId}`,\n        event: \"story_approved\",\n        properties: {\n          story_id: args.storyId,\n          action: args.action,\n          approval_count: args.count,\n          story_status: args.storyStatus,\n          finished_in_set: args.finishedInSet,\n          stories_published: args.publishedCount,\n        },\n      });\n      await posthog.shutdown();\n      return { posthogTracked: true };\n    } catch (error) {\n      console.error(\"editorSideEffects:onStoryApprovalToggled:posthog\", error);\n      return { posthogTracked: false };\n    }\n  },\n});\n"
  },
  {
    "path": "convex/http.ts",
    "content": "import { httpRouter } from \"convex/server\";\nimport { backfillCourseContributorDetailsHttp } from \"./courseContributorBackfill\";\nimport {\n  getDiscordCombineData,\n  setStoriesRoleSyncStatus,\n  setContributorWriteByDiscordAccountId,\n} from \"./discordBot\";\nimport { authComponent, createAuth } from \"./betterAuth/auth\";\nimport { backfillDiscordUserImagesHttp as backfillDiscordUserImagesHandler } from \"./discordAvatarSync\";\n\nconst http = httpRouter();\n\nauthComponent.registerRoutes(http, createAuth);\nhttp.route({\n  path: \"/discord/set-contributor-write\",\n  method: \"POST\",\n  handler: setContributorWriteByDiscordAccountId,\n});\nhttp.route({\n  path: \"/discord/combine-data\",\n  method: \"POST\",\n  handler: getDiscordCombineData,\n});\nhttp.route({\n  path: \"/discord/set-stories-role-status\",\n  method: \"POST\",\n  handler: setStoriesRoleSyncStatus,\n});\nhttp.route({\n  path: \"/admin/backfill-discord-avatars\",\n  method: \"POST\",\n  handler: backfillDiscordUserImagesHandler,\n});\nhttp.route({\n  path: \"/admin/backfill-course-contributors\",\n  method: \"POST\",\n  handler: backfillCourseContributorDetailsHttp,\n});\n\nexport default http;\n"
  },
  {
    "path": "convex/landing.ts",
    "content": "import { query } from \"./_generated/server\";\nimport type { Id } from \"./_generated/dataModel\";\nimport { v } from \"convex/values\";\nimport { courseContributorValidator } from \"./lib/courseContributors\";\n\nconst courseListItemValidator = v.object({\n  id: v.number(),\n  short: v.string(),\n  name: v.string(),\n  count: v.number(),\n  about: v.string(),\n  tags: v.array(v.string()),\n  from_language: v.number(),\n  fromLanguageId: v.id(\"languages\"),\n  from_language_name: v.string(),\n  learning_language: v.number(),\n  learningLanguageId: v.id(\"languages\"),\n  learning_language_name: v.string(),\n});\n\nconst landingCourseItemValidator = v.object({\n  id: v.number(),\n  short: v.string(),\n  name: v.string(),\n  count: v.number(),\n  learningLanguage: v.object({\n    id: v.id(\"languages\"),\n    short: v.string(),\n    flag: v.optional(v.union(v.number(), v.string())),\n    flag_file: v.optional(v.string()),\n  }),\n});\n\nconst landingGroupValidator = v.object({\n  fromLanguageId: v.id(\"languages\"),\n  fromLanguageName: v.string(),\n  labels: v.object({\n    storiesFor: v.string(),\n    nStoriesTemplate: v.string(),\n  }),\n  courses: v.array(landingCourseItemValidator),\n});\n\nconst landingPageDataValidator = v.object({\n  stats: v.object({\n    courseCount: v.number(),\n    storyCount: v.number(),\n  }),\n  groups: v.array(landingGroupValidator),\n});\n\ntype LandingCourseItem = {\n  id: number;\n  short: string;\n  name: string;\n  count: number;\n  learningLanguage: {\n    id: Id<\"languages\">;\n    short: string;\n    flag: number | string | undefined;\n    flag_file: string | undefined;\n  };\n};\n\ntype LandingGroup = {\n  fromLanguageId: Id<\"languages\">;\n  fromLanguageName: string;\n  labels: {\n    storiesFor: string;\n    nStoriesTemplate: string;\n  };\n  courses: LandingCourseItem[];\n};\n\nexport const getPublicCourseList = query({\n  args: {},\n  returns: v.array(courseListItemValidator),\n  handler: async (ctx) => {\n    const courses = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_public\", (q) => q.eq(\"public\", true))\n      .collect();\n\n    const languageIds = new Set<Id<\"languages\">>();\n    for (const course of courses) {\n      languageIds.add(course.fromLanguageId);\n      languageIds.add(course.learningLanguageId);\n    }\n    const languageRows = await Promise.all(\n      Array.from(languageIds).map(async (languageId) => ({\n        languageId,\n        language: await ctx.db.get(languageId),\n      })),\n    );\n    const legacyLanguageIdByConvexId = new Map<Id<\"languages\">, number>();\n    const languageNameByConvexId = new Map<Id<\"languages\">, string>();\n    for (const row of languageRows) {\n      if (!row.language) continue;\n      legacyLanguageIdByConvexId.set(row.languageId, row.language.legacyId);\n      languageNameByConvexId.set(row.languageId, row.language.name);\n    }\n\n    return courses\n      .map((course) => {\n        if (!course.short) return null;\n\n        const fromLanguageName =\n          languageNameByConvexId.get(course.fromLanguageId) ?? \"\";\n        const learningLanguageName =\n          languageNameByConvexId.get(course.learningLanguageId) ?? \"\";\n\n        return {\n          id: course.legacyId,\n          short: course.short,\n          name:\n            course.name && course.name.trim().length > 0\n              ? course.name\n              : learningLanguageName,\n          count: course.count ?? 0,\n          about: course.about ?? \"\",\n          tags: course.tags ?? [],\n          from_language:\n            legacyLanguageIdByConvexId.get(course.fromLanguageId) ?? 0,\n          fromLanguageId: course.fromLanguageId as Id<\"languages\">,\n          from_language_name: fromLanguageName,\n          learning_language:\n            legacyLanguageIdByConvexId.get(course.learningLanguageId) ?? 0,\n          learningLanguageId: course.learningLanguageId as Id<\"languages\">,\n          learning_language_name: learningLanguageName,\n        };\n      })\n      .filter(\n        (\n          course,\n        ): course is {\n          id: number;\n          short: string;\n          name: string;\n          count: number;\n          about: string;\n          tags: string[];\n          from_language: number;\n          fromLanguageId: Id<\"languages\">;\n          from_language_name: string;\n          learning_language: number;\n          learningLanguageId: Id<\"languages\">;\n          learning_language_name: string;\n        } => course !== null,\n      )\n      .sort((a, b) => {\n        const fromCmp = a.from_language_name.localeCompare(\n          b.from_language_name,\n        );\n        if (fromCmp !== 0) return fromCmp;\n        return a.name.localeCompare(b.name);\n      });\n  },\n});\n\nexport const getPublicLandingPageData = query({\n  args: {},\n  returns: landingPageDataValidator,\n  handler: async (ctx) => {\n    const courses = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_public\", (q) => q.eq(\"public\", true))\n      .collect();\n\n    const englishLanguage = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_short\", (q) => q.eq(\"short\", \"en\"))\n      .unique();\n\n    const languageIds = new Set<Id<\"languages\">>();\n    for (const course of courses) {\n      languageIds.add(course.fromLanguageId);\n      languageIds.add(course.learningLanguageId);\n    }\n    if (englishLanguage?._id) {\n      languageIds.add(englishLanguage._id);\n    }\n\n    const languageRows = await Promise.all(\n      Array.from(languageIds).map(async (languageId) => ({\n        languageId,\n        language: await ctx.db.get(languageId),\n      })),\n    );\n    const languageById = new Map<\n      Id<\"languages\">,\n      (typeof languageRows)[number][\"language\"]\n    >();\n    for (const row of languageRows) {\n      if (!row.language) continue;\n      languageById.set(row.languageId, row.language);\n    }\n\n    const englishRows = englishLanguage\n      ? await ctx.db\n          .query(\"localizations\")\n          .withIndex(\"by_language_id_and_tag\", (q) =>\n            q.eq(\"languageId\", englishLanguage._id),\n          )\n          .collect()\n      : [];\n    const englishLocalization = new Map<string, string>();\n    for (const row of englishRows) {\n      if (!row.tag || !row.text) continue;\n      englishLocalization.set(row.tag, row.text);\n    }\n\n    const fromLanguageIds = Array.from(\n      new Set(courses.map((course) => course.fromLanguageId)),\n    );\n    const localizationByFromLanguageId = new Map<\n      Id<\"languages\">,\n      Map<string, string>\n    >();\n    await Promise.all(\n      fromLanguageIds.map(async (fromLanguageId) => {\n        const targetRows =\n          englishLanguage?._id === fromLanguageId\n            ? englishRows\n            : await ctx.db\n                .query(\"localizations\")\n                .withIndex(\"by_language_id_and_tag\", (q) =>\n                  q.eq(\"languageId\", fromLanguageId),\n                )\n                .collect();\n        const merged = new Map<string, string>(englishLocalization);\n        for (const row of targetRows) {\n          if (!row.tag || !row.text) continue;\n          merged.set(row.tag, row.text);\n        }\n        localizationByFromLanguageId.set(fromLanguageId, merged);\n      }),\n    );\n\n    const coursesByFromLanguageId = new Map<\n      Id<\"languages\">,\n      LandingCourseItem[]\n    >();\n    for (const course of courses) {\n      if (!course.short) continue;\n      const fromLanguage = languageById.get(course.fromLanguageId);\n      const learningLanguage = languageById.get(course.learningLanguageId);\n      if (!fromLanguage || !learningLanguage) continue;\n\n      const mappedCourse = {\n        id: course.legacyId,\n        short: course.short,\n        name:\n          course.name && course.name.trim().length > 0\n            ? course.name\n            : learningLanguage.name,\n        count: course.count ?? 0,\n        learningLanguage: {\n          id: course.learningLanguageId as Id<\"languages\">,\n          short: learningLanguage.short,\n          flag: learningLanguage.flag,\n          flag_file: learningLanguage.flag_file,\n        },\n      };\n\n      const list = coursesByFromLanguageId.get(course.fromLanguageId) ?? [];\n      list.push(mappedCourse);\n      coursesByFromLanguageId.set(course.fromLanguageId, list);\n    }\n\n    for (const [, groupCourses] of coursesByFromLanguageId) {\n      groupCourses.sort((a, b) => a.name.localeCompare(b.name));\n    }\n\n    const englishGroup = englishLanguage?._id\n      ? coursesByFromLanguageId.get(englishLanguage._id)\n        ? [englishLanguage._id]\n        : []\n      : [];\n    const otherGroupIds = Array.from(coursesByFromLanguageId.keys())\n      .filter((languageId) => languageId !== englishLanguage?._id)\n      .sort((a, b) => {\n        const nameA = languageById.get(a)?.name ?? \"\";\n        const nameB = languageById.get(b)?.name ?? \"\";\n        return nameA.localeCompare(nameB);\n      });\n    const orderedGroupIds = [...englishGroup, ...otherGroupIds];\n\n    const groups = orderedGroupIds\n      .map((fromLanguageId) => {\n        const fromLanguage = languageById.get(fromLanguageId);\n        const coursesInGroup =\n          coursesByFromLanguageId.get(fromLanguageId) ?? [];\n        if (!fromLanguage || coursesInGroup.length === 0) return null;\n        const localization = localizationByFromLanguageId.get(fromLanguageId);\n\n        return {\n          fromLanguageId,\n          fromLanguageName: fromLanguage.name,\n          labels: {\n            storiesFor: localization?.get(\"stories_for\") ?? \"Stories for\",\n            nStoriesTemplate:\n              localization?.get(\"n_stories\") ?? \"$count stories\",\n          },\n          courses: coursesInGroup,\n        };\n      })\n      .filter((group): group is LandingGroup => group !== null);\n\n    let storyCount = 0;\n    for (const group of groups) {\n      for (const course of group.courses) {\n        storyCount += course.count;\n      }\n    }\n\n    return {\n      stats: {\n        courseCount: groups.reduce(\n          (count, group) => count + group.courses.length,\n          0,\n        ),\n        storyCount,\n      },\n      groups,\n    };\n  },\n});\n\nconst publicStoryListItemValidator = v.object({\n  id: v.number(),\n  name: v.string(),\n  course_id: v.number(),\n  image: v.string(),\n  set_id: v.number(),\n  set_index: v.number(),\n  active: v.string(),\n  gilded: v.string(),\n  active_lip: v.string(),\n  gilded_lip: v.string(),\n});\n\nconst localizationEntryValidator = v.object({\n  tag: v.string(),\n  text: v.string(),\n});\n\nconst publicCoursePageValidator = v.union(\n  v.object({\n    ...courseListItemValidator.fields,\n    contributors: v.array(courseContributorValidator),\n    contributors_past: v.array(courseContributorValidator),\n    stories: v.array(publicStoryListItemValidator),\n    localization: v.array(localizationEntryValidator),\n  }),\n  v.null(),\n);\n\nexport const getPublicCoursePageData = query({\n  args: {\n    short: v.string(),\n  },\n  returns: publicCoursePageValidator,\n  handler: async (ctx, args) => {\n    const course = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_short\", (q) => q.eq(\"short\", args.short))\n      .unique();\n    if (!course || !course.public || !course.short) return null;\n\n    const languageRows = await Promise.all([\n      ctx.db.get(course.fromLanguageId),\n      ctx.db.get(course.learningLanguageId),\n    ]);\n    const fromLanguage = languageRows[0];\n    const learningLanguage = languageRows[1];\n    const legacyFromLanguageId = fromLanguage?.legacyId ?? 0;\n    const legacyLearningLanguageId = learningLanguage?.legacyId ?? 0;\n    const fromLanguageName = fromLanguage?.name ?? \"\";\n    const learningLanguageName = learningLanguage?.name ?? \"\";\n\n    const publicStories = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_course_public_deleted_set\", (q) =>\n        q.eq(\"courseId\", course._id).eq(\"public\", true).eq(\"deleted\", false),\n      )\n      .collect();\n\n    const imageIds = Array.from(\n      new Set(\n        publicStories\n          .map((story) => story.imageId)\n          .filter((imageId): imageId is Id<\"images\"> => !!imageId),\n      ),\n    );\n    const imageRows = await Promise.all(\n      imageIds.map(async (imageId) => ({\n        imageId,\n        image: await ctx.db.get(imageId),\n      })),\n    );\n    const imageById = new Map<\n      Id<\"images\">,\n      (typeof imageRows)[number][\"image\"]\n    >();\n    for (const row of imageRows) imageById.set(row.imageId, row.image);\n\n    const englishLanguage = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_short\", (q) => q.eq(\"short\", \"en\"))\n      .unique();\n    const englishRows = englishLanguage\n      ? await ctx.db\n          .query(\"localizations\")\n          .withIndex(\"by_language_id_and_tag\", (q) =>\n            q.eq(\"languageId\", englishLanguage._id),\n          )\n          .collect()\n      : [];\n    const targetRows =\n      !fromLanguage || fromLanguage._id === englishLanguage?._id\n        ? englishRows\n        : await ctx.db\n            .query(\"localizations\")\n            .withIndex(\"by_language_id_and_tag\", (q) =>\n              q.eq(\"languageId\", fromLanguage._id),\n            )\n            .collect();\n    const localizationMap = new Map<string, string>();\n    for (const row of englishRows) {\n      if (!row.tag || !row.text) continue;\n      localizationMap.set(row.tag, row.text);\n    }\n    for (const row of targetRows) {\n      if (!row.tag || !row.text) continue;\n      localizationMap.set(row.tag, row.text);\n    }\n\n    const mappedStories = publicStories\n      .map((story) => {\n        if (typeof story.legacyId !== \"number\") return null;\n        const image = story.imageId ? imageById.get(story.imageId) : null;\n        if (!image?.active || !image?.gilded) return null;\n        return {\n          id: story.legacyId,\n          name: story.name,\n          course_id: course.legacyId,\n          image: image.legacyId,\n          set_id: story.set_id ?? 0,\n          set_index: story.set_index ?? 0,\n          active: image.active,\n          gilded: image.gilded,\n          active_lip: image.active_lip,\n          gilded_lip: image.gilded_lip,\n        };\n      })\n      .filter(\n        (\n          story,\n        ): story is {\n          id: number;\n          name: string;\n          course_id: number;\n          image: string;\n          set_id: number;\n          set_index: number;\n          active: string;\n          gilded: string;\n          active_lip: string;\n          gilded_lip: string;\n        } => story !== null,\n      )\n      .sort((a, b) => {\n        const setCmp = a.set_id - b.set_id;\n        if (setCmp !== 0) return setCmp;\n        return a.set_index - b.set_index;\n      });\n    const contributorLists = {\n      contributors: course.contributorDetails ?? [],\n      contributors_past: course.contributorDetailsPast ?? [],\n    };\n\n    return {\n      id: course.legacyId,\n      short: course.short,\n      name:\n        course.name && course.name.trim().length > 0\n          ? course.name\n          : learningLanguageName,\n      count: course.count ?? 0,\n      about: course.about ?? \"\",\n      tags: course.tags ?? [],\n      from_language: legacyFromLanguageId,\n      fromLanguageId: course.fromLanguageId as Id<\"languages\">,\n      from_language_name: fromLanguageName,\n      learning_language: legacyLearningLanguageId,\n      learningLanguageId: course.learningLanguageId as Id<\"languages\">,\n      learning_language_name: learningLanguageName,\n      contributors: contributorLists.contributors,\n      contributors_past: contributorLists.contributors_past,\n      stories: mappedStories,\n      localization: Array.from(localizationMap.entries()).map(\n        ([tag, text]) => ({\n          tag,\n          text,\n        }),\n      ),\n    };\n  },\n});\n"
  },
  {
    "path": "convex/languageWrite.ts",
    "content": "import { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport type { MutationCtx } from \"./_generated/server\";\nimport { requireAdmin, requireContributorOrAdmin } from \"./lib/authorization\";\n\nfunction toLegacyLanguageResponse(row: {\n  legacyId: number;\n  name: string;\n  short: string;\n  flag?: number | string;\n  flag_file?: string;\n  speaker?: string;\n  default_text?: string;\n  tts_replace?: string;\n  public: boolean;\n  rtl: boolean;\n}) {\n  return {\n    id: row.legacyId,\n    name: row.name,\n    short: row.short,\n    flag:\n      typeof row.flag === \"number\"\n        ? row.flag\n        : Number.isFinite(Number(row.flag))\n          ? Number(row.flag)\n          : null,\n    flag_file: row.flag_file ?? null,\n    speaker: row.speaker ?? null,\n    default_text: row.default_text ?? \"\",\n    tts_replace: row.tts_replace ?? \"\",\n    public: row.public,\n    rtl: row.rtl,\n  };\n}\n\nasync function getLanguageByLegacyId(\n  ctx: MutationCtx,\n  legacyLanguageId: number,\n) {\n  return await ctx.db\n    .query(\"languages\")\n    .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", legacyLanguageId))\n    .unique();\n}\n\nasync function getLanguageByShort(ctx: MutationCtx, short?: string | null) {\n  if (!short) return null;\n  return await ctx.db\n    .query(\"languages\")\n    .withIndex(\"by_short\", (q) => q.eq(\"short\", short))\n    .unique();\n}\n\nasync function getAvatarByLegacyId(ctx: MutationCtx, legacyAvatarId: number) {\n  return await ctx.db\n    .query(\"avatars\")\n    .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", legacyAvatarId))\n    .unique();\n}\n\nexport const setDefaultText = mutation({\n  args: {\n    legacyLanguageId: v.number(),\n    default_text: v.string(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    id: v.number(),\n    name: v.string(),\n    short: v.string(),\n    flag: v.union(v.number(), v.null()),\n    flag_file: v.union(v.string(), v.null()),\n    speaker: v.union(v.string(), v.null()),\n    default_text: v.string(),\n    tts_replace: v.string(),\n    public: v.boolean(),\n    rtl: v.boolean(),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const language = await getLanguageByLegacyId(ctx, args.legacyLanguageId);\n\n    if (!language) {\n      throw new Error(`Language ${args.legacyLanguageId} not found`);\n    }\n\n    const operationKey =\n      args.operationKey ??\n      `language:${args.legacyLanguageId}:default_text:${Date.now()}`;\n\n    await ctx.db.patch(language._id, {\n      default_text: args.default_text,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: operationKey,\n    });\n\n    return toLegacyLanguageResponse({\n      ...language,\n      default_text: args.default_text,\n    });\n  },\n});\n\nexport const setTtsReplace = mutation({\n  args: {\n    legacyLanguageId: v.number(),\n    tts_replace: v.string(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    id: v.number(),\n    name: v.string(),\n    short: v.string(),\n    flag: v.union(v.number(), v.null()),\n    flag_file: v.union(v.string(), v.null()),\n    speaker: v.union(v.string(), v.null()),\n    default_text: v.string(),\n    tts_replace: v.string(),\n    public: v.boolean(),\n    rtl: v.boolean(),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const language = await getLanguageByLegacyId(ctx, args.legacyLanguageId);\n\n    if (!language) {\n      throw new Error(`Language ${args.legacyLanguageId} not found`);\n    }\n\n    const operationKey =\n      args.operationKey ??\n      `language:${args.legacyLanguageId}:tts_replace:${Date.now()}`;\n\n    await ctx.db.patch(language._id, {\n      tts_replace: args.tts_replace,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: operationKey,\n    });\n\n    return toLegacyLanguageResponse({\n      ...language,\n      tts_replace: args.tts_replace,\n    });\n  },\n});\n\nexport const setAvatarSpeaker = mutation({\n  args: {\n    legacyLanguageId: v.number(),\n    legacyAvatarId: v.number(),\n    name: v.string(),\n    speaker: v.string(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    id: v.union(v.number(), v.null()),\n    avatar_id: v.number(),\n    language_id: v.number(),\n    name: v.string(),\n    speaker: v.string(),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const [language, avatar] = await Promise.all([\n      getLanguageByLegacyId(ctx, args.legacyLanguageId),\n      getAvatarByLegacyId(ctx, args.legacyAvatarId),\n    ]);\n\n    if (!language) {\n      throw new Error(`Language ${args.legacyLanguageId} not found`);\n    }\n    if (!avatar) {\n      throw new Error(`Avatar ${args.legacyAvatarId} not found`);\n    }\n\n    const operationKey =\n      args.operationKey ??\n      `avatar_mapping:${args.legacyLanguageId}:${args.legacyAvatarId}:${Date.now()}`;\n\n    const existing = await ctx.db\n      .query(\"avatar_mappings\")\n      .withIndex(\"by_avatar_id_and_language_id\", (q) =>\n        q.eq(\"avatarId\", avatar._id).eq(\"languageId\", language._id),\n      )\n      .unique();\n\n    if (existing) {\n      await ctx.db.patch(existing._id, {\n        name: args.name,\n        speaker: args.speaker,\n        mirrorUpdatedAt: Date.now(),\n        lastOperationKey: operationKey,\n      });\n    } else {\n      await ctx.db.insert(\"avatar_mappings\", {\n        avatarId: avatar._id,\n        languageId: language._id,\n        name: args.name,\n        speaker: args.speaker,\n        mirrorUpdatedAt: Date.now(),\n        lastOperationKey: operationKey,\n      });\n    }\n\n    return {\n      id: existing?.legacyId ?? null,\n      avatar_id: args.legacyAvatarId,\n      language_id: args.legacyLanguageId,\n      name: args.name,\n      speaker: args.speaker,\n    };\n  },\n});\n\nexport const upsertSpeakerFromVoice = mutation({\n  args: {\n    localeShort: v.optional(v.string()),\n    languageShort: v.optional(v.string()),\n    speaker: v.string(),\n    gender: v.string(),\n    type: v.string(),\n    service: v.string(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.union(\n    v.null(),\n    v.object({\n      legacyLanguageId: v.number(),\n      speaker: v.string(),\n      gender: v.string(),\n      type: v.string(),\n      service: v.string(),\n    }),\n  ),\n  handler: async (ctx, args) => {\n    await requireAdmin(ctx);\n    const language =\n      (await getLanguageByShort(ctx, args.localeShort)) ??\n      (await getLanguageByShort(ctx, args.languageShort));\n\n    if (!language || language.legacyId === undefined) {\n      return null;\n    }\n\n    const existing = (\n      await ctx.db\n        .query(\"speakers\")\n        .withIndex(\"by_speaker\", (q) => q.eq(\"speaker\", args.speaker))\n        .collect()\n    )[0];\n\n    const operationKey =\n      args.operationKey ?? `speaker:${args.speaker}:upsert:${Date.now()}`;\n\n    if (existing) {\n      await ctx.db.patch(existing._id, {\n        languageId: language._id,\n        speaker: args.speaker,\n        gender: args.gender,\n        type: args.type,\n        service: args.service,\n        mirrorUpdatedAt: Date.now(),\n        lastOperationKey: operationKey,\n      });\n    } else {\n      await ctx.db.insert(\"speakers\", {\n        languageId: language._id,\n        speaker: args.speaker,\n        gender: args.gender,\n        type: args.type,\n        service: args.service,\n        mirrorUpdatedAt: Date.now(),\n        lastOperationKey: operationKey,\n      });\n    }\n\n    return {\n      legacyLanguageId: language.legacyId,\n      speaker: args.speaker,\n      gender: args.gender,\n      type: args.type,\n      service: args.service,\n    };\n  },\n});\n"
  },
  {
    "path": "convex/lib/authorization.ts",
    "content": "import type { MutationCtx, QueryCtx } from \"../_generated/server\";\n\ntype AuthCtx = MutationCtx | QueryCtx;\n\ntype RoleIdentity = {\n  userId?: string | number | null;\n  role?: string | null;\n} | null;\n\nasync function getIdentity(ctx: AuthCtx) {\n  return (await ctx.auth.getUserIdentity()) as RoleIdentity;\n}\n\nasync function getRole(ctx: AuthCtx) {\n  const identity = await getIdentity(ctx);\n  return identity?.role ?? null;\n}\n\nexport async function requireAdmin(ctx: AuthCtx) {\n  const role = await getRole(ctx);\n  if (role !== \"admin\") {\n    throw new Error(\"Unauthorized\");\n  }\n}\n\nexport async function requireContributorOrAdmin(ctx: AuthCtx) {\n  const role = await getRole(ctx);\n  if (role !== \"contributor\" && role !== \"admin\") {\n    throw new Error(\"Unauthorized\");\n  }\n}\n\nexport async function requireSessionLegacyUserId(ctx: AuthCtx) {\n  const identity = await getIdentity(ctx);\n  const rawUserId = identity?.userId;\n  const legacyUserId =\n    typeof rawUserId === \"number\"\n      ? rawUserId\n      : Number.parseInt(String(rawUserId ?? \"\"), 10);\n\n  if (\n    !Number.isFinite(legacyUserId) ||\n    legacyUserId <= 0 ||\n    !Number.isInteger(legacyUserId)\n  ) {\n    throw new Error(\"Unauthorized\");\n  }\n\n  return legacyUserId;\n}\n\nexport async function getSessionLegacyUserId(ctx: AuthCtx) {\n  const identity = await getIdentity(ctx);\n  const rawUserId = identity?.userId;\n  const legacyUserId =\n    typeof rawUserId === \"number\"\n      ? rawUserId\n      : Number.parseInt(String(rawUserId ?? \"\"), 10);\n  return Number.isFinite(legacyUserId) ? legacyUserId : null;\n}\n"
  },
  {
    "path": "convex/lib/courseContributors.ts",
    "content": "import { v } from \"convex/values\";\nimport { components } from \"../_generated/api\";\nimport type { Id } from \"../_generated/dataModel\";\nimport type { MutationCtx, QueryCtx } from \"../_generated/server\";\n\ntype ContributorCtx = QueryCtx | MutationCtx;\n\ntype AdapterWhere = Array<{\n  field: string;\n  operator?: \"eq\" | \"in\";\n  value: string | Array<string>;\n}>;\n\ntype AuthUserRow = {\n  _id?: string | null;\n  userId?: string | null;\n  name?: string | null;\n  image?: string | null;\n};\n\ntype AuthAccountRow = {\n  userId?: string | null;\n  providerId?: string | null;\n};\n\ntype PaginatedAdapterResponse<T> = {\n  page: T[];\n  isDone?: boolean;\n  continueCursor?: string | null;\n};\n\nexport const courseContributorValidator = v.object({\n  legacyUserId: v.number(),\n  name: v.string(),\n  image: v.union(v.string(), v.null()),\n  discordLinked: v.boolean(),\n});\n\nexport type CourseContributor = {\n  legacyUserId: number;\n  name: string;\n  image: string | null;\n  discordLinked: boolean;\n  latestDate: number;\n  active: boolean;\n};\n\nasync function findManyAll<T>(\n  ctx: ContributorCtx,\n  model: \"user\" | \"account\",\n  where: AdapterWhere,\n): Promise<T[]> {\n  let cursor: string | null = null;\n  const rows: T[] = [];\n\n  while (true) {\n    const page = (await ctx.runQuery(components.betterAuth.adapter.findMany, {\n      model,\n      where,\n      paginationOpts: { cursor, numItems: 200 },\n    })) as PaginatedAdapterResponse<T>;\n\n    rows.push(...page.page);\n    if (page.isDone) break;\n    cursor = page.continueCursor ?? null;\n    if (!cursor) break;\n  }\n\n  return rows;\n}\n\nasync function getUsersByLegacyId(\n  ctx: ContributorCtx,\n  legacyUserIds: number[],\n): Promise<\n  Map<number, { name: string; image: string | null; discordLinked: boolean }>\n> {\n  const uniqueLegacyIds = Array.from(\n    new Set(\n      legacyUserIds.filter((legacyUserId) => Number.isFinite(legacyUserId)),\n    ),\n  );\n  if (!uniqueLegacyIds.length) {\n    return new Map<\n      number,\n      { name: string; image: string | null; discordLinked: boolean }\n    >();\n  }\n\n  const users = await findManyAll<AuthUserRow>(ctx, \"user\", [\n    {\n      field: \"userId\",\n      operator: \"in\",\n      value: uniqueLegacyIds.map((legacyUserId) => String(legacyUserId)),\n    },\n  ]);\n\n  const authDocIds = users\n    .map((user) => user._id)\n    .filter((authDocId): authDocId is string => Boolean(authDocId));\n  const accounts =\n    authDocIds.length === 0\n      ? []\n      : await findManyAll<AuthAccountRow>(ctx, \"account\", [\n          { field: \"providerId\", operator: \"eq\", value: \"discord\" },\n          { field: \"userId\", operator: \"in\", value: authDocIds },\n        ]);\n\n  const discordLinkedAuthDocIds = new Set(\n    accounts\n      .map((account) => account.userId)\n      .filter((authDocId): authDocId is string => Boolean(authDocId)),\n  );\n\n  const map = new Map<\n    number,\n    { name: string; image: string | null; discordLinked: boolean }\n  >();\n  for (const user of users) {\n    const legacyUserId = Number.parseInt(user.userId ?? \"\", 10);\n    if (!Number.isFinite(legacyUserId)) continue;\n    map.set(legacyUserId, {\n      name: user.name?.trim() || `User ${legacyUserId}`,\n      image:\n        typeof user.image === \"string\" && user.image.length > 0\n          ? user.image\n          : null,\n      discordLinked:\n        typeof user._id === \"string\" && discordLinkedAuthDocIds.has(user._id),\n    });\n  }\n\n  return map;\n}\n\nexport async function getRankedCourseContributors(\n  ctx: ContributorCtx,\n  courseId: Id<\"courses\">,\n): Promise<CourseContributor[]> {\n  const courseStories = await ctx.db\n    .query(\"stories\")\n    .withIndex(\"by_course\", (q) => q.eq(\"courseId\", courseId))\n    .collect();\n\n  const latestApprovalByUser = new Map<number, number>();\n  for (const story of courseStories) {\n    const approvals = await ctx.db\n      .query(\"story_approval\")\n      .withIndex(\"by_story\", (q) => q.eq(\"storyId\", story._id))\n      .collect();\n    for (const approval of approvals) {\n      if (typeof approval.legacyUserId !== \"number\") continue;\n      const existing = latestApprovalByUser.get(approval.legacyUserId) ?? 0;\n      if (approval.date > existing) {\n        latestApprovalByUser.set(approval.legacyUserId, approval.date);\n      }\n    }\n  }\n\n  const usersByLegacyId = await getUsersByLegacyId(\n    ctx,\n    Array.from(latestApprovalByUser.keys()),\n  );\n  const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;\n\n  return Array.from(latestApprovalByUser.entries())\n    .map(([legacyUserId, latestDate]) => {\n      const user = usersByLegacyId.get(legacyUserId);\n      return {\n        legacyUserId,\n        name: user?.name ?? `User ${legacyUserId}`,\n        image: user?.image ?? null,\n        discordLinked: user?.discordLinked ?? false,\n        latestDate,\n        active: latestDate > cutoffMs,\n      };\n    })\n    .sort((a, b) => b.latestDate - a.latestDate);\n}\n\nexport function partitionCourseContributors(contributors: CourseContributor[]) {\n  return {\n    contributors: contributors\n      .filter((contributor) => contributor.active)\n      .map(({ legacyUserId, name, image, discordLinked }) => ({\n        legacyUserId,\n        name,\n        image,\n        discordLinked,\n      })),\n    contributors_past: contributors\n      .filter((contributor) => !contributor.active)\n      .map(({ legacyUserId, name, image, discordLinked }) => ({\n        legacyUserId,\n        name,\n        image,\n        discordLinked,\n      })),\n  };\n}\n"
  },
  {
    "path": "convex/lib/courseCounts.ts",
    "content": "import type { Id } from \"../_generated/dataModel\";\nimport type { MutationCtx } from \"../_generated/server\";\n\nexport async function recomputeCoursePublishedCount(\n  ctx: MutationCtx,\n  courseId: Id<\"courses\">,\n) {\n  const course = await ctx.db.get(courseId);\n  if (!course) {\n    throw new Error(`Course ${courseId} not found`);\n  }\n\n  const stories = await ctx.db\n    .query(\"stories\")\n    .withIndex(\"by_course\", (q) => q.eq(\"courseId\", courseId))\n    .collect();\n  const count = stories.filter(\n    (story) => story.public && !story.deleted,\n  ).length;\n\n  if (course.count !== count) {\n    await ctx.db.patch(courseId, { count });\n  }\n\n  return count;\n}\n"
  },
  {
    "path": "convex/lib/discordAvatarSync.ts",
    "content": "type DiscordAccountRecord = {\n  accountId?: string | null;\n  accessToken?: string | null;\n  refreshToken?: string | null;\n  accessTokenExpiresAt?: number | Date | null;\n  providerId?: string | null;\n  scope?: string | null;\n  userId?: string | null;\n};\n\ntype DiscordTokenResponse = {\n  access_token?: string;\n  refresh_token?: string;\n  expires_in?: number;\n  scope?: string;\n};\n\ntype DiscordUserResponse = {\n  id?: string;\n  avatar?: string | null;\n  discriminator?: string | null;\n};\n\nexport type DiscordAvatarSyncResult =\n  | {\n      ok: true;\n      imageUrl: string | null;\n      accessToken: string | null;\n      refreshToken: string | null;\n      accessTokenExpiresAt: number | null;\n      scope: string | null;\n    }\n  | {\n      ok: false;\n      reason: string;\n    };\n\nfunction getDiscordCredentials() {\n  const clientId =\n    process.env.DISCORD_CLIENT_ID ?? process.env.AUTH_DISCORD_CLIENT_ID ?? null;\n  const clientSecret =\n    process.env.DISCORD_CLIENT_SECRET ??\n    process.env.AUTH_DISCORD_CLIENT_SECRET ??\n    null;\n\n  if (!clientId || !clientSecret) {\n    return null;\n  }\n\n  return { clientId, clientSecret };\n}\n\nfunction getDiscordBotToken() {\n  return (\n    process.env.DISCORD_TOKEN ??\n    process.env.DISCORD_BOT_TOKEN ??\n    process.env.BOT_TOKEN ??\n    null\n  );\n}\n\nasync function fetchDiscordUserWithOAuth(\n  accessToken: string,\n): Promise<DiscordUserResponse | null> {\n  const response = await fetch(\"https://discord.com/api/v10/users/@me\", {\n    headers: {\n      Authorization: `Bearer ${accessToken}`,\n    },\n  });\n\n  if (response.status === 401) {\n    return null;\n  }\n\n  if (!response.ok) {\n    const text = await response.text();\n    throw new Error(`Discord user fetch failed: ${response.status} ${text}`);\n  }\n\n  return (await response.json()) as DiscordUserResponse;\n}\n\nasync function fetchDiscordUserById(\n  discordAccountId: string,\n  botToken: string,\n): Promise<DiscordUserResponse | null> {\n  const response = await fetch(\n    `https://discord.com/api/v10/users/${discordAccountId}`,\n    {\n      headers: {\n        Authorization: `Bot ${botToken}`,\n      },\n    },\n  );\n\n  if (response.status === 404) {\n    return null;\n  }\n\n  if (!response.ok) {\n    const text = await response.text();\n    throw new Error(\n      `Discord bot user fetch failed: ${response.status} ${text}`,\n    );\n  }\n\n  return (await response.json()) as DiscordUserResponse;\n}\n\nasync function refreshDiscordAccessToken(refreshToken: string) {\n  const credentials = getDiscordCredentials();\n  if (!credentials) {\n    throw new Error(\"Discord OAuth credentials are not configured\");\n  }\n\n  const body = new URLSearchParams({\n    grant_type: \"refresh_token\",\n    refresh_token: refreshToken,\n    client_id: credentials.clientId,\n    client_secret: credentials.clientSecret,\n  });\n\n  const response = await fetch(\"https://discord.com/api/v10/oauth2/token\", {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/x-www-form-urlencoded\",\n    },\n    body,\n  });\n\n  if (!response.ok) {\n    const text = await response.text();\n    throw new Error(`Discord token refresh failed: ${response.status} ${text}`);\n  }\n\n  return (await response.json()) as DiscordTokenResponse;\n}\n\nfunction buildDiscordAvatarUrl(user: DiscordUserResponse) {\n  if (!user.id) return null;\n  if (user.avatar) {\n    const ext = user.avatar.startsWith(\"a_\") ? \"gif\" : \"png\";\n    return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${ext}?size=128`;\n  }\n\n  const parsedDiscriminator =\n    typeof user.discriminator === \"string\"\n      ? Number.parseInt(user.discriminator, 10)\n      : Number.NaN;\n  const defaultAvatarIndex =\n    typeof user.discriminator === \"string\" &&\n    user.discriminator.length > 0 &&\n    user.discriminator !== \"0\" &&\n    Number.isFinite(parsedDiscriminator)\n      ? parsedDiscriminator % 5\n      : Number((BigInt(user.id) >> 22n) % 6n);\n  return `https://cdn.discordapp.com/embed/avatars/${defaultAvatarIndex}.png`;\n}\n\nasync function tryFetchDiscordUserWithBot(account: DiscordAccountRecord) {\n  const botToken = getDiscordBotToken();\n  if (!botToken || !account.accountId) {\n    return null;\n  }\n\n  return await fetchDiscordUserById(account.accountId, botToken);\n}\n\nexport async function syncDiscordAvatarFromAccount(\n  account: DiscordAccountRecord,\n): Promise<DiscordAvatarSyncResult> {\n  if (account.providerId !== \"discord\") {\n    return { ok: false, reason: \"not_discord\" };\n  }\n\n  let accessToken =\n    typeof account.accessToken === \"string\" && account.accessToken.length > 0\n      ? account.accessToken\n      : null;\n  let refreshToken =\n    typeof account.refreshToken === \"string\" && account.refreshToken.length > 0\n      ? account.refreshToken\n      : null;\n  let accessTokenExpiresAt =\n    typeof account.accessTokenExpiresAt === \"number\"\n      ? account.accessTokenExpiresAt\n      : account.accessTokenExpiresAt instanceof Date\n        ? account.accessTokenExpiresAt.getTime()\n        : null;\n  let scope =\n    typeof account.scope === \"string\" && account.scope.length > 0\n      ? account.scope\n      : null;\n\n  try {\n    const botUser = await tryFetchDiscordUserWithBot(account);\n    if (botUser) {\n      return {\n        ok: true,\n        imageUrl: buildDiscordAvatarUrl(botUser),\n        accessToken,\n        refreshToken,\n        accessTokenExpiresAt,\n        scope,\n      };\n    }\n  } catch {\n    // Fall back to the user's OAuth session if bot lookup is unavailable.\n  }\n\n  if (!accessToken && !refreshToken) {\n    return { ok: false, reason: \"missing_tokens\" };\n  }\n\n  let user =\n    accessToken !== null ? await fetchDiscordUserWithOAuth(accessToken) : null;\n\n  if (!user && refreshToken) {\n    const refreshed = await refreshDiscordAccessToken(refreshToken);\n    accessToken =\n      typeof refreshed.access_token === \"string\" &&\n      refreshed.access_token.length\n        ? refreshed.access_token\n        : null;\n    refreshToken =\n      typeof refreshed.refresh_token === \"string\" &&\n      refreshed.refresh_token.length\n        ? refreshed.refresh_token\n        : refreshToken;\n    accessTokenExpiresAt =\n      typeof refreshed.expires_in === \"number\"\n        ? Date.now() + refreshed.expires_in * 1000\n        : accessTokenExpiresAt;\n    scope =\n      typeof refreshed.scope === \"string\" && refreshed.scope.length > 0\n        ? refreshed.scope\n        : scope;\n\n    if (!accessToken) {\n      return { ok: false, reason: \"refresh_missing_access_token\" };\n    }\n\n    user = await fetchDiscordUserWithOAuth(accessToken);\n  }\n\n  if (!user) {\n    return { ok: false, reason: \"unable_to_fetch_profile\" };\n  }\n\n  return {\n    ok: true,\n    imageUrl: buildDiscordAvatarUrl(user),\n    accessToken,\n    refreshToken,\n    accessTokenExpiresAt,\n    scope,\n  };\n}\n"
  },
  {
    "path": "convex/lib/phpbb.ts",
    "content": "import md5Raw from \"js-md5\";\n\nconst encoder = new TextEncoder();\n\nfunction md5Hex(content: string): string {\n  return bytesToHex(md5Bytes(encoder.encode(content)));\n}\n\nfunction md5Bytes(input: Uint8Array): Uint8Array {\n  const md5 = md5Raw as unknown as {\n    array: (data: Uint8Array | string) => number[];\n  };\n  const bytes = md5.array(input);\n  return Uint8Array.from(bytes);\n}\n\nfunction bytesToHex(bytes: Uint8Array): string {\n  let result = \"\";\n  for (const b of bytes) {\n    result += b.toString(16).padStart(2, \"0\");\n  }\n  return result;\n}\n\nfunction concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {\n  const out = new Uint8Array(a.length + b.length);\n  out.set(a, 0);\n  out.set(b, a.length);\n  return out;\n}\n\nconst itoa64 =\n  \"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\";\n\nexport function phpbbHash(password: string): string {\n  const count = 6;\n  const randomBytes = new Uint8Array(count);\n  globalThis.crypto.getRandomValues(randomBytes);\n  const random = bytesToHex(randomBytes);\n  return hashCryptPrivate(password, hashGensaltPrivate(random));\n}\n\nfunction hashCryptPrivate(password: string, setting: string): string {\n  let output = \"*\";\n\n  if (setting.substring(0, 3) !== \"$H$\" && setting.substring(0, 3) !== \"$P$\") {\n    return output;\n  }\n\n  const countLog2 = itoa64.indexOf(setting[3]);\n  if (countLog2 < 7 || countLog2 > 30) {\n    return output;\n  }\n\n  let count = 1 << countLog2;\n  const salt = setting.substring(4, 12);\n  if (salt.length !== 8) {\n    return output;\n  }\n\n  let hash = md5Bytes(\n    concatBytes(encoder.encode(salt), encoder.encode(password)),\n  );\n\n  do {\n    hash = md5Bytes(concatBytes(hash, encoder.encode(password)));\n  } while (--count);\n\n  output = setting.substring(0, 12) + hashEncode64(hash, 16);\n\n  return output;\n}\n\nfunction hashEncode64(input: Uint8Array | string, count: number): string {\n  let output = \"\";\n  let i = 0;\n\n  const getByte = (idx: number): number => {\n    const val = input[idx];\n    return typeof val === \"string\" ? val.charCodeAt(0) : val;\n  };\n\n  do {\n    let value = getByte(i++);\n    output += itoa64[value & 0x3f];\n\n    if (i < count) {\n      value |= getByte(i) << 8;\n    }\n\n    output += itoa64[(value >> 6) & 0x3f];\n\n    if (i++ >= count) {\n      break;\n    }\n\n    if (i < count) {\n      value |= getByte(i) << 16;\n    }\n\n    output += itoa64[(value >> 12) & 0x3f];\n\n    if (i++ >= count) {\n      break;\n    }\n\n    output += itoa64[(value >> 18) & 0x3f];\n  } while (i < count);\n\n  return output;\n}\n\nfunction hashGensaltPrivate(\n  input: string,\n  iterationCountLog2: number = 6,\n): string {\n  let output = \"$H$\";\n  output += itoa64[Math.min(iterationCountLog2 + 5, 30)];\n  output += hashEncode64(input, 6);\n\n  return output;\n}\n\nexport function phpbbCheckHash(password: string, hash: string): boolean {\n  if (hash.length === 34) {\n    return hashCryptPrivate(password, hash) === hash;\n  }\n\n  return md5Hex(password) === hash;\n}\n"
  },
  {
    "path": "convex/lib/publicStoryContent.ts",
    "content": "import type { Id } from \"../_generated/dataModel\";\nimport type { MutationCtx, QueryCtx } from \"../_generated/server\";\n\ntype RecordValue = Record<string, unknown>;\n\nfunction isRecord(value: unknown): value is RecordValue {\n  return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction sanitizeConvexValue(value: unknown): unknown {\n  if (value === undefined) return undefined;\n  if (Array.isArray(value)) {\n    return value\n      .map((item) => sanitizeConvexValue(item))\n      .filter((item) => item !== undefined);\n  }\n  if (!isRecord(value)) return value;\n\n  const result: RecordValue = {};\n  for (const [key, item] of Object.entries(value)) {\n    const next = sanitizeConvexValue(item);\n    if (next !== undefined) result[key] = next;\n  }\n  return result;\n}\n\nfunction compactAudio(value: unknown): unknown {\n  if (!isRecord(value)) return undefined;\n\n  const audio: RecordValue = {};\n  if (typeof value.url === \"string\" && value.url.length > 0) {\n    audio.url = value.url;\n  }\n  if (Array.isArray(value.keypoints) && value.keypoints.length > 0) {\n    audio.keypoints = sanitizeConvexValue(value.keypoints);\n  }\n\n  return Object.keys(audio).length > 0 ? audio : undefined;\n}\n\nfunction compactStoryValue(\n  value: unknown,\n  path: readonly string[] = [],\n): unknown {\n  if (Array.isArray(value)) {\n    return value\n      .map((item, index) => compactStoryValue(item, [...path, String(index)]))\n      .filter((item) => item !== undefined);\n  }\n  if (!isRecord(value)) return value;\n\n  const isTopLevelElement =\n    path.length === 2 && path[0] === \"elements\" && /^\\d+$/.test(path[1] ?? \"\");\n  const result: RecordValue = {};\n\n  for (const [key, item] of Object.entries(value)) {\n    if (key === \"editor\") continue;\n    if (key === \"audio\") {\n      if (isTopLevelElement) continue;\n      const audio = compactAudio(item);\n      if (audio !== undefined) result.audio = audio;\n      continue;\n    }\n    if (key === \"ssml\") continue;\n\n    const next = compactStoryValue(item, [...path, key]);\n    if (next !== undefined) result[key] = next;\n  }\n\n  return result;\n}\n\nexport function toPublicStoryJson(json: unknown): unknown {\n  return sanitizeConvexValue(compactStoryValue(json));\n}\n\nexport async function upsertPublicStoryContent(\n  ctx: MutationCtx,\n  storyId: Id<\"stories\">,\n  json: unknown,\n  lastUpdated: number,\n) {\n  const publicJson = toPublicStoryJson(json);\n  const existing = await ctx.db\n    .query(\"story_public_content\")\n    .withIndex(\"by_story\", (q) => q.eq(\"storyId\", storyId))\n    .unique();\n\n  if (existing) {\n    await ctx.db.patch(existing._id, {\n      json: publicJson,\n      lastUpdated,\n    });\n    return existing._id;\n  }\n\n  return await ctx.db.insert(\"story_public_content\", {\n    storyId,\n    json: publicJson,\n    lastUpdated,\n  });\n}\n\nexport async function getPublicStoryJson(\n  ctx: QueryCtx,\n  storyId: Id<\"stories\">,\n) {\n  const publicContent = await ctx.db\n    .query(\"story_public_content\")\n    .withIndex(\"by_story\", (q) => q.eq(\"storyId\", storyId))\n    .unique();\n\n  if (publicContent) return publicContent.json;\n\n  const legacyContent = await ctx.db\n    .query(\"story_content\")\n    .withIndex(\"by_story\", (q) => q.eq(\"storyId\", storyId))\n    .unique();\n\n  return legacyContent?.json ?? null;\n}\n"
  },
  {
    "path": "convex/localization.ts",
    "content": "import { query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\nconst localizationEntryValidator = v.object({\n  tag: v.string(),\n  text: v.string(),\n});\n\nexport const getLocalizationWithEnglishFallback = query({\n  args: {\n    languageId: v.id(\"languages\"),\n  },\n  returns: v.array(localizationEntryValidator),\n  handler: async (ctx, args) => {\n    const english = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_short\", (q) => q.eq(\"short\", \"en\"))\n      .unique();\n\n    const englishRows = english\n      ? await ctx.db\n          .query(\"localizations\")\n          .withIndex(\"by_language_id_and_tag\", (q) =>\n            q.eq(\"languageId\", english._id),\n          )\n          .collect()\n      : [];\n\n    const targetRows =\n      english?._id === args.languageId\n        ? englishRows\n        : await ctx.db\n            .query(\"localizations\")\n            .withIndex(\"by_language_id_and_tag\", (q) =>\n              q.eq(\"languageId\", args.languageId),\n            )\n            .collect();\n\n    const merged = new Map<string, string>();\n    for (const row of englishRows) {\n      if (!row.tag || !row.text) continue;\n      merged.set(row.tag, row.text);\n    }\n    for (const row of targetRows) {\n      if (!row.tag || !row.text) continue;\n      merged.set(row.tag, row.text);\n    }\n\n    return Array.from(merged.entries()).map(([tag, text]) => ({ tag, text }));\n  },\n});\n\nexport const getLocalizationByLegacyLanguageId = query({\n  args: {\n    legacyLanguageId: v.number(),\n  },\n  returns: v.array(localizationEntryValidator),\n  handler: async (ctx, args) => {\n    const language = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.legacyLanguageId))\n      .unique();\n    if (!language) return [];\n\n    const rows = await ctx.db\n      .query(\"localizations\")\n      .withIndex(\"by_language_id_and_tag\", (q) =>\n        q.eq(\"languageId\", language._id),\n      )\n      .collect();\n    return rows\n      .filter((row) => Boolean(row.tag) && Boolean(row.text))\n      .map((row) => ({ tag: row.tag, text: row.text }));\n  },\n});\n\nconst languageFlagValidator = v.object({\n  languageId: v.id(\"languages\"),\n  short: v.string(),\n  flag: v.optional(v.number()),\n  flag_file: v.optional(v.string()),\n});\n\nexport const getAllLanguageFlags = query({\n  args: {},\n  returns: v.array(languageFlagValidator),\n  handler: async (ctx) => {\n    const languages = await ctx.db.query(\"languages\").collect();\n    return languages.map((language) => {\n      const numericFlag =\n        typeof language.flag === \"number\"\n          ? language.flag\n          : Number.isFinite(Number(language.flag))\n            ? Number(language.flag)\n            : undefined;\n\n      return {\n        languageId: language._id,\n        short: language.short,\n        flag: numericFlag,\n        flag_file: language.flag_file,\n      };\n    });\n  },\n});\n\nexport const getLanguageFlagByLegacyId = query({\n  args: {\n    legacyLanguageId: v.number(),\n  },\n  returns: v.union(\n    v.object({\n      short: v.string(),\n      flag: v.optional(v.union(v.number(), v.string())),\n      flag_file: v.optional(v.string()),\n    }),\n    v.null(),\n  ),\n  handler: async (ctx, args) => {\n    const language = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.legacyLanguageId))\n      .unique();\n    if (!language) return null;\n\n    const numericFlag =\n      typeof language.flag === \"number\"\n        ? language.flag\n        : Number.isFinite(Number(language.flag))\n          ? Number(language.flag)\n          : undefined;\n\n    return {\n      short: language.short,\n      flag: numericFlag,\n      flag_file: language.flag_file,\n    };\n  },\n});\n"
  },
  {
    "path": "convex/localizationWrite.ts",
    "content": "import { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { requireContributorOrAdmin } from \"./lib/authorization\";\n\nexport const setLocalization = mutation({\n  args: {\n    legacyLanguageId: v.number(),\n    tag: v.string(),\n    text: v.string(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    id: v.union(v.number(), v.null()),\n    language_id: v.number(),\n    tag: v.string(),\n    text: v.string(),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const language = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.legacyLanguageId))\n      .unique();\n\n    if (!language) {\n      throw new Error(`Language ${args.legacyLanguageId} not found`);\n    }\n\n    const operationKey =\n      args.operationKey ??\n      `localization:${args.legacyLanguageId}:${args.tag}:${Date.now()}`;\n\n    const existing = await ctx.db\n      .query(\"localizations\")\n      .withIndex(\"by_language_id_and_tag\", (q) =>\n        q.eq(\"languageId\", language._id).eq(\"tag\", args.tag),\n      )\n      .unique();\n\n    if (existing) {\n      await ctx.db.patch(existing._id, {\n        text: args.text,\n        mirrorUpdatedAt: Date.now(),\n        lastOperationKey: operationKey,\n      });\n    } else {\n      await ctx.db.insert(\"localizations\", {\n        languageId: language._id,\n        tag: args.tag,\n        text: args.text,\n        mirrorUpdatedAt: Date.now(),\n        lastOperationKey: operationKey,\n      });\n    }\n\n    return {\n      id: existing?.legacyId ?? null,\n      language_id: args.legacyLanguageId,\n      tag: args.tag,\n      text: args.text,\n    };\n  },\n});\n"
  },
  {
    "path": "convex/lookupTables.ts",
    "content": "import { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { requireContributorOrAdmin } from \"./lib/authorization\";\n\nconst languageValidator = {\n  legacyId: v.number(),\n  name: v.string(),\n  short: v.string(),\n  flag: v.optional(v.number()),\n  flag_file: v.optional(v.string()),\n  speaker: v.optional(v.string()),\n  default_text: v.optional(v.string()),\n  tts_replace: v.optional(v.string()),\n  public: v.boolean(),\n  rtl: v.boolean(),\n};\n\nconst imageValidator = {\n  legacyId: v.string(),\n  active: v.string(),\n  gilded: v.string(),\n  locked: v.string(),\n  active_lip: v.string(),\n  gilded_lip: v.string(),\n};\n\nconst avatarValidator = {\n  legacyId: v.number(),\n  link: v.string(),\n  name: v.optional(v.string()),\n};\n\nconst speakerValidator = {\n  legacyId: v.optional(v.number()),\n  legacyLanguageId: v.number(),\n  speaker: v.string(),\n  gender: v.string(),\n  type: v.string(),\n  service: v.string(),\n};\n\nconst localizationValidator = {\n  legacyId: v.optional(v.number()),\n  legacyLanguageId: v.number(),\n  tag: v.string(),\n  text: v.string(),\n};\n\nconst courseValidator = {\n  legacyId: v.number(),\n  short: v.optional(v.string()),\n  legacyLearningLanguageId: v.number(),\n  legacyFromLanguageId: v.number(),\n  public: v.boolean(),\n  official: v.boolean(),\n  name: v.optional(v.string()),\n  about: v.optional(v.string()),\n  conlang: v.optional(v.boolean()),\n  tags: v.optional(v.array(v.string())),\n  count: v.optional(v.number()),\n  // Legacy denormalized fields kept only for Postgres-compat migration.\n  // TODO(postgres-sunset): drop from mirror payload and schema.\n  learning_language_name: v.optional(v.string()),\n  from_language_name: v.optional(v.string()),\n  contributors: v.optional(v.array(v.string())),\n  contributors_past: v.optional(v.array(v.string())),\n  todo_count: v.optional(v.number()),\n};\n\nconst avatarMappingValidator = {\n  legacyId: v.optional(v.number()),\n  legacyAvatarId: v.number(),\n  legacyLanguageId: v.number(),\n  name: v.optional(v.string()),\n  speaker: v.optional(v.string()),\n};\n\nexport const upsertLanguage = mutation({\n  args: {\n    language: v.object(languageValidator),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    inserted: v.boolean(),\n    docId: v.id(\"languages\"),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const existing = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.language.legacyId))\n      .unique();\n\n    const doc = {\n      ...args.language,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: args.operationKey,\n    };\n\n    if (existing) {\n      await ctx.db.replace(existing._id, doc);\n      return { inserted: false, docId: existing._id };\n    }\n\n    const docId = await ctx.db.insert(\"languages\", doc);\n    return { inserted: true, docId };\n  },\n});\n\nexport const upsertImage = mutation({\n  args: {\n    image: v.object(imageValidator),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    inserted: v.boolean(),\n    docId: v.id(\"images\"),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const existing = await ctx.db\n      .query(\"images\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.image.legacyId))\n      .unique();\n\n    const doc = {\n      ...args.image,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: args.operationKey,\n    };\n\n    if (existing) {\n      await ctx.db.replace(existing._id, doc);\n      return { inserted: false, docId: existing._id };\n    }\n\n    const docId = await ctx.db.insert(\"images\", doc);\n    return { inserted: true, docId };\n  },\n});\n\nexport const upsertAvatar = mutation({\n  args: {\n    avatar: v.object(avatarValidator),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    inserted: v.boolean(),\n    docId: v.id(\"avatars\"),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const existing = await ctx.db\n      .query(\"avatars\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.avatar.legacyId))\n      .unique();\n\n    const doc = {\n      ...args.avatar,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: args.operationKey,\n    };\n\n    if (existing) {\n      await ctx.db.replace(existing._id, doc);\n      return { inserted: false, docId: existing._id };\n    }\n\n    const docId = await ctx.db.insert(\"avatars\", doc);\n    return { inserted: true, docId };\n  },\n});\n\nexport const upsertSpeaker = mutation({\n  args: {\n    speaker: v.object(speakerValidator),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    inserted: v.boolean(),\n    docId: v.id(\"speakers\"),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const language = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) =>\n        q.eq(\"legacyId\", args.speaker.legacyLanguageId),\n      )\n      .unique();\n    if (!language) {\n      throw new Error(\n        `Missing language for legacy id ${args.speaker.legacyLanguageId}`,\n      );\n    }\n\n    const existing = await ctx.db\n      .query(\"speakers\")\n      .withIndex(\"by_speaker\", (q) => q.eq(\"speaker\", args.speaker.speaker))\n      .unique();\n\n    const doc = {\n      legacyId: args.speaker.legacyId,\n      languageId: language._id,\n      speaker: args.speaker.speaker,\n      gender: args.speaker.gender,\n      type: args.speaker.type,\n      service: args.speaker.service,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: args.operationKey,\n    };\n\n    if (existing) {\n      await ctx.db.replace(existing._id, doc);\n      return { inserted: false, docId: existing._id };\n    }\n\n    const docId = await ctx.db.insert(\"speakers\", doc);\n    return { inserted: true, docId };\n  },\n});\n\nexport const upsertLocalization = mutation({\n  args: {\n    localization: v.object(localizationValidator),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    inserted: v.boolean(),\n    docId: v.id(\"localizations\"),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const language = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) =>\n        q.eq(\"legacyId\", args.localization.legacyLanguageId),\n      )\n      .unique();\n    if (!language) {\n      throw new Error(\n        `Missing language for legacy id ${args.localization.legacyLanguageId}`,\n      );\n    }\n\n    const existing = await ctx.db\n      .query(\"localizations\")\n      .withIndex(\"by_language_id_and_tag\", (q) =>\n        q.eq(\"languageId\", language._id).eq(\"tag\", args.localization.tag),\n      )\n      .unique();\n\n    const doc = {\n      legacyId: args.localization.legacyId,\n      languageId: language._id,\n      tag: args.localization.tag,\n      text: args.localization.text,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: args.operationKey,\n    };\n\n    if (existing) {\n      await ctx.db.replace(existing._id, doc);\n      return { inserted: false, docId: existing._id };\n    }\n\n    const docId = await ctx.db.insert(\"localizations\", doc);\n    return { inserted: true, docId };\n  },\n});\n\nexport const upsertCourse = mutation({\n  args: {\n    course: v.object(courseValidator),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    inserted: v.boolean(),\n    docId: v.id(\"courses\"),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const learningLanguage = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) =>\n        q.eq(\"legacyId\", args.course.legacyLearningLanguageId),\n      )\n      .unique();\n    if (!learningLanguage) {\n      throw new Error(\n        `Missing learning language for legacy id ${args.course.legacyLearningLanguageId}`,\n      );\n    }\n    const fromLanguage = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) =>\n        q.eq(\"legacyId\", args.course.legacyFromLanguageId),\n      )\n      .unique();\n    if (!fromLanguage) {\n      throw new Error(\n        `Missing from language for legacy id ${args.course.legacyFromLanguageId}`,\n      );\n    }\n\n    const existing = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.course.legacyId))\n      .unique();\n\n    const doc = {\n      legacyId: args.course.legacyId,\n      short: args.course.short,\n      learningLanguageId: learningLanguage._id,\n      fromLanguageId: fromLanguage._id,\n      public: args.course.public,\n      official: args.course.official,\n      name: args.course.name,\n      about: args.course.about,\n      conlang: args.course.conlang,\n      tags: args.course.tags,\n      count: args.course.count,\n      learning_language_name: args.course.learning_language_name,\n      from_language_name: args.course.from_language_name,\n      contributors: args.course.contributors,\n      contributors_past: args.course.contributors_past,\n      todo_count: args.course.todo_count,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: args.operationKey,\n    };\n\n    if (existing) {\n      await ctx.db.replace(existing._id, doc);\n      return { inserted: false, docId: existing._id };\n    }\n\n    const docId = await ctx.db.insert(\"courses\", doc);\n    return { inserted: true, docId };\n  },\n});\n\nexport const upsertAvatarMapping = mutation({\n  args: {\n    avatarMapping: v.object(avatarMappingValidator),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    inserted: v.boolean(),\n    docId: v.id(\"avatar_mappings\"),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const avatar = await ctx.db\n      .query(\"avatars\")\n      .withIndex(\"by_id_value\", (q) =>\n        q.eq(\"legacyId\", args.avatarMapping.legacyAvatarId),\n      )\n      .unique();\n    if (!avatar) {\n      throw new Error(\n        `Missing avatar for legacy id ${args.avatarMapping.legacyAvatarId}`,\n      );\n    }\n    const language = await ctx.db\n      .query(\"languages\")\n      .withIndex(\"by_id_value\", (q) =>\n        q.eq(\"legacyId\", args.avatarMapping.legacyLanguageId),\n      )\n      .unique();\n    if (!language) {\n      throw new Error(\n        `Missing language for legacy id ${args.avatarMapping.legacyLanguageId}`,\n      );\n    }\n\n    const existing = await ctx.db\n      .query(\"avatar_mappings\")\n      .withIndex(\"by_avatar_id_and_language_id\", (q) =>\n        q.eq(\"avatarId\", avatar._id).eq(\"languageId\", language._id),\n      )\n      .unique();\n\n    const doc = {\n      legacyId: args.avatarMapping.legacyId,\n      avatarId: avatar._id,\n      languageId: language._id,\n      name: args.avatarMapping.name,\n      speaker: args.avatarMapping.speaker,\n      mirrorUpdatedAt: Date.now(),\n      lastOperationKey: args.operationKey,\n    };\n\n    if (existing) {\n      await ctx.db.replace(existing._id, doc);\n      return { inserted: false, docId: existing._id };\n    }\n\n    const docId = await ctx.db.insert(\"avatar_mappings\", doc);\n    return { inserted: true, docId };\n  },\n});\n"
  },
  {
    "path": "convex/roles.ts",
    "content": "import { action } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { components } from \"./_generated/api\";\n\nexport const setBetterAuthRolesBatch = action({\n  args: {\n    users: v.array(\n      v.object({\n        email: v.string(),\n        role: v.string(),\n      }),\n    ),\n  },\n  handler: async (ctx, args) => {\n    let updated = 0;\n    let skippedMissing = 0;\n    let skippedSame = 0;\n    const errors: Array<{ email: string; message: string }> = [];\n\n    for (const user of args.users) {\n      const email = user.email.toLowerCase();\n      try {\n        const authUser = await ctx.runQuery(\n          components.betterAuth.adapter.findOne,\n          {\n            model: \"user\",\n            where: [{ field: \"email\", value: email }],\n          },\n        );\n\n        if (!authUser?._id) {\n          skippedMissing += 1;\n          continue;\n        }\n\n        const currentRole = Array.isArray(authUser.role)\n          ? authUser.role[0]\n          : authUser.role;\n\n        if (currentRole === user.role) {\n          skippedSame += 1;\n          continue;\n        }\n\n        await ctx.runMutation(components.betterAuth.adapter.updateOne, {\n          input: {\n            model: \"user\",\n            where: [{ field: \"_id\", value: authUser._id }],\n            update: { role: user.role },\n          },\n        });\n\n        updated += 1;\n      } catch (error: unknown) {\n        const message = error instanceof Error ? error.message : String(error);\n        errors.push({\n          email,\n          message,\n        });\n      }\n    }\n\n    return { updated, skippedMissing, skippedSame, errors };\n  },\n});\n"
  },
  {
    "path": "convex/schema.ts",
    "content": "import { defineSchema, defineTable } from \"convex/server\";\nimport { v } from \"convex/values\";\n\nconst courseContributorDetailsValidator = v.object({\n  legacyUserId: v.number(),\n  name: v.string(),\n  image: v.union(v.string(), v.null()),\n  discordLinked: v.boolean(),\n});\n\nexport default defineSchema({\n  languages: defineTable({\n    legacyId: v.number(),\n    name: v.string(),\n    short: v.string(),\n    flag: v.optional(v.union(v.number(), v.string())),\n    flag_file: v.optional(v.string()),\n    speaker: v.optional(v.string()),\n    default_text: v.optional(v.string()),\n    tts_replace: v.optional(v.string()),\n    public: v.boolean(),\n    rtl: v.boolean(),\n    mirrorUpdatedAt: v.optional(v.number()),\n    lastOperationKey: v.optional(v.string()),\n    // Backward compatibility for previously mirrored docs.\n    mirror_updated_at: v.optional(v.number()),\n    last_operation_key: v.optional(v.string()),\n  })\n    .index(\"by_id_value\", [\"legacyId\"])\n    .index(\"by_short\", [\"short\"])\n    .index(\"by_last_operation_key\", [\"lastOperationKey\"]),\n\n  images: defineTable({\n    legacyId: v.string(),\n    active: v.string(),\n    gilded: v.string(),\n    locked: v.string(),\n    active_lip: v.string(),\n    gilded_lip: v.string(),\n    mirrorUpdatedAt: v.optional(v.number()),\n    lastOperationKey: v.optional(v.string()),\n    // Backward compatibility for previously mirrored docs.\n    mirror_updated_at: v.optional(v.number()),\n    last_operation_key: v.optional(v.string()),\n  })\n    .index(\"by_id_value\", [\"legacyId\"])\n    .index(\"by_last_operation_key\", [\"lastOperationKey\"]),\n\n  avatars: defineTable({\n    legacyId: v.number(),\n    link: v.string(),\n    name: v.optional(v.string()),\n    mirrorUpdatedAt: v.optional(v.number()),\n    lastOperationKey: v.optional(v.string()),\n    // Backward compatibility for previously mirrored docs.\n    mirror_updated_at: v.optional(v.number()),\n    last_operation_key: v.optional(v.string()),\n  })\n    .index(\"by_id_value\", [\"legacyId\"])\n    .index(\"by_last_operation_key\", [\"lastOperationKey\"]),\n\n  speakers: defineTable({\n    legacyId: v.optional(v.number()),\n    languageId: v.id(\"languages\"),\n    speaker: v.string(),\n    gender: v.string(),\n    type: v.string(),\n    service: v.string(),\n    mirrorUpdatedAt: v.optional(v.number()),\n    lastOperationKey: v.optional(v.string()),\n  })\n    .index(\"by_id_value\", [\"legacyId\"])\n    .index(\"by_speaker\", [\"speaker\"])\n    .index(\"by_language_id\", [\"languageId\"]),\n\n  localizations: defineTable({\n    legacyId: v.optional(v.number()),\n    languageId: v.id(\"languages\"),\n    tag: v.string(),\n    text: v.string(),\n    mirrorUpdatedAt: v.optional(v.number()),\n    lastOperationKey: v.optional(v.string()),\n  })\n    .index(\"by_id_value\", [\"legacyId\"])\n    .index(\"by_language_id_and_tag\", [\"languageId\", \"tag\"]),\n\n  courses: defineTable({\n    legacyId: v.number(),\n    short: v.optional(v.string()),\n    learningLanguageId: v.id(\"languages\"),\n    fromLanguageId: v.id(\"languages\"),\n    public: v.boolean(),\n    official: v.boolean(),\n    name: v.optional(v.string()),\n    about: v.optional(v.string()),\n    conlang: v.optional(v.boolean()),\n    tags: v.optional(v.array(v.string())),\n    count: v.optional(v.number()),\n    // Legacy denormalized fields kept only for Postgres-compat migration.\n    // TODO(postgres-sunset): remove these once all readers use joined language docs.\n    learning_language_name: v.optional(v.string()),\n    from_language_name: v.optional(v.string()),\n    contributors: v.optional(v.array(v.string())),\n    contributors_past: v.optional(v.array(v.string())),\n    contributorDetails: v.optional(v.array(courseContributorDetailsValidator)),\n    contributorDetailsPast: v.optional(\n      v.array(courseContributorDetailsValidator),\n    ),\n    todo_count: v.optional(v.number()),\n    mirrorUpdatedAt: v.optional(v.number()),\n    lastOperationKey: v.optional(v.string()),\n  })\n    .index(\"by_id_value\", [\"legacyId\"])\n    .index(\"by_short\", [\"short\"])\n    .index(\"by_public\", [\"public\"]),\n\n  avatar_mappings: defineTable({\n    legacyId: v.optional(v.number()),\n    avatarId: v.id(\"avatars\"),\n    languageId: v.id(\"languages\"),\n    name: v.optional(v.string()),\n    speaker: v.optional(v.string()),\n    mirrorUpdatedAt: v.optional(v.number()),\n    lastOperationKey: v.optional(v.string()),\n  })\n    .index(\"by_id_value\", [\"legacyId\"])\n    .index(\"by_language_id\", [\"languageId\"])\n    .index(\"by_avatar_id_and_language_id\", [\"avatarId\", \"languageId\"]),\n\n  stories: defineTable({\n    duo_id: v.optional(v.string()),\n    name: v.string(),\n    set_id: v.optional(v.number()),\n    set_index: v.optional(v.number()),\n    // Temporary migration compatibility:\n    // some existing Convex rows stored auth component user IDs (string),\n    // while mirrored Postgres rows use legacy numeric user IDs.\n    // TODO(post-migration): normalize to a single author identity type.\n    authorId: v.optional(v.union(v.number(), v.string())),\n    authorChangeId: v.optional(v.union(v.number(), v.string())),\n    date: v.optional(v.number()),\n    change_date: v.optional(v.number()),\n    date_published: v.optional(v.number()),\n    public: v.boolean(),\n    imageId: v.optional(v.id(\"images\")),\n    courseId: v.id(\"courses\"),\n    status: v.union(\n      v.literal(\"draft\"),\n      v.literal(\"feedback\"),\n      v.literal(\"finished\"),\n    ),\n    approvalCount: v.optional(v.number()),\n    deleted: v.boolean(),\n    todo_count: v.number(),\n    legacyId: v.optional(v.number()),\n  })\n    .index(\"by_course\", [\"courseId\"])\n    .index(\"by_duo_id_course\", [\"duo_id\", \"courseId\"])\n    .index(\"by_status\", [\"status\"])\n    .index(\"by_public\", [\"public\", \"deleted\"])\n    .index(\"by_set\", [\"courseId\", \"set_id\", \"set_index\"])\n    .index(\"by_course_public_deleted_set\", [\n      \"courseId\",\n      \"public\",\n      \"deleted\",\n      \"set_id\",\n      \"set_index\",\n    ])\n    .index(\"by_legacy_id\", [\"legacyId\"]),\n\n  story_content: defineTable({\n    storyId: v.id(\"stories\"),\n    text: v.string(),\n    json: v.any(),\n    lastUpdated: v.number(),\n  })\n    .index(\"by_story\", [\"storyId\"])\n    .index(\"by_updated\", [\"lastUpdated\"]),\n\n  story_public_content: defineTable({\n    storyId: v.id(\"stories\"),\n    json: v.any(),\n    lastUpdated: v.number(),\n  })\n    .index(\"by_story\", [\"storyId\"])\n    .index(\"by_updated\", [\"lastUpdated\"]),\n\n  story_done: defineTable({\n    storyId: v.id(\"stories\"),\n    legacyUserId: v.optional(v.number()),\n    time: v.number(),\n  })\n    .index(\"by_story\", [\"storyId\"])\n    .index(\"by_user\", [\"legacyUserId\"])\n    .index(\"by_user_and_story\", [\"legacyUserId\", \"storyId\"])\n    .index(\"by_user_time\", [\"legacyUserId\", \"time\"]),\n\n  story_done_state: defineTable({\n    storyId: v.id(\"stories\"),\n    courseId: v.id(\"courses\"),\n    legacyStoryId: v.number(),\n    legacyCourseId: v.number(),\n    legacyUserId: v.number(),\n    lastDoneAt: v.number(),\n  })\n    .index(\"by_user_and_story\", [\"legacyUserId\", \"storyId\"])\n    .index(\"by_user_and_course\", [\"legacyUserId\", \"courseId\"])\n    .index(\"by_user_and_last_done_at\", [\"legacyUserId\", \"lastDoneAt\"]),\n\n  course_activity: defineTable({\n    courseId: v.id(\"courses\"),\n    legacyCourseId: v.number(),\n    legacyUserId: v.number(),\n    lastDoneAt: v.number(),\n  })\n    .index(\"by_user_and_course\", [\"legacyUserId\", \"courseId\"])\n    .index(\"by_user_and_last_done_at\", [\"legacyUserId\", \"lastDoneAt\"]),\n\n  user_preferences: defineTable({\n    tokenIdentifier: v.string(),\n    legacyUserId: v.optional(v.number()),\n    hideStoryQuestions: v.boolean(),\n    updatedAt: v.number(),\n  }).index(\"by_token_identifier\", [\"tokenIdentifier\"]),\n\n  story_approval: defineTable({\n    storyId: v.id(\"stories\"),\n    legacyUserId: v.optional(v.number()),\n    date: v.number(),\n    legacyId: v.optional(v.number()),\n  })\n    .index(\"by_story\", [\"storyId\"])\n    .index(\"by_date\", [\"date\"])\n    .index(\"by_user\", [\"legacyUserId\"])\n    .index(\"by_story_and_user\", [\"storyId\", \"legacyUserId\"])\n    .index(\"by_legacy_id\", [\"legacyId\"]),\n\n  discord_stories_role_sync: defineTable({\n    legacyUserId: v.number(),\n    discordAccountId: v.union(v.string(), v.null()),\n    eligibleStoriesCount: v.union(v.number(), v.null()),\n    assignedStoriesCount: v.union(v.number(), v.null()),\n    syncStatus: v.union(\n      v.literal(\"assigned\"),\n      v.literal(\"up_to_date\"),\n      v.literal(\"no_milestone\"),\n      v.literal(\"not_linked\"),\n      v.literal(\"member_not_found\"),\n      v.literal(\"error\"),\n    ),\n    lastSyncedAt: v.number(),\n    lastError: v.union(v.string(), v.null()),\n  })\n    .index(\"by_legacy_user_id\", [\"legacyUserId\"])\n    .index(\"by_sync_status\", [\"syncStatus\"]),\n});\n"
  },
  {
    "path": "convex/storyApproval.ts",
    "content": "import { mutation, query, type MutationCtx } from \"./_generated/server\";\nimport type { Id } from \"./_generated/dataModel\";\nimport { v } from \"convex/values\";\nimport { internal } from \"./_generated/api\";\nimport {\n  requireContributorOrAdmin,\n  requireSessionLegacyUserId,\n} from \"./lib/authorization\";\nimport { recomputeCoursePublishedCount } from \"./lib/courseCounts\";\nimport {\n  getRankedCourseContributors,\n  partitionCourseContributors,\n} from \"./lib/courseContributors\";\n\nconst storyApprovalInputValidator = {\n  legacyStoryId: v.number(),\n  date: v.optional(v.number()),\n  legacyApprovalId: v.optional(v.number()),\n};\n\nasync function recomputeCourseContributors(\n  ctx: MutationCtx,\n  courseId: Id<\"courses\">,\n): Promise<{\n  contributors: string[];\n  contributors_past: string[];\n  contributorDetails: Array<{\n    legacyUserId: number;\n    name: string;\n    image: string | null;\n    discordLinked: boolean;\n  }>;\n  contributorDetailsPast: Array<{\n    legacyUserId: number;\n    name: string;\n    image: string | null;\n    discordLinked: boolean;\n  }>;\n}> {\n  const ranked = await getRankedCourseContributors(ctx, courseId);\n  const contributorLists = partitionCourseContributors(ranked);\n\n  return {\n    contributors: contributorLists.contributors.map((row) => row.name),\n    contributors_past: contributorLists.contributors_past.map(\n      (row) => row.name,\n    ),\n    contributorDetails: contributorLists.contributors,\n    contributorDetailsPast: contributorLists.contributors_past,\n  };\n}\n\nexport const upsertStoryApproval = mutation({\n  args: storyApprovalInputValidator,\n  returns: v.object({\n    inserted: v.boolean(),\n    docId: v.id(\"story_approval\"),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const legacyUserId = await requireSessionLegacyUserId(ctx);\n    const identity = (await ctx.auth.getUserIdentity()) as {\n      name?: string | null;\n    } | null;\n    const actorName = identity?.name?.trim() || `user_${legacyUserId}`;\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.legacyStoryId))\n      .unique();\n    if (!story) {\n      throw new Error(`Missing story for legacy id ${args.legacyStoryId}`);\n    }\n\n    const existing = await ctx.db\n      .query(\"story_approval\")\n      .withIndex(\"by_story_and_user\", (q) =>\n        q.eq(\"storyId\", story._id).eq(\"legacyUserId\", legacyUserId),\n      )\n      .unique();\n\n    const doc = {\n      storyId: story._id,\n      legacyUserId,\n      date: args.date ?? Date.now(),\n      legacyId: args.legacyApprovalId,\n    };\n\n    if (existing) {\n      await ctx.db.replace(existing._id, doc);\n      return { inserted: false, docId: existing._id };\n    }\n\n    const docId = await ctx.db.insert(\"story_approval\", doc);\n    return { inserted: true, docId };\n  },\n});\n\nexport const deleteStoryApproval = mutation({\n  args: {\n    legacyStoryId: v.number(),\n  },\n  returns: v.object({\n    deleted: v.boolean(),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const legacyUserId = await requireSessionLegacyUserId(ctx);\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.legacyStoryId))\n      .unique();\n    if (!story) {\n      return { deleted: false };\n    }\n\n    const existing = await ctx.db\n      .query(\"story_approval\")\n      .withIndex(\"by_story_and_user\", (q) =>\n        q.eq(\"storyId\", story._id).eq(\"legacyUserId\", legacyUserId),\n      )\n      .unique();\n    if (!existing) return { deleted: false };\n\n    await ctx.db.delete(existing._id);\n    return { deleted: true };\n  },\n});\n\nexport const deleteStoryApprovalByLegacyId = mutation({\n  args: {\n    legacyApprovalId: v.number(),\n  },\n  returns: v.object({\n    deleted: v.boolean(),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const existing = await ctx.db\n      .query(\"story_approval\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.legacyApprovalId))\n      .unique();\n    if (!existing) return { deleted: false };\n    await ctx.db.delete(existing._id);\n    return { deleted: true };\n  },\n});\n\nexport const toggleStoryApproval = mutation({\n  args: {\n    legacyStoryId: v.number(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    count: v.number(),\n    story_status: v.union(\n      v.literal(\"draft\"),\n      v.literal(\"feedback\"),\n      v.literal(\"finished\"),\n    ),\n    finished_in_set: v.number(),\n    action: v.union(v.literal(\"added\"), v.literal(\"deleted\")),\n    published: v.array(v.number()),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const legacyUserId = await requireSessionLegacyUserId(ctx);\n    const identity = (await ctx.auth.getUserIdentity()) as {\n      name?: string | null;\n    } | null;\n    const actorName = identity?.name?.trim() || `user_${legacyUserId}`;\n\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.legacyStoryId))\n      .unique();\n    if (!story || typeof story.legacyId !== \"number\") {\n      throw new Error(`Story ${args.legacyStoryId} not found`);\n    }\n\n    const existing = await ctx.db\n      .query(\"story_approval\")\n      .withIndex(\"by_story_and_user\", (q) =>\n        q.eq(\"storyId\", story._id).eq(\"legacyUserId\", legacyUserId),\n      )\n      .unique();\n\n    let action: \"added\" | \"deleted\";\n    if (existing) {\n      await ctx.db.delete(existing._id);\n      action = \"deleted\";\n    } else {\n      await ctx.db.insert(\"story_approval\", {\n        storyId: story._id,\n        legacyUserId,\n        date: Date.now(),\n      });\n      action = \"added\";\n    }\n\n    const approvals = await ctx.db\n      .query(\"story_approval\")\n      .withIndex(\"by_story\", (q) => q.eq(\"storyId\", story._id))\n      .collect();\n    const count = approvals.length;\n    const story_status: \"draft\" | \"feedback\" | \"finished\" =\n      count === 0 ? \"draft\" : count === 1 ? \"feedback\" : \"finished\";\n\n    await ctx.db.patch(story._id, {\n      status: story_status,\n      approvalCount: count,\n    });\n\n    const storiesInCourse = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_course\", (q) => q.eq(\"courseId\", story.courseId))\n      .collect();\n\n    const finishedStoriesInSet = storiesInCourse.filter(\n      (row) =>\n        row.set_id === story.set_id &&\n        row.status === \"finished\" &&\n        !row.deleted,\n    );\n    const finished_in_set = finishedStoriesInSet.length;\n\n    const published: number[] = [];\n    let datePublishedMs: number | null = null;\n    if (finished_in_set >= 4) {\n      datePublishedMs = Date.now();\n      for (const row of finishedStoriesInSet) {\n        if (row.public) continue;\n        await ctx.db.patch(row._id, {\n          public: true,\n          date_published: datePublishedMs,\n        });\n        if (typeof row.legacyId === \"number\") {\n          published.push(row.legacyId);\n        }\n      }\n    }\n\n    let courseCount: number | null = null;\n    if (published.length > 0) {\n      courseCount = await recomputeCoursePublishedCount(ctx, story.courseId);\n    }\n\n    const {\n      contributors,\n      contributors_past,\n      contributorDetails,\n      contributorDetailsPast,\n    } = await recomputeCourseContributors(ctx, story.courseId);\n    await ctx.db.patch(story.courseId, {\n      contributors,\n      contributors_past,\n      contributorDetails,\n      contributorDetailsPast,\n    });\n\n    const operationKey =\n      args.operationKey ??\n      `story_approval:${story.legacyId}:user:${legacyUserId}:toggle:${Date.now()}`;\n    await ctx.scheduler.runAfter(\n      0,\n      internal.editorSideEffects.onStoryApprovalToggled,\n      {\n        operationKey,\n        storyId: story.legacyId,\n        action,\n        count,\n        storyStatus: story_status,\n        finishedInSet: finished_in_set,\n        publishedCount: published.length,\n        actorName,\n        actorLegacyUserId: legacyUserId,\n      },\n    );\n\n    return {\n      count,\n      story_status,\n      finished_in_set,\n      action,\n      published,\n    };\n  },\n});\n\nexport const getApprovalCountByStory = query({\n  args: {\n    legacyStoryId: v.number(),\n  },\n  returns: v.number(),\n  handler: async (ctx, args) => {\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.legacyStoryId))\n      .unique();\n    if (!story) return 0;\n    const approvals = await ctx.db\n      .query(\"story_approval\")\n      .withIndex(\"by_story\", (q) => q.eq(\"storyId\", story._id))\n      .collect();\n    return approvals.length;\n  },\n});\n"
  },
  {
    "path": "convex/storyDone.ts",
    "content": "import { mutation, query } from \"./_generated/server\";\nimport type { Id } from \"./_generated/dataModel\";\nimport type { MutationCtx, QueryCtx } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { getSessionLegacyUserId } from \"./lib/authorization\";\n\nconst storyDoneInputValidator = v.object({\n  legacyStoryId: v.number(),\n  time: v.optional(v.number()),\n});\n\nconst dashboardCourseValidator = v.object({\n  short: v.string(),\n  name: v.string(),\n  learningLanguageName: v.string(),\n  learningLanguageShort: v.string(),\n  learningLanguageFlag: v.optional(v.union(v.number(), v.string())),\n  learningLanguageFlagFile: v.optional(v.string()),\n});\n\nconst nextStepValidator = v.object({\n  course: dashboardCourseValidator,\n  completedCount: v.number(),\n  totalCount: v.number(),\n  nextStoryId: v.union(v.number(), v.null()),\n  reviewStoryId: v.union(v.number(), v.null()),\n});\n\nexport const recordStoryDone = mutation({\n  args: storyDoneInputValidator.fields,\n  returns: v.object({\n    inserted: v.boolean(),\n    docId: v.id(\"story_done\"),\n  }),\n  handler: async (ctx, args) => {\n    const legacyUserId = await getSessionLegacyUserId(ctx);\n\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.legacyStoryId))\n      .unique();\n    if (!story) {\n      throw new Error(`Missing story for legacy id ${args.legacyStoryId}`);\n    }\n\n    const doneAt = args.time ?? Date.now();\n    const docId = await ctx.db.insert(\"story_done\", {\n      storyId: story._id,\n      legacyUserId: legacyUserId ?? undefined,\n      time: doneAt,\n    });\n\n    if (typeof legacyUserId === \"number\") {\n      const course = await ctx.db.get(story.courseId);\n      if (\n        course &&\n        typeof course.legacyId === \"number\" &&\n        typeof story.legacyId === \"number\"\n      ) {\n        await upsertStoryDoneState(ctx, {\n          legacyUserId,\n          storyId: story._id,\n          courseId: story.courseId,\n          legacyStoryId: story.legacyId,\n          legacyCourseId: course.legacyId,\n          lastDoneAt: doneAt,\n        });\n        await upsertCourseActivity(ctx, {\n          legacyUserId,\n          courseId: story.courseId,\n          legacyCourseId: course.legacyId,\n          lastDoneAt: doneAt,\n        });\n      }\n    }\n\n    return { inserted: true, docId };\n  },\n});\n\nexport const getDoneStoryIdsForCourse = query({\n  args: {\n    legacyCourseId: v.number(),\n    legacyUserId: v.number(),\n  },\n  returns: v.array(v.number()),\n  handler: async (ctx, args) => {\n    const course = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.legacyCourseId))\n      .unique();\n    if (!course) return [];\n\n    return await getDoneStoryIdsForCourseIdAndUser(\n      ctx,\n      course._id,\n      args.legacyUserId,\n    );\n  },\n});\n\nexport const getDoneStoryIdsForCurrentUserInCourse = query({\n  args: {\n    courseShort: v.string(),\n  },\n  returns: v.array(v.number()),\n  handler: async (ctx, args) => {\n    const legacyUserId = await getSessionLegacyUserId(ctx);\n    if (!legacyUserId) return [];\n\n    const course = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_short\", (q) => q.eq(\"short\", args.courseShort))\n      .unique();\n    if (!course) return [];\n\n    return await getDoneStoryIdsForCourseIdAndUser(\n      ctx,\n      course._id,\n      legacyUserId,\n    );\n  },\n});\n\nasync function getDoneStoryIdsForCourseIdAndUser(\n  ctx: QueryCtx,\n  courseId: Id<\"courses\">,\n  legacyUserId: number,\n) {\n  const doneStateRows = await ctx.db\n    .query(\"story_done_state\")\n    .withIndex(\"by_user_and_course\", (q) =>\n      q.eq(\"legacyUserId\", legacyUserId).eq(\"courseId\", courseId),\n    )\n    .collect();\n  if (doneStateRows.length > 0) {\n    const storyIds = new Set<number>();\n    for (const row of doneStateRows) {\n      storyIds.add(row.legacyStoryId);\n    }\n    return Array.from(storyIds.values());\n  }\n  return [];\n}\n\nexport const getDoneCourseIdsForUser = query({\n  args: {},\n  returns: v.union(v.array(v.number()), v.null()),\n  handler: async (ctx) => {\n    const legacyUserId = await getCurrentIdentityLegacyUserId(ctx);\n    if (!legacyUserId) return null;\n\n    const activityRows = await ctx.db\n      .query(\"course_activity\")\n      .withIndex(\"by_user_and_last_done_at\", (q) =>\n        q.eq(\"legacyUserId\", legacyUserId),\n      )\n      .order(\"desc\")\n      .collect();\n    const uniqueCourseIds = new Set<number>();\n    const result: Array<number> = [];\n    for (const row of activityRows) {\n      if (uniqueCourseIds.has(row.legacyCourseId)) continue;\n      uniqueCourseIds.add(row.legacyCourseId);\n      result.push(row.legacyCourseId);\n    }\n    return result;\n  },\n});\n\nexport const getLastDoneCourseShortForLegacyUser = query({\n  args: {\n    legacyUserId: v.number(),\n  },\n  returns: v.union(v.string(), v.null()),\n  handler: async (ctx, args) => {\n    const activityRows = await ctx.db\n      .query(\"course_activity\")\n      .withIndex(\"by_user_and_last_done_at\", (q) =>\n        q.eq(\"legacyUserId\", args.legacyUserId),\n      )\n      .order(\"desc\")\n      .take(20);\n\n    for (const row of activityRows) {\n      const course = await ctx.db.get(row.courseId);\n      if (!course?.short) continue;\n      return course.short;\n    }\n    return null;\n  },\n});\n\nexport const getNextStoryForCurrentUserInCourse = query({\n  args: {\n    courseShort: v.string(),\n    currentStoryId: v.optional(v.number()),\n  },\n  returns: v.union(nextStepValidator, v.null()),\n  handler: async (ctx, args) => {\n    const legacyUserId = await getCurrentIdentityLegacyUserId(ctx);\n    if (!legacyUserId) return null;\n\n    const course = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_short\", (q) => q.eq(\"short\", args.courseShort))\n      .unique();\n    if (!course) return null;\n\n    return await getNextStepForCourse(ctx, {\n      courseId: course._id,\n      legacyUserId,\n      currentStoryId: args.currentStoryId,\n    });\n  },\n});\n\nasync function upsertStoryDoneState(\n  ctx: MutationCtx,\n  args: {\n    storyId: Id<\"stories\">;\n    courseId: Id<\"courses\">;\n    legacyStoryId: number;\n    legacyCourseId: number;\n    legacyUserId: number;\n    lastDoneAt: number;\n  },\n) {\n  const existingRows = await ctx.db\n    .query(\"story_done_state\")\n    .withIndex(\"by_user_and_story\", (q) =>\n      q.eq(\"legacyUserId\", args.legacyUserId).eq(\"storyId\", args.storyId),\n    )\n    .collect();\n  if (existingRows.length === 0) {\n    await ctx.db.insert(\"story_done_state\", args);\n    return;\n  }\n\n  for (const row of existingRows) {\n    await ctx.db.patch(row._id, {\n      courseId: args.courseId,\n      legacyStoryId: args.legacyStoryId,\n      legacyCourseId: args.legacyCourseId,\n      lastDoneAt: Math.max(row.lastDoneAt, args.lastDoneAt),\n    });\n  }\n}\n\nasync function getNextStepForCourse(\n  ctx: QueryCtx,\n  args: {\n    courseId: Id<\"courses\">;\n    legacyUserId: number;\n    currentStoryId?: number;\n  },\n) {\n  const course = await ctx.db.get(args.courseId);\n  if (!course?.public || !course.short) return null;\n\n  const learningLanguage = await ctx.db.get(course.learningLanguageId);\n  if (!learningLanguage) return null;\n\n  const publicStories = await ctx.db\n    .query(\"stories\")\n    .withIndex(\"by_course_public_deleted_set\", (q) =>\n      q.eq(\"courseId\", args.courseId).eq(\"public\", true).eq(\"deleted\", false),\n    )\n    .collect();\n\n  const orderedStories = publicStories\n    .filter(\n      (story): story is typeof story & { legacyId: number } =>\n        typeof story.legacyId === \"number\",\n    )\n    .sort((a, b) => {\n      const setCmp = (a.set_id ?? 0) - (b.set_id ?? 0);\n      if (setCmp !== 0) return setCmp;\n      return (a.set_index ?? 0) - (b.set_index ?? 0);\n    });\n  if (orderedStories.length === 0) return null;\n\n  const doneStoryIds = new Set<number>(\n    await getDoneStoryIdsForCourseIdAndUser(\n      ctx,\n      args.courseId,\n      args.legacyUserId,\n    ),\n  );\n  if (typeof args.currentStoryId === \"number\") {\n    doneStoryIds.add(args.currentStoryId);\n  }\n\n  const totalCount = orderedStories.length;\n  const completedCount = orderedStories.reduce(\n    (count, story) => count + (doneStoryIds.has(story.legacyId) ? 1 : 0),\n    0,\n  );\n\n  const currentIndex =\n    typeof args.currentStoryId === \"number\"\n      ? orderedStories.findIndex(\n          (story) => story.legacyId === args.currentStoryId,\n        )\n      : -1;\n\n  let nextStory =\n    currentIndex >= 0\n      ? orderedStories\n          .slice(currentIndex + 1)\n          .find((story) => !doneStoryIds.has(story.legacyId))\n      : undefined;\n  if (!nextStory) {\n    nextStory = orderedStories.find(\n      (story) => !doneStoryIds.has(story.legacyId),\n    );\n  }\n\n  return {\n    course: {\n      short: course.short,\n      name:\n        course.name && course.name.trim().length > 0\n          ? course.name\n          : learningLanguage.name,\n      learningLanguageName: learningLanguage.name,\n      learningLanguageShort: learningLanguage.short,\n      learningLanguageFlag: learningLanguage.flag,\n      learningLanguageFlagFile: learningLanguage.flag_file,\n    },\n    completedCount,\n    totalCount,\n    nextStoryId: nextStory?.legacyId ?? null,\n    reviewStoryId: orderedStories[0]?.legacyId ?? null,\n  };\n}\n\nasync function upsertCourseActivity(\n  ctx: MutationCtx,\n  args: {\n    courseId: Id<\"courses\">;\n    legacyCourseId: number;\n    legacyUserId: number;\n    lastDoneAt: number;\n  },\n) {\n  const existingRows = await ctx.db\n    .query(\"course_activity\")\n    .withIndex(\"by_user_and_course\", (q) =>\n      q.eq(\"legacyUserId\", args.legacyUserId).eq(\"courseId\", args.courseId),\n    )\n    .collect();\n  if (existingRows.length === 0) {\n    await ctx.db.insert(\"course_activity\", args);\n    return;\n  }\n\n  for (const row of existingRows) {\n    await ctx.db.patch(row._id, {\n      legacyCourseId: args.legacyCourseId,\n      lastDoneAt: Math.max(row.lastDoneAt, args.lastDoneAt),\n    });\n  }\n}\n\nasync function getCurrentIdentityLegacyUserId(\n  ctx: QueryCtx,\n): Promise<number | null> {\n  const identity = (await ctx.auth.getUserIdentity()) as {\n    userId?: string | number | null;\n  } | null;\n  const rawLegacyUserId = identity?.userId;\n  if (typeof rawLegacyUserId === \"number\" && Number.isFinite(rawLegacyUserId)) {\n    return rawLegacyUserId;\n  }\n  if (\n    typeof rawLegacyUserId === \"string\" &&\n    Number.isFinite(Number(rawLegacyUserId))\n  ) {\n    return Number(rawLegacyUserId);\n  }\n  return null;\n}\n"
  },
  {
    "path": "convex/storyPublicContent.ts",
    "content": "import { paginationOptsValidator } from \"convex/server\";\nimport { v } from \"convex/values\";\nimport { mutation } from \"./_generated/server\";\nimport { requireAdmin } from \"./lib/authorization\";\nimport { upsertPublicStoryContent } from \"./lib/publicStoryContent\";\n\nexport const backfillBatch = mutation({\n  args: {\n    paginationOpts: paginationOptsValidator,\n  },\n  returns: v.object({\n    processed: v.number(),\n    continueCursor: v.string(),\n    isDone: v.boolean(),\n  }),\n  handler: async (ctx, args) => {\n    await requireAdmin(ctx);\n    const page = await ctx.db\n      .query(\"story_content\")\n      .order(\"asc\")\n      .paginate(args.paginationOpts);\n\n    for (const content of page.page) {\n      await upsertPublicStoryContent(\n        ctx,\n        content.storyId,\n        content.json,\n        content.lastUpdated,\n      );\n    }\n\n    return {\n      processed: page.page.length,\n      continueCursor: page.continueCursor,\n      isDone: page.isDone,\n    };\n  },\n});\n"
  },
  {
    "path": "convex/storyRead.ts",
    "content": "import { query } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { getPublicStoryJson } from \"./lib/publicStoryContent\";\n\nconst storyReadResultValidator = v.union(\n  v.object({\n    id: v.number(),\n    set_id: v.number(),\n    course_id: v.number(),\n    from_language: v.string(),\n    from_language_id: v.number(),\n    from_language_long: v.string(),\n    from_language_rtl: v.boolean(),\n    from_language_name: v.string(),\n    learning_language: v.string(),\n    learning_language_long: v.string(),\n    learning_language_rtl: v.boolean(),\n    course_short: v.string(),\n    elements: v.array(v.any()),\n    illustrations: v.object({\n      gilded: v.string(),\n      active: v.string(),\n      locked: v.string(),\n    }),\n  }),\n  v.null(),\n);\n\nconst storyMetaResultValidator = v.union(\n  v.object({\n    from_language_name: v.string(),\n    image: v.string(),\n    from_language_long: v.string(),\n    learning_language_long: v.string(),\n  }),\n  v.null(),\n);\n\nconst storyPreviewResultValidator = v.union(\n  v.object({\n    id: v.number(),\n    title: v.string(),\n    active: v.string(),\n    gilded: v.string(),\n  }),\n  v.null(),\n);\n\nfunction nonEmptyString(value: unknown) {\n  return typeof value === \"string\" && value.trim().length > 0 ? value : \"\";\n}\n\nexport const getStoryByLegacyId = query({\n  args: {\n    storyId: v.number(),\n  },\n  returns: storyReadResultValidator,\n  handler: async (ctx, args) => {\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.storyId))\n      .unique();\n    if (!story || typeof story.legacyId !== \"number\") return null;\n    if (story.deleted) return null;\n\n    const storyJson = await getPublicStoryJson(ctx, story._id);\n    if (!storyJson) return null;\n\n    const course = await ctx.db.get(story.courseId);\n    if (!course) return null;\n\n    const [fromLanguage, learningLanguage, image] = await Promise.all([\n      ctx.db.get(course.fromLanguageId),\n      ctx.db.get(course.learningLanguageId),\n      story.imageId ? ctx.db.get(story.imageId) : Promise.resolve(null),\n    ]);\n    if (!fromLanguage || !learningLanguage) return null;\n\n    let parsedJson = storyJson;\n    if (typeof parsedJson === \"string\") {\n      try {\n        parsedJson = JSON.parse(parsedJson);\n      } catch {\n        return null;\n      }\n    }\n\n    const elements = Array.isArray(parsedJson?.elements)\n      ? parsedJson.elements\n      : [];\n    const illustrations = parsedJson?.illustrations ?? {};\n    const active =\n      nonEmptyString(illustrations.active) || (image?.active ?? \"\");\n    const gilded =\n      nonEmptyString(illustrations.gilded) || (image?.gilded ?? \"\");\n    const locked =\n      nonEmptyString(illustrations.locked) || (image?.locked ?? \"\");\n\n    return {\n      id: story.legacyId,\n      set_id: story.set_id ?? 0,\n      course_id: course.legacyId,\n      from_language: fromLanguage.short,\n      from_language_id: fromLanguage.legacyId,\n      from_language_long: fromLanguage.name,\n      from_language_rtl: fromLanguage.rtl,\n      from_language_name: story.name,\n      learning_language: learningLanguage.short,\n      learning_language_long: learningLanguage.name,\n      learning_language_rtl: learningLanguage.rtl,\n      course_short: `${learningLanguage.short}-${fromLanguage.short}`,\n      elements,\n      illustrations: {\n        gilded,\n        active,\n        locked,\n      },\n    };\n  },\n});\n\nexport const getStoryMetaByLegacyId = query({\n  args: {\n    storyId: v.number(),\n  },\n  returns: storyMetaResultValidator,\n  handler: async (ctx, args) => {\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.storyId))\n      .unique();\n    if (!story || story.deleted) return null;\n\n    const course = await ctx.db.get(story.courseId);\n    if (!course) return null;\n\n    const [fromLanguage, learningLanguage, image] = await Promise.all([\n      ctx.db.get(course.fromLanguageId),\n      ctx.db.get(course.learningLanguageId),\n      story.imageId ? ctx.db.get(story.imageId) : Promise.resolve(null),\n    ]);\n\n    if (!fromLanguage || !learningLanguage) return null;\n\n    return {\n      from_language_name: story.name,\n      image: image?.legacyId ?? \"\",\n      from_language_long: fromLanguage.name,\n      learning_language_long: learningLanguage.name,\n    };\n  },\n});\n\nexport const getStoryPreviewByLegacyId = query({\n  args: {\n    storyId: v.number(),\n  },\n  returns: storyPreviewResultValidator,\n  handler: async (ctx, args) => {\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.storyId))\n      .unique();\n    if (!story || story.deleted || typeof story.legacyId !== \"number\") {\n      return null;\n    }\n\n    const storyJson = await getPublicStoryJson(ctx, story._id);\n    const image = story.imageId ? await ctx.db.get(story.imageId) : null;\n\n    let parsedJson = storyJson;\n    if (typeof parsedJson === \"string\") {\n      try {\n        parsedJson = JSON.parse(parsedJson);\n      } catch {\n        parsedJson = null;\n      }\n    }\n\n    const illustrations = parsedJson?.illustrations ?? {};\n    const active =\n      nonEmptyString(illustrations.active) || (image?.active ?? \"\");\n    const gilded =\n      nonEmptyString(illustrations.gilded) || (image?.gilded ?? active);\n\n    return {\n      id: story.legacyId,\n      title: story.name,\n      active,\n      gilded,\n    };\n  },\n});\n"
  },
  {
    "path": "convex/storyTables.ts",
    "content": "import { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { requireContributorOrAdmin } from \"./lib/authorization\";\nimport { upsertPublicStoryContent } from \"./lib/publicStoryContent\";\n\nconst storyValidator = {\n  legacyId: v.number(),\n  duo_id: v.optional(v.string()),\n  name: v.string(),\n  set_id: v.optional(v.number()),\n  set_index: v.optional(v.number()),\n  // Temporary migration compatibility with pre-existing rows.\n  // TODO(post-migration): tighten to one type after author identity normalization.\n  authorId: v.optional(v.union(v.number(), v.string())),\n  authorChangeId: v.optional(v.union(v.number(), v.string())),\n  date: v.optional(v.number()),\n  change_date: v.optional(v.number()),\n  date_published: v.optional(v.number()),\n  public: v.boolean(),\n  legacyImageId: v.optional(v.string()),\n  legacyCourseId: v.number(),\n  status: v.union(\n    v.literal(\"draft\"),\n    v.literal(\"feedback\"),\n    v.literal(\"finished\"),\n  ),\n  approvalCount: v.optional(v.number()),\n  deleted: v.boolean(),\n  todo_count: v.number(),\n};\n\nconst storyContentValidator = {\n  legacyStoryId: v.number(),\n  text: v.string(),\n  json: v.any(),\n  lastUpdated: v.number(),\n};\n\nexport const upsertStory = mutation({\n  args: {\n    story: v.object(storyValidator),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    inserted: v.boolean(),\n    docId: v.id(\"stories\"),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const course = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_id_value\", (q) =>\n        q.eq(\"legacyId\", args.story.legacyCourseId),\n      )\n      .unique();\n    if (!course) {\n      throw new Error(\n        `Missing course for legacy id ${args.story.legacyCourseId}`,\n      );\n    }\n\n    const image = args.story.legacyImageId\n      ? await ctx.db\n          .query(\"images\")\n          .withIndex(\"by_id_value\", (q) =>\n            q.eq(\"legacyId\", args.story.legacyImageId!),\n          )\n          .unique()\n      : null;\n\n    const existing = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.story.legacyId))\n      .unique();\n\n    const doc = {\n      legacyId: args.story.legacyId,\n      duo_id: args.story.duo_id,\n      name: args.story.name,\n      set_id: args.story.set_id,\n      set_index: args.story.set_index,\n      authorId: args.story.authorId,\n      authorChangeId: args.story.authorChangeId,\n      date: args.story.date,\n      change_date: args.story.change_date,\n      date_published: args.story.date_published,\n      public: args.story.public,\n      imageId: image?._id,\n      courseId: course._id,\n      status: args.story.status,\n      approvalCount: args.story.approvalCount ?? existing?.approvalCount ?? 0,\n      deleted: args.story.deleted,\n      todo_count: args.story.todo_count,\n    };\n\n    if (existing) {\n      await ctx.db.replace(existing._id, doc);\n      return { inserted: false, docId: existing._id };\n    }\n\n    const docId = await ctx.db.insert(\"stories\", doc);\n    return { inserted: true, docId };\n  },\n});\n\nexport const upsertStoryContent = mutation({\n  args: {\n    storyContent: v.object(storyContentValidator),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.object({\n    inserted: v.boolean(),\n    docId: v.id(\"story_content\"),\n  }),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) =>\n        q.eq(\"legacyId\", args.storyContent.legacyStoryId),\n      )\n      .unique();\n    if (!story) {\n      throw new Error(\n        `Missing story for legacy id ${args.storyContent.legacyStoryId}`,\n      );\n    }\n\n    const existing = await ctx.db\n      .query(\"story_content\")\n      .withIndex(\"by_story\", (q) => q.eq(\"storyId\", story._id))\n      .unique();\n\n    const doc = {\n      storyId: story._id,\n      text: args.storyContent.text,\n      json: args.storyContent.json,\n      lastUpdated: args.storyContent.lastUpdated,\n    };\n\n    if (existing) {\n      await ctx.db.replace(existing._id, doc);\n      await upsertPublicStoryContent(\n        ctx,\n        story._id,\n        args.storyContent.json,\n        args.storyContent.lastUpdated,\n      );\n      return { inserted: false, docId: existing._id };\n    }\n\n    const docId = await ctx.db.insert(\"story_content\", doc);\n    await upsertPublicStoryContent(\n      ctx,\n      story._id,\n      args.storyContent.json,\n      args.storyContent.lastUpdated,\n    );\n    return { inserted: true, docId };\n  },\n});\n"
  },
  {
    "path": "convex/storyWrite.ts",
    "content": "import { internal } from \"./_generated/api\";\nimport { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport {\n  requireContributorOrAdmin,\n  requireSessionLegacyUserId,\n} from \"./lib/authorization\";\nimport { recomputeCoursePublishedCount } from \"./lib/courseCounts\";\nimport { upsertPublicStoryContent } from \"./lib/publicStoryContent\";\n\nexport const setStory = mutation({\n  args: {\n    legacyStoryId: v.optional(v.number()),\n    duo_id: v.string(),\n    name: v.string(),\n    image: v.string(),\n    set_id: v.number(),\n    set_index: v.number(),\n    legacyCourseId: v.number(),\n    text: v.string(),\n    json: v.any(),\n    todo_count: v.number(),\n    change_date: v.string(),\n    confirmOfficialOverwrite: v.optional(v.boolean()),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.union(\n    v.null(),\n    v.object({\n      id: v.number(),\n      name: v.string(),\n      course_id: v.number(),\n      text: v.string(),\n      todo_count: v.number(),\n    }),\n  ),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const authorChangeLegacyUserId = await requireSessionLegacyUserId(ctx);\n    const identity = (await ctx.auth.getUserIdentity()) as {\n      name?: string | null;\n      role?: string | null;\n    } | null;\n    const actorName =\n      identity?.name?.trim() || `user_${authorChangeLegacyUserId}`;\n    const course = await ctx.db\n      .query(\"courses\")\n      .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.legacyCourseId))\n      .unique();\n    if (!course) {\n      throw new Error(`Course ${args.legacyCourseId} not found`);\n    }\n    if (course.official) {\n      if (identity?.role !== \"admin\") {\n        throw new Error(\"Official stories cannot be overwritten.\");\n      }\n      if (!args.confirmOfficialOverwrite) {\n        throw new Error(\n          \"Official story overwrite requires explicit confirmation.\",\n        );\n      }\n    }\n\n    const storyById =\n      args.legacyStoryId !== undefined\n        ? await ctx.db\n            .query(\"stories\")\n            .withIndex(\"by_legacy_id\", (q) =>\n              q.eq(\"legacyId\", args.legacyStoryId!),\n            )\n            .unique()\n        : null;\n\n    const storyByDuoId =\n      !storyById && args.duo_id\n        ? ((\n            await ctx.db\n              .query(\"stories\")\n              .withIndex(\"by_duo_id_course\", (q) =>\n                q.eq(\"duo_id\", args.duo_id).eq(\"courseId\", course._id),\n              )\n              .collect()\n          )[0] ?? null)\n        : null;\n\n    const story = storyById ?? storyByDuoId;\n    if (!story || story.legacyId === undefined) return null;\n\n    const image = args.image\n      ? await ctx.db\n          .query(\"images\")\n          .withIndex(\"by_id_value\", (q) => q.eq(\"legacyId\", args.image))\n          .unique()\n      : null;\n\n    const changeDateMillis = Date.parse(args.change_date);\n    const operationKey =\n      args.operationKey ?? `story:${story.legacyId}:set_story:${Date.now()}`;\n    const previousCourseId = story.courseId;\n    const movedPublishedStory =\n      previousCourseId !== course._id && story.public && !story.deleted;\n\n    await ctx.db.patch(story._id, {\n      duo_id: args.duo_id,\n      name: args.name,\n      imageId: image?._id,\n      change_date: Number.isFinite(changeDateMillis)\n        ? changeDateMillis\n        : Date.now(),\n      authorChangeId: authorChangeLegacyUserId,\n      set_id: args.set_id,\n      set_index: args.set_index,\n      courseId: course._id,\n      todo_count: args.todo_count,\n    });\n\n    const existingContent = await ctx.db\n      .query(\"story_content\")\n      .withIndex(\"by_story\", (q) => q.eq(\"storyId\", story._id))\n      .unique();\n\n    const lastUpdated = Date.now();\n\n    if (existingContent) {\n      await ctx.db.patch(existingContent._id, {\n        text: args.text,\n        json: args.json,\n        lastUpdated,\n      });\n    } else {\n      await ctx.db.insert(\"story_content\", {\n        storyId: story._id,\n        text: args.text,\n        json: args.json,\n        lastUpdated,\n      });\n    }\n    await upsertPublicStoryContent(ctx, story._id, args.json, lastUpdated);\n\n    const storiesInCourse = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_course\", (q) => q.eq(\"courseId\", course._id))\n      .collect();\n    const courseTodoCount = storiesInCourse.reduce(\n      (acc, row) => acc + (row.todo_count ?? 0),\n      0,\n    );\n    await ctx.db.patch(course._id, { todo_count: courseTodoCount });\n    if (movedPublishedStory) {\n      await recomputeCoursePublishedCount(ctx, previousCourseId);\n      await recomputeCoursePublishedCount(ctx, course._id);\n    }\n\n    await ctx.scheduler.runAfter(0, internal.editorSideEffects.onStorySaved, {\n      operationKey,\n      storyId: story.legacyId,\n      storyName: args.name,\n      courseId: args.legacyCourseId,\n      text: args.text,\n      todoCount: args.todo_count,\n      actorName,\n      actorLegacyUserId: authorChangeLegacyUserId,\n    });\n\n    return {\n      id: story.legacyId,\n      name: args.name,\n      course_id: args.legacyCourseId,\n      text: args.text,\n      todo_count: args.todo_count,\n    };\n  },\n});\n\nexport const deleteStory = mutation({\n  args: {\n    legacyStoryId: v.number(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.union(\n    v.null(),\n    v.object({\n      id: v.number(),\n      name: v.string(),\n      course_id: v.number(),\n      text: v.string(),\n    }),\n  ),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const actorLegacyUserId = await requireSessionLegacyUserId(ctx);\n    const identity = (await ctx.auth.getUserIdentity()) as {\n      name?: string | null;\n    } | null;\n    const actorName = identity?.name?.trim() || `user_${actorLegacyUserId}`;\n    const story = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\", (q) => q.eq(\"legacyId\", args.legacyStoryId))\n      .unique();\n    if (!story || story.legacyId === undefined) return null;\n\n    const [course, content] = await Promise.all([\n      ctx.db.get(story.courseId),\n      ctx.db\n        .query(\"story_content\")\n        .withIndex(\"by_story\", (q) => q.eq(\"storyId\", story._id))\n        .unique(),\n    ]);\n\n    if (!course) {\n      throw new Error(`Course missing for story ${args.legacyStoryId}`);\n    }\n\n    await ctx.db.patch(story._id, {\n      deleted: true,\n      public: false,\n    });\n    if (story.public) {\n      await recomputeCoursePublishedCount(ctx, story.courseId);\n    }\n\n    const operationKey =\n      args.operationKey ?? `story:${story.legacyId}:delete:${Date.now()}`;\n    await ctx.scheduler.runAfter(0, internal.editorSideEffects.onStoryDeleted, {\n      operationKey,\n      storyId: story.legacyId,\n      storyName: story.name,\n      courseId: course.legacyId,\n      actorName,\n      actorLegacyUserId,\n    });\n\n    return {\n      id: story.legacyId,\n      name: story.name,\n      course_id: course.legacyId,\n      text: content?.text ?? \"\",\n    };\n  },\n});\n\nexport const importStory = mutation({\n  args: {\n    sourceLegacyStoryId: v.number(),\n    targetLegacyCourseId: v.number(),\n    operationKey: v.optional(v.string()),\n  },\n  returns: v.union(\n    v.null(),\n    v.object({\n      id: v.number(),\n      course_id: v.number(),\n      text: v.string(),\n      name: v.string(),\n    }),\n  ),\n  handler: async (ctx, args) => {\n    await requireContributorOrAdmin(ctx);\n    const authorLegacyUserId = await requireSessionLegacyUserId(ctx);\n    const identity = (await ctx.auth.getUserIdentity()) as {\n      name?: string | null;\n    } | null;\n    const actorName = identity?.name?.trim() || `user_${authorLegacyUserId}`;\n    const [sourceStory, targetCourse] = await Promise.all([\n      ctx.db\n        .query(\"stories\")\n        .withIndex(\"by_legacy_id\", (q) =>\n          q.eq(\"legacyId\", args.sourceLegacyStoryId),\n        )\n        .unique(),\n      ctx.db\n        .query(\"courses\")\n        .withIndex(\"by_id_value\", (q) =>\n          q.eq(\"legacyId\", args.targetLegacyCourseId),\n        )\n        .unique(),\n    ]);\n\n    if (!sourceStory || !targetCourse) return null;\n\n    const sourceContent = await ctx.db\n      .query(\"story_content\")\n      .withIndex(\"by_story\", (q) => q.eq(\"storyId\", sourceStory._id))\n      .unique();\n    if (!sourceContent) return null;\n\n    const last = await ctx.db\n      .query(\"stories\")\n      .withIndex(\"by_legacy_id\")\n      .order(\"desc\")\n      .take(1);\n    const newLegacyId = Math.max(1, Number(last[0]?.legacyId ?? 0) + 1);\n\n    const now = Date.now();\n\n    const newStoryId = await ctx.db.insert(\"stories\", {\n      legacyId: newLegacyId,\n      duo_id: sourceStory.duo_id,\n      name: sourceStory.name,\n      set_id: sourceStory.set_id,\n      set_index: sourceStory.set_index,\n      authorId: authorLegacyUserId,\n      authorChangeId: authorLegacyUserId,\n      date: now,\n      change_date: now,\n      public: false,\n      imageId: sourceStory.imageId,\n      courseId: targetCourse._id,\n      status: \"draft\",\n      approvalCount: 0,\n      deleted: false,\n      todo_count: 0,\n    });\n\n    const lastUpdated = Date.now();\n\n    await ctx.db.insert(\"story_content\", {\n      storyId: newStoryId,\n      text: sourceContent.text,\n      json: sourceContent.json,\n      lastUpdated,\n    });\n    await upsertPublicStoryContent(\n      ctx,\n      newStoryId,\n      sourceContent.json,\n      lastUpdated,\n    );\n\n    const operationKey =\n      args.operationKey ??\n      `story:${newLegacyId}:import:${args.targetLegacyCourseId}:${Date.now()}`;\n\n    await ctx.scheduler.runAfter(\n      0,\n      internal.editorSideEffects.onStoryImported,\n      {\n        operationKey,\n        storyId: newLegacyId,\n        storyName: sourceStory.name,\n        courseId: args.targetLegacyCourseId,\n        text: sourceContent.text,\n        actorName,\n        actorLegacyUserId: authorLegacyUserId,\n      },\n    );\n\n    return {\n      id: newLegacyId,\n      course_id: args.targetLegacyCourseId,\n      text: sourceContent.text,\n      name: sourceStory.name,\n    };\n  },\n});\n"
  },
  {
    "path": "convex/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"allowJs\": true,\n    \"strict\": true,\n    \"moduleResolution\": \"Bundler\",\n    \"jsx\": \"react-jsx\",\n    \"skipLibCheck\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"target\": \"ESNext\",\n    \"lib\": [\"ES2021\", \"dom\"],\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"ESNext\",\n    \"isolatedModules\": true,\n    \"noEmit\": true\n  },\n  \"include\": [\"./**/*\"],\n  \"exclude\": [\"./_generated\"]\n}\n"
  },
  {
    "path": "convex/tsconfig.node.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"types\": [\"node\"]\n  },\n  \"include\": [\"./editorSideEffects.ts\"]\n}\n"
  },
  {
    "path": "convex/userPreferences.ts",
    "content": "import { query, mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { getSessionLegacyUserId } from \"./lib/authorization\";\n\nconst storyPreferencesValidator = v.object({\n  hasSavedPreference: v.boolean(),\n  hideStoryQuestions: v.boolean(),\n});\n\nexport const getCurrentStoryPreferences = query({\n  args: {},\n  returns: storyPreferencesValidator,\n  handler: async (ctx) => {\n    const identity = (await ctx.auth.getUserIdentity()) as {\n      tokenIdentifier?: string | null;\n    } | null;\n    const tokenIdentifier = identity?.tokenIdentifier;\n    if (!tokenIdentifier) {\n      return {\n        hasSavedPreference: false,\n        hideStoryQuestions: false,\n      };\n    }\n\n    const preference = await ctx.db\n      .query(\"user_preferences\")\n      .withIndex(\"by_token_identifier\", (q) =>\n        q.eq(\"tokenIdentifier\", tokenIdentifier),\n      )\n      .unique();\n\n    return {\n      hasSavedPreference: preference !== null,\n      hideStoryQuestions: preference?.hideStoryQuestions ?? false,\n    };\n  },\n});\n\nexport const setCurrentStoryPreferences = mutation({\n  args: {\n    hideStoryQuestions: v.boolean(),\n  },\n  returns: storyPreferencesValidator,\n  handler: async (ctx, args) => {\n    const identity = (await ctx.auth.getUserIdentity()) as {\n      tokenIdentifier?: string | null;\n    } | null;\n    const tokenIdentifier = identity?.tokenIdentifier;\n    if (!tokenIdentifier) {\n      throw new Error(\"Unauthorized\");\n    }\n\n    const legacyUserId = await getSessionLegacyUserId(ctx);\n    const existingPreference = await ctx.db\n      .query(\"user_preferences\")\n      .withIndex(\"by_token_identifier\", (q) =>\n        q.eq(\"tokenIdentifier\", tokenIdentifier),\n      )\n      .unique();\n\n    const updatedAt = Date.now();\n\n    if (existingPreference) {\n      await ctx.db.patch(existingPreference._id, {\n        legacyUserId: legacyUserId ?? undefined,\n        hideStoryQuestions: args.hideStoryQuestions,\n        updatedAt,\n      });\n    } else {\n      await ctx.db.insert(\"user_preferences\", {\n        tokenIdentifier,\n        legacyUserId: legacyUserId ?? undefined,\n        hideStoryQuestions: args.hideStoryQuestions,\n        updatedAt,\n      });\n    }\n\n    return {\n      hasSavedPreference: true,\n      hideStoryQuestions: args.hideStoryQuestions,\n    };\n  },\n});\n"
  },
  {
    "path": "database/stories/es-en-o/1_1_es-en-buenos-dias.txt",
    "content": "[DATA]\nfromLanguageName=Good Morning\nicon=783305780a6dad8e0e4eb34109d948e6a5fc2c35\nset=1|1\n\n[HEADER]\n> 早上好 \n~ good~morning\n^ zǎo~shang~hǎo\n$989/9a5413ae.mp3;3,50\n\n[LINE]\nSpeaker593: 早上好，|普丽提！\n~            good~morning  Priti \n^            zǎo~shang~hǎo Pǔ~lì~tí\n$6252/510799dc.mp3;2,50;2,362;4,525\n\n[LINE]\nSpeaker560: 早上好，|亲爱的。\n~           good~morning  darling \n^           zǎo~shang~hǎo qīn~ài~de\n$6252/7a0ca48a.mp3;2,50;2,362;3,488;2,425\n\n[LINE]\nSpeaker560: 我的|钥匙|在哪里？\n~           my  keys (are)~where\n^           wǒ~de yào~shi zài~nǎ~lǐ\n$6252/9827ee98.mp3;1,50;2,187;3,100;2,463;3,162\n\n[LINE]\nSpeaker593: 你的|钥匙？\n~            your keys   \n^            nǐ~de yào~shi\n$989/53efd362.mp3;1,50;2,162;3,63\n\n[MULTIPLE_CHOICE]\n> Priti can't find her keys.\n+ Yes，that's right.\n- No，that's wrong.\n\n[LINE]\nSpeaker560: 对，|我|需要|去上班。\n~           yes  I need   to~go~to~work    \n^           duì wǒ xū~yào qù~shàng~bān\n$6252/ec77e1c4.mp3;1,50;2,512;3,138;2,300;3,137\n\n[ARRANGE]\n> Tap what you hear\nSpeaker560: [(我|需要)​(我的|车)​(钥匙)！]\n~              I need my car keys    \n^              wǒ~xū~yào wǒ~de chē yào~shi\n$6252/2d6bc2fe.mp3;1,50;3,162;2,300;2,113;2,100;3,200\n\n[LINE]\nSpeaker593: 普丽提！|你的|钥匙|在这儿，|在桌子上！\n~          　Priti your keys (are)~here on~the~table\n^            Pǔ~lì~tí nǐ~de yào~shi zài~zhèr zài~zhuō~zi~shàng\n$6252/c6326dbc.mp3;3,50;2,1150;2,162;3,75;2,325;3,150;2,500;3,163;2,337\n\n[POINT_TO_PHRASE]\n> Choose the option that means \"tired.\"\nSpeaker560: (对不起)，​(亲爱的)。​(我​很)(+累)。​我​(工作)​(很忙)！\n~            sorry darling I (am)~tired I (at)~work (am)~very~busy\n^            duì~bu~qǐ qīn~ài~de wǒ~hěn lèi wǒ gōng~zuò hěn~máng\n$6252/251e4b84.mp3;3,50;3,725;2,462;2,713;2,150;2,200;2,800;3,162;2,338;2,175\n\n[LINE]\nSpeaker593: 你|想|喝|咖啡|吗？\n~           you want to~drink coffee (question~marker)\n^           nǐ xiǎng hē kā~fēi ma\n$6252/1db91224.mp3;1,50;2,150;2,187;3,125;2,363\n\n[LINE]\nSpeaker560: 好，|谢谢。\n~            yes  thanks  \n^            hǎo xiè~xie\n$6252/239d1c4e.mp3;1,50;3,512\n\n[LINE]\nSpeaker593: 好了，|这儿|给|你，|亲爱的。\n~           OK this (is)~for you darling\n^           hǎo~le zhèr gěi nǐ qīn~ài~de\n$6252/3f5aff8c.mp3;1,50;2,212;3,525;2,150;2,150;3,475;2,325\n\n[LINE]\nSpeaker560: 糖|~在哪里？|哦，|在这儿。 \n~  sugar (is)~where oh (it's)~here  \n^  táng zài~nǎ~lǐ ó zài~zhèr\n$6252/f24875a2.mp3;1,50;2,237;3,138;2,975;2,237;3,175\n\n[MULTIPLE_CHOICE]\n> Hmm... what is Priti doing?\n- pouring her coffee on the table\n- putting sugar on her keys\n+ looking for some sugar for her coffee\n\n[LINE]\n> 普丽提|喝|她的|咖啡。 \n~ Priti drinks her coffee\n^ Pǔ~lì~tí hē tā~de kā~fēi\n$6252/80ea594a.mp3;3,50;2,987;2,225;2,150;3,100\n\n[LINE]\nSpeaker560: 呸！\n~           yuck\n^           pēi\n$6252/73a88754.mp3;1,50\n\n[LINE]\nSpeaker593: 怎么了？\n~            what's~wrong\n^            zěn~me~le\n$6252/7eff02b8.mp3;2,50;2,225\n\n[LINE]\nSpeaker560: 这|不是|糖，|是|盐！\n~           this isn't sugar it's salt\n^           zhè bú~shì táng shì yán\n$6252/b08af1e8.mp3;1,50;3,162;2,263;2,475;2,212\n\n[LINE]\nSpeaker593: 普丽提，|你|的确|是|累|坏了！\n~            Priti  you indeed are tired to~the~extreme   \n^            Pǔ~lì~tí nǐ dí~què shì lèi huài~le\n$6252/f09a9af4.mp3;3,50;2,900;3,137;2,363;3,112;2,425\n\n[LINE]\nSpeaker560: 对，|我|需要|一杯新的|咖啡，|加糖的，|而|不加盐！\n~           yes  I need  a~new~cup~of coffee with~sugar and without~salt \n^           duì wǒ xū~yào yì~bēi~xīn~de kā~fēi jiā~táng~de ér bù~jiā~yán\n$6252/3b467ce4.mp3;1,50;2,512;3,150;2,300;2,113;2,162;2,175;3,88;3,662;2,438;2,437;2,125;2,113;2,200\n\n[MULTIPLE_CHOICE]\n> Priti was so tired that...\n- ...she fell asleep in the kitchen.\n+ ...she put salt in her coffee.\n- ...she put her keys in her coffee.\n\n[MATCH]\n> Tap the pairs\n- 需要 <> need\n- 亲爱的 <> darling\n- 咖啡 <> coffee\n- 钥匙 <> keys\n- 糖 <> sugar\n"
  },
  {
    "path": "database/stories/es-en-o/1_2_es-en-una-cita.txt",
    "content": "[DATA]\nfromLanguageName=A Date\nicon=df24f7756b139f6eda927eb776621b9febe1a3f1\nset=1|2\n\n[HEADER]\n> Una cita\n~ a   date\n\n[LINE]\n> Bea está en una cita en un restaurante.\n~ Bea is   on a   date in a  restaurant  \n\n[LINE]\nSpeaker507: ¡Hola!  ¿Cómo~estás? \n~            hello   how~are~you \n\n[LINE]\nSpeaker100: ¡Bien!\n~            fine \n\n[LINE]\nSpeaker100: ¿Qué  quieres     comer? \n~            what do~you~want to~eat \n\n[LINE]\nSpeaker507: Una ensalada.\n~           a   salad    \n\n[SELECT_PHRASE]\n> Select the missing phrase\nSpeaker507: Hoy   tengo  [un~partido~importante].\n~           today I~have  an~important~game      \n+ un partido importante\n- un batido importante\n- una parte imponente\n\n[LINE]\nSpeaker100: Ah, ¿te~gustan   los~deportes?\n~           oh   do~you~like (the)~sports \n\n[LINE]\nSpeaker507: Sí,  yo juego~al fútbol.\n~           yes  I  play     soccer \n\n[MULTIPLE_CHOICE]\n> Bea is ordering a salad because…\n+ …she is playing soccer today.\n- …her date said the salad is delicious.\n- …she just ate a whole pizza.\n\n[LINE]\nSpeaker100: ¡Yo~también!\n~            me~too     \n\n[LINE]\nSpeaker507: ¡Súper!\n~            great \n\n[LINE]\nSpeaker507: ¿De~dónde~eres?     \n~            where~are~you~from \n\n[LINE]\nSpeaker100: Soy  americano.\n~           I~am American  \n\n[ARRANGE]\n> Tap what you hear\nSpeaker100: [(Mi~padre)  (es~de~Cuba)   (y)   (mi~madre)  (es~de~México).]  \n~             my~father   is~from~Cuba   and   my~mother   is~from~Mexico   \n\n[LINE]\nSpeaker507: ¡Me~encanta Cuba!\n~            I~love     Cuba \n\n[LINE]\nSpeaker100: Cuba es muy  bonita.   \n~           Cuba is very beautiful \n\n[LINE]\nSpeaker507: ¿Tienes      mascotas?\n~            do~you~have pets     \n\n[LINE]\nSpeaker100: Tengo  dos gatos y   un perro.\n~           I~have two cats  and a  dog   \n\n[POINT_TO_PHRASE]\n> Choose the option that means \"also.\"\nSpeaker507: Yo (+también) (tengo) un (perro), (José).\n~           I    also      have   a   dog      José  \n\n[LINE]\nSpeaker100: ¿Eh? Yo~me~llamo Daniel…\n~            eh  my~name~is  Daniel \n\n[LINE]\nSpeaker100: ¿Tú~no~eres Gabriela?\n~            aren't~you Gabriela \n\n[LINE]\nSpeaker507: ¡No!\n~            no \n\n[LINE]\nSpeaker507: ¡Yo~me~llamo Bea!\n~            my~name~is  Bea \n\n[MULTIPLE_CHOICE]\n> Did you catch that? Bea and Daniel…\n- …bought tickets to Cuba.\n+ …were supposed to meet different people.\n- …are walking their dogs.\n\n[LINE]\n> Una mujer camina hacia   Daniel.\n~ a   woman walks  towards Daniel \n\n[LINE]\nSpeaker101: ¡Hola!  ¿Eres    Daniel? Soy  Gabriela.\n~            hello   are~you Daniel  I~am Gabriela \n\n[LINE]\nSpeaker100: No, lo~siento. Soy  José.\n~           no  I'm~sorry  I~am José \n\n[MULTIPLE_CHOICE]\n> Whoa! What just happened?\n- Bea changed her name to Gabriela.\n+ Daniel lied to his real date because he likes Bea.\n- Bea and Daniel's dogs played soccer.\n\n[MATCH]\n> Tap the pairs\n- y <> and\n- Soy <> I am\n- ensalada <> salad\n- Me encanta <> I love\n- Eh <> eh\n\n"
  },
  {
    "path": "database/stories/es-en-o/1_3_es-en-una-cosa.txt",
    "content": "[DATA]\nfromLanguageName=One Thing\nicon=717bd84875f83c678f64f124937a278061e0e778\nset=1|3\n\n[HEADER]\n> Una cosa \n~ one thing\n\n[LINE]\n> Lucy está en casa, con  su  nieta,         Lin.\n~ Lucy is   at home  with her granddaughter  Lin \n\n[LINE]\nSpeaker509: ¡Ay, no! Necesito pan   para mi sándwich.\n~            oh  no  I~need   bread for  my sandwich \n\n[LINE]\nSpeaker508: ¿Vas           al     supermercado?\n~            are~you~going to~the supermarket  \n\n[LINE]\nSpeaker509: Sí. \n~           yes \n\n[MULTIPLE_CHOICE]\n> Lin and Lucy have a lot of bread.\n- Yes, that's right.\n+ No, that's wrong.\n\n[LINE]\nSpeaker508: ¡Ah, necesito una cosa  del      supermercado!\n~            oh  I~need   one thing from~the supermarket  \n\n[LINE]\nSpeaker509: ¿Qué? \n~            what \n\n[LINE]\nSpeaker508: Un  tomate, por~favor.\n~           one tomato  please    \n\n[SELECT_PHRASE]\n> Select the missing phrase\nSpeaker508: Es   para [mi~ensalada].\n~           it's for   my~salad     \n- linda y salada\n+ mi ensalada\n- la mía es salada\n\n[LINE]\nSpeaker509: Está~bien.\n~           OK        \n\n[LINE]\nSpeaker508: ¡Gracias!  \n~            thank~you \n\n[POINT_TO_PHRASE]\n> Choose the option that means \"also.\"\nSpeaker508: ¡Ah! Y   (+también) (quiero) (tres)  (manzanas)…\n~            oh  and   also      I~want   three   apples    \n\n[LINE]\nSpeaker509: Está~bien.\n~           OK        \n\n[LINE]\nSpeaker508: … y   jugo~de~naranja…\n~             and orange~juice    \n\n[LINE]\nSpeaker509: Lin…\n~           Lin \n\n[LINE]\nSpeaker508: … y   leche, por~favor.\n~             and milk   please    \n\n[LINE]\nSpeaker509: Mmm… tengo  una idea.\n~           hmm  I~have an  idea \n\n[ARRANGE]\n> Tap what you hear\nSpeaker509: [(Aquí) (está) (el)  (dinero), (Lin).]\n~             here   is     the   money     Lin   \n\n[LINE]\nSpeaker508: ¡¿Qué?! \n~             what  \n\n[LINE]\nSpeaker509: Yo quiero una cosa  del      supermercado: pan.  \n~           I  want   one thing from~the supermarket   bread \n\n[MULTIPLE_CHOICE]\n> Why did Lucy give Lin money?\n- Lin is opening a sandwich shop.\n+ She wants Lin to go to the supermarket.\n- Lin is going to a restaurant for lunch.\n\n[MATCH]\n> Tap the pairs\n- idea <> idea\n- una <> one\n- Yo <> I\n- nieta <> granddaughter\n- por favor <> please\n\n"
  },
  {
    "path": "database/stories/nl-en/0_1_es-en-el-pastel-de-dragones.txt",
    "content": "[DATA]\nfromLanguageName=The Dragon Cake\nicon=c2fbecd45802dc974e3ec727a0206cfec66fd87f\nset=0|1 #28|1\ndeleted=1\n\n[HEADER]\n> El  pastel~de~dragones\n~ the dragon~cake       \n\n[LINE]\n> Bea está con  Vikram. Él está~decorando un~pastel para la~boda     de Kara, una~amiga de Bea.  \n~ Bea is   with Vikram  he is~decorating  a~cake    for  the~wedding of Kara  a~friend  of Bea's \n\n[LINE]\nSpeaker507: ¡Me~encanta cómo decoraste     el~pastel!\n~            I~love     how  you~decorated the~cake  \n\n[LINE]\nSpeaker507: Los~novios          están~montando dragones, ¡como los~personajes de   la~serie~favorita~de~Kara~y~David!\n~           the~bride~and~groom are~riding     dragons    like the~characters from Kara~and~David's~favorite~show    \n\n[MULTIPLE_CHOICE]\n> The cake is decorated to look like a TV show.\n+ Yes, that's right\n- No, that's wrong.\n\n[LINE]\nSpeaker593: Kara es mi primera clienta  y   quiero hacer un excelente trabajo.\n~           Kara is my first   customer and I~want to~do an excellent job     \n\n[ARRANGE]\n> Tap what you hear\n> [(Bea) (recibe)   (un~mensaje) (de)   (Kara).]\n~   Bea   receives   a~message    from   Kara   \n\n[LINE]\nSpeaker507: ¡Ay, no! ¡Kara canceló  la~boda!    \n~            oh  no   Kara canceled the~wedding \n\n[LINE]\nSpeaker593: ¿Qué?  ¿Por~qué?\n~            what   why     \n\n[CONTINUATION]\n> What comes next?\nSpeaker507: ¡David se~fue~del país    con  [otra    mujer]!\n~            David left~the   country with  another woman  \n+ otra    mujer\n~ another woman\n- un~dragón\n~ a~dragon \n- muchos pasteles\n~ many   cakes   \n\n[LINE]\nSpeaker593: ¡Qué~horrible!   \n~            that's~terrible \n\n[LINE]\nSpeaker507: No~lo~puedo~creer. \n~           I~can't~believe~it \n\n[SELECT_PHRASE]\n> Select the missing phrase\nSpeaker593: Lo~sé…  Quería   hacer   [el~pastel~perfecto] para Kara.\n~           I~know  I~wanted to~make  the~perfect~cake    for  Kara \n- el pastel de efecto\n- el pincel perfecto\n+ el pastel perfecto\n\n[LINE]\nSpeaker507: Tengo  una~idea.\n~           I~have an~idea  \n\n[LINE]\n> Bea redecora    el~pastel.\n~ Bea redecorates the~cake  \n\n[LINE]\nSpeaker593: Espera… parece~que    los~dragones se~están~comiendo~al novio.\n~           wait    it~looks~like the~dragons  are~eating~the       groom \n\n[MULTIPLE_CHOICE]\n> Bea redecorated the cake because…\n+ …Kara canceled the wedding.\n- …Vikram wants to keep the dragons.\n- …Kara asked her to change it.\n\n[LINE]\n> Bea toma  una~foto  y   se~la~envía a  Kara.\n~ Bea takes a~picture and sends~it    to Kara \n\n[LINE]\nSpeaker507: ¡Kara me~respondió!\n~            Kara answered~me  \n\n[LINE]\nSpeaker593: ¿Qué  dijo?       \n~            what did~she~say \n\n[LINE]\nSpeaker507: Se~siente muy  triste, pero le~encanta el~pastel.\n~           she~feels very sad     but  she~loves  the~cake  \n\n[LINE]\nSpeaker593: ¿De~verdad? ¡Estoy muy  contento!\n~            really      I~am  very happy    \n\n[MULTIPLE_CHOICE]\n> Why does Kara like the cake after all?\n+ The dragons are eating the groom.\n- She decided to marry Bea.\n- She's excited for her wedding.\n\n[MATCH]\n> Tap the pairs\n- los personajes <> the characters\n- Estoy <> I am\n- Por qué <> why\n- primera <> first\n- una foto <> a picture\n\n"
  },
  {
    "path": "database/stories/nl-en/0_2_es-en-es-amor.txt",
    "content": "[DATA]\nfromLanguageName=Is It Love?\nicon=d7461e0ccc18067f34ff9c385653ac769bde4875\nset=0|2 #28|2\ndeleted=1\n\n[HEADER]\n> ¿Es    amor?\n~  is~it love\n\n[LINE]\n> Es~hora~de    un nuevo episodio de nuestro programa~de~citas: \"¿Es    amor?\".\n~ it's~time~for a  new   episode  of our     dating~show          is~it love\n\n[LINE]\n> Eddy conoció~a muchas mujeres durante el~programa…\n~ Eddy met       many   women   during  the~show\n\n[LINE]\n> … ¡pero ahora tiene~que elegir~a    una!\n~    but  now   he~has~to (to)~choose one\n\n[MULTIPLE_CHOICE]\n> On a  television show, Eddy has  to…\n~ On to TV         Show  Eddy have to\n+ …choose a  woman to go out with.\n~  choose to Woman to Go out with\n- …eat a  lot of food.\n~  Eat to Lot of Food\n- …drive really fast.\n~  drive Really fast\n\n[LINE]\n> Él está~hablando~con Rosa en este~momento.\n~ he is~talking~to     Rosa in this~moment\n\n[LINE]\nSpeaker414: No~puedo creer        que  tengo~que elegir~a    una~sola mujer. ¡Esto es muy  estresante!\n~           I~can't  (to)~believe that I~have~to (to)~choose just~one woman   this is very stressful\n\n[LINE]\nSpeaker338: ¡No, es   fácil! Yo creo  que  deberías   elegirme~a~mí.\n~            no  it's easy   I  think that you~should (to)~choose~me\n\n[ARRANGE]\n> Tap what you hear\nSpeaker414: [(Pero) (es)   (una) (decisión~difícil).]\n~             but    it's   a     difficult~decision\n\n[LINE]\nSpeaker338: ¡¿Ah, sí?!\n~             oh  yes\n\n[SELECT_PHRASE]\n> Select the missing phrase\nSpeaker338: Yo creía   que  [te~gustaba]…\n~           I  thought that  you~liked~me\n- le gustaban\n- te gastabas\n+ te gustaba\n\n[LINE]\nSpeaker414: Sí,  pero también~me~gusta Marina…\n~           yes  but  I~also~like      Marina\n\n[LINE]\nSpeaker338: Bueno, sí,  Marina es hermosa.\n~           well   yes  Marina is beautiful\n\n[LINE]\nSpeaker414: Ella es interesante y   graciosa.\n~           she  is interesting and funny\n\n[ARRANGE]\n> Tap what you hear\nSpeaker338: [(Y)   (ella) (toca)  (muy~bien~el~violín).]\n~             and   she    plays   the~violin~very~well\n\n[LINE]\nSpeaker414: ¡Yo sé!   A~ustedes~dos~les~encanta la~música.\n~            I  know  you~both~love             music\n\n[LINE]\nSpeaker338: Sí,  tenemos muchas cosas  en común.\n~           yes  we~have many   things in common\n\n[LINE]\nSpeaker414: ¡Sí!  A~las~dos~les~gustan las~películas~de~aventura y   los~museos~de~arte.\n~            yes  you~both~like        adventure~movies          and art~museums\n\n[LINE]\nSpeaker414: ¡Y   las~dos  escriben poemas!\n~            and you~both write    poems\n\n[LINE]\nSpeaker338: Sí,  sus~poemas son hermosos…\n~           yes  her~poems  are beautiful\n\n[LINE]\nSpeaker414: ¡No~sé        qué  hacer!\n~            I~don't~know what to~do\n\n[LINE]\nSpeaker338: Ya~sé  lo~que deberías   hacer…\n~           I~know what   you~should (to)~do\n\n[LINE]\nSpeaker414: ¡¿Qué?!\n~             what\n\n[LINE]\nSpeaker338: Olvídate de    Marina y   de    mí. ¡Elige~a alguien más!\n~           forget   about Marina and about me   choose  someone else\n\n[MULTIPLE_CHOICE]\n> Did you catch that? Rosa doesn't…\n- …like Marina's poetry.\n- …like funny movies.\n+ …want Eddy to go out with her or Marina.\n\n[LINE]\nSpeaker414: ¡¿Qué?!  ¿Por~qué?\n~             what    why\n\n[LINE]\nSpeaker338: Porque…  yo quiero elegir~a  Marina.\n~           because  I  want   to~choose Marina\n\n[LINE]\nSpeaker414: ¿Ah?\n~            oh\n\n[LINE]\n> En el  próximo episodio de \"¿Es    amor?\"… ¿¡qué  va~a~decir~Marina!?\n~ in the next    episode  of   is~it love      what is~Marina~going~to~say\n\n[MULTIPLE_CHOICE]\n> What did Rosa realize?\n- Marina writes terrible poetry.\n+ She likes Marina and wants to go out with her.\n- She doesn't like Marina or Eddy.\n\n[MATCH]\n> Tap the pairs\n- A las dos les gustan <> you both like\n- creer <> believe\n- una <> one\n- sus poemas <> her poems\n- toca <> plays\n\n"
  },
  {
    "path": "database/stories/nl-en/0_3_es-en-la-pelea-de-boxeo.txt",
    "content": "[DATA]\nfromLanguageName=The Boxing Match\nicon=149224cb1a7c361279bece453d459dc57ba243a5\nset=0|3 #28|3\ndeleted=1\n\n[HEADER]\n> La  pelea~de~boxeo\n~ the boxing~match  \n\n[LINE]\n> Lin y   su  abuela,      Lucy, están~viendo una pelea~de~boxeo en un bar.\n~ Lin and her grandmother  Lucy  are~watching a   boxing~match   at a  bar \n\n[LINE]\nSpeaker508: Abue,    me~voy      después~de esta bebida.\n~           Grandma  I'm~leaving after      this drink  \n\n[SELECT_PHRASE]\n> Select the missing word or phrase\nSpeaker509: ¡Pero es   la  pelea~más~importante [del~año]!    \n~            but  it's the most~important~match  of~the~year  \n+ del año\n- del baño\n- tacaño\n\n[LINE]\nSpeaker508: Odio   el~boxeo. Es   aburridísimo.\n~           I~hate boxing    it's very~boring  \n\n[MULTIPLE_CHOICE]\n> Boxing is Lin's favorite sport.\n+ No, that's wrong.\n- Yes, that's right.\n\n[LINE]\nSpeaker509: ¿Qué~dices?           ¡Es   muy  emocionante!\n~            what~are~you~saying   it's very exciting    \n\n[POINT_TO_PHRASE]\n> Choose the option that means \"fighting.\"\nSpeaker508: (Son)      (solo) dos (hombres) (+peleando) con  guantes~enormes.\n~            they~are   just  two  men        fighting  with huge~gloves     \n\n[LINE]\nSpeaker509: No~son       solo hombres… ¡Son      atletas! \n~           they~are~not just men       they~are athletes \n\n[LINE]\nSpeaker508: Solo~quieren   ganar  un estúpido trofeo.\n~           they~just~want to~win a  stupid   trophy \n\n[MULTIPLE_CHOICE]\n> Lin says that boxing is…\n- …a good way to make new friends.\n+ …just two men fighting for a trophy.\n- …how she lost a tooth.\n\n[LINE]\n> Lin pone   su  vaso  sobre la~mesa   y   se~levanta.\n~ Lin places her glass on    the~table and gets~up    \n\n[LINE]\nSpeaker509: ¿Adónde~vas?         \n~            where~are~you~going \n\n[CONTINUATION]\n> What comes next?\nSpeaker508: Voy~a     casa a~ver    \"¿Es    amor?\" en [la~tele].\n~           I'm~going home to~watch   is~it love   on  TV       \n- el~sándwich \n~ the~sandwich\n+ la~tele\n~ TV     \n- mis calcetines\n~ my  socks     \n\n[LINE]\nSpeaker509: ¿El~programa donde veinte hombres se~pelean por la  misma mujer?\n~            the~show    where twenty men     fight     for the same  woman \n\n[ARRANGE]\n> Tap what you hear\nSpeaker509: ¡[(Y)   (lo~peor)   (es~que)  (no~ganan)       (ningún~trofeo)!]\n~              and   the~worst   is~that   they~don't~win   any~trophy      \n\n[LINE]\nSpeaker508: No, pero pelean     por amor.\n~           no  but  they~fight for love \n\n[LINE]\nSpeaker509: ¿Y   tú  dices que  el~boxeo es aburrido?\n~            and you say   that boxing   is boring   \n\n[MULTIPLE_CHOICE]\n> Did you catch that? Lucy thinks…\n- …twenty women are boxing one man.\n- …Lin would be a great boxer.\n+ …fighting over a woman is boring.\n\n[MATCH]\n> Tap the pairs\n- muy <> very\n- no ganan <> they don't win\n- es <> it's\n- la mesa <> the table\n- aburridísimo <> very boring\n\n"
  },
  {
    "path": "database/stories/nl-en/0_4_es-en-el-neumatico-pinchado.txt",
    "content": "[DATA]\nfromLanguageName=The Flat Tire\nicon=76e5ba84900b1c2ebd321eafd9329980919ccede\nset=0|4 #28|4\ndeleted=1\n\n[HEADER]\n> De lekke~band\n~ the flat~tire        \n\n[LINE]\n> Vikram y   Priti están de~vacaciones.\n~ Vikram and Priti are   on~vacation   \n\n[LINE]\n> Ellos están~manejando y   el~carro hace  un ruido~fuerte.\n~ they  are~driving     and the~car  makes a  loud~noise   \n\n[LINE]\n> Vikram para  el~carro a~un~lado   de la~carretera.\n~ Vikram stops the~car  at~the~side of the~road     \n\n[LINE]\nSpeaker593: ¡Ay, no!\n~            oh  no \n\n[LINE]\nSpeaker560: ¿Cuál es el~problema?\n~            what is the~problem \n\n[LINE]\nSpeaker593: El~carro tiene un neumático~pinchado y   no~tengo     otro.       \n~           the~car  has   a  flat~tire          and I~don't~have another~one \n\n[MULTIPLE_CHOICE]\n> Uh oh! What's wrong with the car?\n- It ran out of gas.\n- It's too small.\n+ It has a flat tire.\n\n[LINE]\nSpeaker560: Podemos caminar.   El~apartamento está a~dos~kilómetros.   \n~           we~can  (to)~walk  the~apartment  is   two~kilometers~away \n\n[LINE]\nSpeaker593: ¡Pero está~lloviendo!\n~            but  it's~raining   \n\n[CONTINUATION]\n> What comes next?\nSpeaker560: ¡No~hay      problema! ¡Tengo  [un~paraguas]!\n~            there~is~no problem    I~have  an~umbrella  \n- el~pelo~muy~bonito\n~ very~nice~hair    \n+ un~paraguas\n~ an~umbrella\n- dos gatos\n~ two cats \n\n[SELECT_PHRASE]\n> Select the missing phrase\n> Ellos comienzan~a [caminar~en~la~lluvia].\n~ they  begin        to~walk~in~the~rain   \n- caminar con la novia\n- cocinar en la lluvia\n+ caminar en la lluvia\n\n[LINE]\nSpeaker593: Lo~siento~mucho, Priti. ¡Estas~son~nuestras~peores~vacaciones!\n~           I'm~so~sorry     Priti   this~is~our~worst~vacation           \n\n[LINE]\nSpeaker560: ¡Por~supuesto~que~no! Estas~vacaciones~me~recuerdan~a nuestra luna~de~miel.\n~            of~course~not        this~vacation~reminds~me~of     our     honeymoon    \n\n[LINE]\nSpeaker593: ¡Sí! \n~            yes \n\n[LINE]\nSpeaker560: Tuvimos problemas con  el~carro y   caminamos diez kilómetros para llegar      al     hotel.\n~           we~had  problems  with the~car  and we~walked ten  kilometers to   (to)~arrive at~the hotel \n\n[LINE]\nSpeaker593: Sí,  y   yo olvidé mis llaves en la~habitación. ¿Recuerdas       que  tuve~que entrar      por     la~ventana?\n~           yes  and I  forgot my  keys   in the~room        do~you~remember that I~had~to (to)~get~in through the~window \n\n[MULTIPLE_CHOICE]\n> Hmm… why did Vikram climb through the window?\n+ He didn't have the room key.\n- He was training to be a spy.\n- Priti locked him out.\n\n[LINE]\nSpeaker560: Sí,  y   después abriste    la~puerta con  flores  en las~manos. \n~           yes  and then    you~opened the~door  with flowers in your~hands \n\n[LINE]\nSpeaker593: ¡Qué~día~tan~hermoso! \n~            what~a~beautiful~day \n\n[ARRANGE]\n> Tap what you hear\n> [(Ellos) (llegan) (al)     (apartamento).]\n~   they    arrive   at~the   apartment     \n\n[LINE]\nSpeaker560: ¡Llegamos!   Vamos~a~entrar, tengo~frío.\n~            we~arrived  let's~go~in     I'm~cold   \n\n[LINE]\nSpeaker593: Espera… ¿dónde están las~llaves?\n~           wait     where are   the~keys   \n\n[MULTIPLE_CHOICE]\n> How is this vacation like Vikram and Priti's honeymoon?\n+ They had a car problem and Vikram forgot the keys.\n- They lost their phones and needed a map.\n- They swam in the ocean and made a nice dinner.\n\n[MATCH]\n> Tap the pairs\n- caminar en la lluvia <> to walk in the rain\n- Priti <> Priti\n- y <> and\n- están manejando <> are driving\n- hace <> makes\n\n"
  },
  {
    "path": "database/stories/nl-en/1_1_es-buenos-dias.txt",
    "content": "[DATA]\nfromLanguageName=Good morning\nicon=783305780a6dad8e0e4eb34109d948e6a5fc2c35\nset=1|1\napprovals=11,12\npublic=1\n\nicon_Jan=https://stories-cdn.duolingo.com/image/f118359885a5dc9babe4808c746e1c15ea9cd6f4.svg\nspeaker_Marian=Lotte\nspeaker_Jan=Ruben\n\n[HEADER]\n> Goedemorgen! \n~ Good~morning \n$43/2725ef44.mp3;11,50\n\n[LINE]\n> Jan is thuis met  zijn vrouw, Marian.\n~ ~   is home  with his  wife   ~      \n$43/2a532e34.mp3;3,50;3,350;6,200;4,350;5,162;6,200;7,675\n\n[LINE]\nSpeaker15: Hoi Marian.\n~           Hi  Marian \n$43/2c75faa2.mp3;3,50;7,325\n\n[LINE]\nSpeaker292: Morgen, schat.\n~           Morning  darling \n$43/ab6157c8.mp3;6,50;6,712\n\n[MULTIPLE_CHOICE]\n> What does \"schat\" mean?\n+ Darling\n- Friend\n- Brother\n\n[LINE]\nSpeaker292: Weet~jij    waar mijn lesboek Engels is? \n~           do~you~know Where  my textbook English is \n$43/eb4efbec.mp3;4,50;4,337;5,275;5,138;8,200;7,525;3,375\n\n[LINE]\nSpeaker15: Sorry?\n~          Sorry \n$43/a6e81e90.mp3;5,50\n\n[SELECT_PHRASE]\n> Select the missing phrase\nSpeaker292: Ik heb  [een examen~Engels] op de  universiteit.\n~           I  have  an  English~exam   at the university   \n$43/bbe45be2.mp3;2,50;4,175;4,175;7,125;7,662;3,413;3,150;13,50\n- en Engels examen\n+ een examen Engels\n- een Engelse element\n\n[LINE]\nSpeaker292: Waar  is toch               mijn boek?!\n~           Where is (emphazising~word) my   book  \n$43/dd754028.mp3;4,50;3,337;5,188;5,150;5,275\n\n[LINE]\nSpeaker15: Het ligt daar,       op de  tafel.\n~          It  is   over~there  on the table \n$43/04ded8de.mp3;3,50;5,225;5,237;3,488;3,150;6,100\n\n[POINT_TO_PHRASE]\n> Click on the option meaning \"tired\".\nSpeaker292: (Ik) (ben) (+moe) (Jan{jan}).   (Ik) (werk) (zoveel).     \n~            I   am  tired  ~      I   work  so~much\n$43/297a01e6.mp3;2,50;4,162;4,250;4,188;3,1287;5,175;7,400\n\n[LINE]\nSpeaker15: Wil~je      een~kop  koffie?\n~          Do~you~want a~cup~of coffee \n$43/3631cbb2.mp3;3,50;3,300;4,75;4,87;7,250\n\n[LINE]\nSpeaker292: Ja,  graag!\n~           Yes  thanks \n$43/39f70b04.mp3;2,50;6,475\n\n[ARRANGE]\n> Tap what you hear\nSpeaker15: [(Met)  (of) (zonder)  (melk)]?\n~            With   or   without   milk   \n$43/57ec8bd6.mp3;3,50;3,325;7,125;5,350\n\n[LINE]\nSpeaker292: Zwart, graag.\n~           Black, please. \n$43/54729340.mp3;5,50;6,600\n\n[LINE]\nSpeaker15: Alsjeblieft.\n~          Here~you~are \n$43/376d2726.mp3;11,50\n\n[LINE]\n> Marian doet suiker in   haar kopje.\n~ ~      puts sugar  into her  cup   \n$43/6484c3ea.mp3;6,50;5,550;7,187;3,475;5,125;6,238\n\n[CONTINUATION]\n> What comes next?\n> Ze   drinkt [haar koffie].\n~ She drinks  her  coffee  \n$43/68a9b9e4.mp3;2,50;7,287;5,363;7,237\n- haar melk\n+ haar koffie\n- haar boek\n\n[LINE]\nSpeaker292: Gadver!\n~           Damn   \n$43/6e0577e8.mp3;6,50\n\n[LINE]\nSpeaker15: Wat? \n~          What \n$43/714cb542.mp3;3,50\n\n[LINE]\nSpeaker292: Het  is zout, geen suiker!\n~           That is salt not sugar\n$43/71d81310.mp3;3,50;3,200;5,187;5,538;7,375\n\n[LINE]\nSpeaker15: Je  bent echt   heel moe,   Marian.\n~          You are  really very tired ~      \n$43/80f99fe4.mp3;2,50;5,150;5,275;5,212;4,275;7,438\n\n[CONTINUATION]\n> Complete the sentence\n> Marian was zo moe,   dat  ze  [zout     in haar koffie deed  in~plaats~van suiker].\n~ ~      was so tired that she  put~salt in her  coffee (put) instead~of    sugar  \n$43/8df42b9c.mp3;6,50;4,537;3,213;4,212;4,563;3,187;5,125;3,350;5,113;7,162;5,375;3,275;7,125;4,338;7,150\n- in~slaap~viel tijdens~het~praten met Jan.\n~ fell~asleep   while~talking      to  ~   \n+ zout     in haar koffie deed  in~plaats~van suiker.\n~ put~salt in her  coffee (put) instead~of    sugar  \n- haar        kopje liet~vallen terwijl ze~aan~het~drinken~was.\n~ dropped~her cup   (dropped)   when    she~was~drinking       \n\n[MATCH]\n> Tap the pairs\n- with <> met\n- at home <>thuis\n- the wife <> de vrouw (de echtgenote)\n- darling <> schat\n- the salt <> het zout\n\n"
  },
  {
    "path": "database/stories/nl-en/1_2_es-una-cita.txt",
    "content": "[DATA]\nfromLanguageName=A date\nicon=df24f7756b139f6eda927eb776621b9febe1a3f1\nset=1|2\napprovals=11,12\npublic=1\n\nspeaker_Julie=Lotte\nspeaker_Anne=Lotte\nspeaker_Paul=Ruben\n\n[HEADER]\n> Een Afspraakje\n~ A   date      \n$56/badfe656.mp3;3,50;11,212\n\n[LINE]\n> Julia zit~in een restaurant voor een afspraakje.\n~ ~           is~at  a   restaurant for  a   date       \n$56/507cb990.mp3;5,50;4,512;3,250;4,138;11,87;5,600;4,175;11,125\n\n[LINE]\nSpeaker100: Wat  zou~je    willen eten?  \n~           What would~you like   for~dinner?\n$56/ef375bfe.mp3;3,50;4,212;3,200;7,113;5,250\n\n[MULTIPLE_CHOICE]\n> Paul wants to know ...\n- ... what the food is.\n+ ... what Julie wants to eat.\n- ... how much the food costs\n\n[LINE]\nSpeaker336: Een salade.\n~           A   salad  \n$56/b4270222.mp3;3,50;7,237\n\n[LINE]\nSpeaker336: Ik eet geen vlees.\n~           I  eat no   meat  \n$56/b1703792.mp3;2,50;4,262;5,200;6,300\n\n[LINE]\nSpeaker100: Ben je  vegetariër?  \n~           Are you a~vegetarian \n$56/8e8f50e6.mp3;3,50;3,175;11,100\n\n[LINE]\nSpeaker336: Ja. \n~           Yes \n$56/afa9d058.mp3;2,50\n\n[LINE]\nSpeaker100: Ik ook!\n~           Me too \n$56/943c340a.mp3;2,50;4,212\n\n[LINE]\nSpeaker336: Oh, leuk.\n~           ~   nice \n$56/ab4ac5bc.mp3;2,50;5,537\n\n[LINE]\nSpeaker336: Ben je  Nederlands?\n~           Are you Dutch      \n$56/a8684f4a.mp3;3,50;3,275;11,75\n\n[ARRANGE]\n> Put the words in the right order:\nSpeaker100: [(Nee), (ik) (ben) (Engels)] \n~             No     I    am    English  \n$56/971ccce8.mp3;3,50;3,637;4,150;7,225\n\n[LINE]\nSpeaker100: Mijn vader  is Canadees en  mijn moeder Spaans. \n~           My   father is Canadian and my   mother Spanish \n$56/994bea58.mp3;4,50;6,250;3,437;9,175;3,650;5,150;7,213;7,275\n\n[LINE]\nSpeaker336: Ik ben Canadees!\n~           I  am  Canadian \n$56/9f96a0ba.mp3;2,50;4,237;9,213\n\n[LINE]\nSpeaker100: Ik houd~van Canada!\n~           I  love     Canada \n$56/d846568a.mp3;2,50;5,200;4,262;7,188\n\n[SELECT_PHRASE]\n> Choose the best answer:\nSpeaker100: Heb~je      een  [huisdier]?         \n~           Do~you~have a     pet\n$56/ddacf426.mp3;3,50;3,225;4,125;9,100\n+ huisdier\n- huisdieren\n- thuisbeest\n\n[LINE]\nSpeaker336: Ja,  ik heb  drie  katten en  een hond.\n~           Yes  I  have three cats   and a   dog  \n$56/e1748498.mp3;2,50;3,712;4,150;5,213;7,262;3,350;4,113;5,125\n\n[LINE]\nSpeaker100: Ik heb  ook  katten en  een hond, Anne!\n~           I  have also cats   and a   dog   ~    \n$56/e5030206.mp3;2,50;4,200;4,212;7,200;3,375;4,150;5,125;5,563\n\n[LINE]\n> Julia kijkt verrast   naar Paul.\n~ ~     looks surprised to   ~    \n$56/18e9471e.mp3;5,50;6,475;8,325;5,450;5,162\n\n[LINE]\nSpeaker336: Anne? Maar ik heet      Julia !\n~           ~     But I  am~called ~      \n$56/30c86932.mp3;4,50;5,1237;3,263;5,125;6,275\n\n[LINE]\nSpeaker336: En  jij bent       Sebastiaan toch? \n~           And you are        ~          right \n$56/7cd97f28.mp3;2,50;4,262;5,250;11,250;5,750\n\n[LINE]\nSpeaker100: Wat? \n~           What \n$56/00ae35fc.mp3;3,50\n\n[MULTIPLE_CHOICE]\n> Paul en Julie zitten allebei met de verkeerde persoon aan tafel.\n+ Yes, that's true.\n- No, that's not right.\n\n[LINE]\n> Een vrouw komt   het restaurant binnen.\n~ A   woman enters the restaurant (komt~binnen=enters)\n$56/88899a24.mp3;3,50;6,175;5,375;4,225;11,112;7,638\n\n[LINE]\nSpeaker101: Hoi! Ben jij Paul? Ik ben Anne.\n~           Hi   Are you ~     I  am  ~    \n$56/06a4ba58.mp3;3,50;4,1350;4,250;5,150;3,1312;4,175;5,225\n\n[LINE]\nSpeaker100: Euh, nee. Ik ben Sebastiaan.\n~           ~    no   I  am  ~          \n$56/0d46a074.mp3;3,50;4,637;3,825;4,213;11,200\n\n[MULTIPLE_CHOICE]\n> Paul...\n- ... is moe van het praten met Julia.\n- ... heet eigenlijk Sebastiaan.\n+ ... liegt tegen Anne omdat hij Julia leuk vindt.\n\n[MATCH]\n> Tap the pairs\n- to be called <> heten\n- the vegetarian <> de vegetariër\n- the meat <> het vlees\n- the pet <> het huisdier\n- the woman <> de vrouw\n\n"
  },
  {
    "path": "database/stories/nl-en/1_3_es-una-cosa.txt",
    "content": "[DATA]\nfromLanguageName=One thing\nicon=717bd84875f83c678f64f124937a278061e0e778\nset=1|3\napprovals=11,12\npublic=1\n\nicon_Jorrit=https://stories-cdn.duolingo.com/image/8dedb551515218979bffa2ba590906d0ed1ee462.svg\nspeaker_Maartje=Lotte\nspeaker_Jorrit=Ruben\n\n[HEADER]\n> Iets\n$79/22e5c25c.mp3;4,50\n\n[LINE]\n> Maartje is thuis   met  haar broer   Sergio.\n~ ~       is at~home with her  brother ~      \n$79/24ed0aba.mp3;7,50;3,550;6,200;4,375;5,150;6,187;7,275\n\n[LINE]\nSpeaker856: Oh! Er    is geen brood meer.\n~           Oh  There is no   bread left \n$79/2970a7ea.mp3;2,50;3,1150;3,162;5,200;6,375;5,400\n\n[MULTIPLE_CHOICE]\n> Maartje saw there is more bread.\n- That’s true.\n+ That’s not true.\n\n[LINE]\nSpeaker366: Ga~je         naar de  supermarkt? \n~           Are~you~going to   the supermarket \n$79/2ff7d1ce.mp3;2,0;3,174;5,90;3,166;11,65\n\n[LINE]\nSpeaker856: Ja,  ik wil  brood      voor~de~lunch.   \n~           Yes  I  want some~bread for~the~lunch \n$79/35cc9a08.mp3;2,50;3,487;4,175;6,213;5,375;3,162;6,100\n\n[LINE]\nSpeaker366: Kun je  iets        voor~me kopen?\n~           Can you (something) (for~me) buy~something~for~me   \n$79/3b0db506.mp3;3,0;3,125;5,102;5,173;3,157;6,105\n\n[MULTIPLE_CHOICE]\n> Sergio wants to know, if\n- Maartje can buy bread\n+ Maartje can buy something for him\n- Maartje can buy cheese for him\n\n[LINE]\nSpeaker856: Wat~dan?\n~           What    \n$79/3fb21174.mp3;3,50;4,262\n\n[SELECT_PHRASE]\n> Choose the correct word:\nSpeaker366: [Een tomaat], alsjeblieft.\n~            a   tomato   please      \n$79/42a5ddd4.mp3;3,0;7,104;13,403\n+ Een tomaat\n- Twee tomaat\n\n[LINE]\nSpeaker366: Voor mijn salade.\n~           for  my   salad  \n$79/a372eff0.mp3;4,0;5,220;7,174\n\n[LINE]\nSpeaker856: Oké.\n~           OK  \n$79/4762623e.mp3;3,50\n\n[LINE]\nSpeaker366: Bedankt.\n~           Thanks  \n$79/af744664.mp3;7,0\n\n[LINE]\nSpeaker366: Oh ja!  En  drie  broodjes.\n~           Oh yes  And three bread~rolls    \n$79/b87f36d8.mp3;2,0;3,119;4,263;5,305;9,180\n\n[LINE]\nSpeaker856: Oké…\n~           OK  \n$79/4995bfec.mp3;3,50\n\n[LINE]\nSpeaker366: En  sinaasappelsap…\n~           And orange~juice   \n$79/c8e088b0.mp3;2,0;14,104\n\n[LINE]\nSpeaker856: Sergio{Serdzjo}...\n$79/5bf229fa.mp3;7,50\n\n[LINE]\nSpeaker366: ...en  koffie, graag.   \n~              and coffee  please \n$79/aa3d9d72.mp3;5,0;7,424;6,276\n\n[LINE]\nSpeaker856: Eh… Ik heb  een idee.\n~           Eh  I  have an  idea \n$79/619d10c2.mp3;2,50;3,1025;4,112;4,200;5,150\n\n[CONTINUATION]\n> Which word links the two sentences together to make one logical sentence?\nSpeaker856: Ik blijf      thuis    [want]     jij gaat    naar de  supermarkt. \n~           I  will~stay  at~home   because   you will~go to   the supermarket \n$79/631c58f4.mp3;2,50;6,162;6,388;5,500;4,212;5,275;5,375;3,163;11,87\n+ want\n~ want\n- omdat\n~ because\n- dagenlang\n~ for~days\n\n[LINE]\nSpeaker366: Waarom?\n~           why    \n$79/f992b5b4.mp3;6,0\n\n[LINE]\nSpeaker856: Ik heb           maar één ding  nodig.          Brood.\n~           I  need~(+nodig) only one thing (+hebben)~need  Bread \n$79/6653e5b4.mp3;2,50;4,162;5,188;4,300;5,287;6,200;6,1513\n\n[MULTIPLE_CHOICE]\n> Maartje wants Sergio to go to the supermarket because...\n- she doesn’t know where to find the coffee.\n+ Sergio wants a lot of things.\n- she can buy bread at the bakery.\n\n[MATCH]\n> Put the right words together\n- something <> iets\n- the orange juice <> het sinaasappelsap\n- to buy <> kopen\n- the brother <> de broer\n- the idea <> het idee\n\n"
  },
  {
    "path": "database/stories/nl-en/1_4_es-en-la-luna-de-miel.txt",
    "content": "[DATA]\nfromLanguageName=The honeymoon\nicon=7e5d271488d31d6f1d0c503512e642ca7effe84f\nset=1|4\napprovals=11,12\npublic=1\n\nspeaker_Sophie=Lotte\nspeaker_Marie=Lotte\nspeaker_Taxichauffeur=Ruben\n\n[HEADER]\n> De Huwelijksreis\n$570/33a29958.mp3;2,50;14,125\n\n[LINE]\n> Sophie zit       in een taxi.\n~ ~      is(~sits) in a   taxi \n$570/380f2592.mp3;6,50;4,562;3,238;4,125;5,125\n\n[LINE]\nSpeaker113: Goedemorgen. \n~           Good~morning \n$570/af5a381c.mp3;11,50\n\n[SELECT_PHRASE]\n> Fill in the missing words.\nSpeaker439: Goedemorgen,  naar [het~vliegveld] alstublieft.\n~           Good~morning  to    the~airport    please     ~           \n$570/b2410394.mp3;11,50;5,1012;4,200;10,100;12,725\n- de vliegtuigen\n+ het vliegveld\n- de vliegen\n\n[LINE]\nSpeaker113: Is~goed.\n~           Alright \n$570/b68892f0.mp3;2,50;5,287\n\n[LINE]\nSpeaker113: Reist~u            voor uw     werk?\n~           Are~you~travelling for  (your) work \n$570/b7bb48fc.mp3;5,50;2,450;5,62;3,213;5,137\n\n[LINE]\nSpeaker439: Nee.\n~           No  \n$570/bac6a654.mp3;3,50\n\n[LINE]\nSpeaker439: Ik heb  een vliegticket naar Toulouse{toeloeze}.\n~           I  have a   ticket      for  ~        \n$570/d3771e54.mp3;2,50;4,175;4,187;12,100;5,675;9,175\n\n[LINE]\nSpeaker439: Ik heb~zelfs twee vliegtickets naar Toulouse{toeloeze}.\n~           I  even~have  two  tickets      for  ~        \n$570/e08523f2.mp3;2,50;4,150;6,175;5,525;13,375;5,800;9,150\n\n[LINE]\nSpeaker113: Toulouse{toeloeze} is een mooie     stad!\n~           ~                  is a   beautiful city \n$570/f3b58c32.mp3;8,50;3,550;4,187;6,113;5,375\n\n[LINE]\nSpeaker439: Het is voor mijn huwelijksreis.\n~           It  is for  my   honeymoon     \n$570/f89947ac.mp3;3,50;3,200;5,200;5,175;14,287\n\n[MULTIPLE_CHOICE]\n> Why is Sophie going to Toulouse?\n- She is going to work there.\n+ She is going on her honeymoon.\n- She's moving there.\n\n[LINE]\nSpeaker113: Waar  is uw   man?    \n~           Where is your husband \n$570/faa2048a.mp3;4,50;3,275;3,175;4,125\n\n[LINE]\nSpeaker439: Mijn vrouw.\n~           My   wife  \n$570/fc242b4e.mp3;4,50;6,350\n\n[LINE]\nSpeaker439: Zij wil~niet           naar Toulouse{toeloeze} met  mij.\n~           She doesn't~want~to~go to   ~                  with me  \n$570/02aaaeac.mp3;3,50;4,262;5,200;5,313;9,162;4,525;5,200\n\n[MULTIPLE_CHOICE]\n> Sophie's wife ...\n- ... is already in Toulouse.\n- ... is in the taxi with her.\n+ ... doesn't want to go to Toulouse with her.\n\n[LINE]\nSpeaker439: Het is een zwarte dag voor mij.\n~           It  is a   dark   day for  me  \n$570/08d95b0c.mp3;3,50;3,200;4,212;7,63;4,512;5,250;5,175\n\n[ARRANGE]\n> Zet de woorden in de juiste volgorde:\nSpeaker113: [(Oh) (dat) (spijt) (me) (verschrikkelijk)].\n$570/09ce9496.mp3;2,50;4,225;6,212;3,400;16,125\n\n[LINE]\nSpeaker113: We zijn         bij het vliegveld aangekomen.\n~           We have~arrived at  the airport   (arrived)    \n$570/0c8f51f2.mp3;2,50;5,150;4,262;4,125;10,150;11,538\n\n[LINE]\n> Een vrouw komt~aanlopen  met  haar reiskoffer.\n~ A   woman arrives        with her  suitcase   \n$570/11079c80.mp3;3,50;6,175;5,337;4,325;4,200;5,150;11,150\n\n[LINE]\nSpeaker439: Marie?\n$570/1506c5cc.mp3;5,50\n\n[LINE]\nSpeaker1186: Sophie!\n$570/1a07a10e.mp3;6,50\n\n[LINE]\nSpeaker1186: Het~spijt~me!\n~            I~am~sorry   \n$570/1ec8dcee.mp3;3,50;6,200;3,450\n\n[LINE]\nSpeaker1186: Ik houd~van je! \n~            I  love     you \n$570/21459fa2.mp3;2,50;5,162;4,313;3,162\n\n[MULTIPLE_CHOICE]\n> What does Marie mean?\n- I am late!\n- I hate you!\n+ I love you!\n\n[LINE]\nSpeaker439: Ik houd~ook~van~jou!\n~           I  love~you~too\n$570/25326a50.mp3;2,50;5,137;4,388;4,200;4,175\n\n[LINE]\nSpeaker1186: Op       naar Toulouse{toeloeze}. \n~            Let's~go to   ~        \n$570/2e4fa95e.mp3;2,50;5,162;9,213\n\n[LINE]\nSpeaker113: Goede~reis!      \n~           Have~a~nice~trip \n$570/325bca28.mp3;5,50;5,387\n\n[MULTIPLE_CHOICE]\n> What happened when Sophie got to the airport?\n- Her taxidriver agreed to go on vacation with her.\n+ Her wife, Marie, was already there and ready to go to Toulouse.\n- Her wife, Marie, called her on the phone.\n\n[MATCH]\n> Tap the pairs.\n- have a nice trip <> goede reis!\n- to travel <> reizen\n- the airport <> het vliegveld\n- a dark day <> een zwarte dag\n- the honeymoon <> de huwelijksreis \n\n"
  },
  {
    "path": "database/stories/nl-en/2_1_es-en-la-chaqueta-roja.txt",
    "content": "[DATA]\nfromLanguageName=The red jacket\nicon=5361833c123aec9adfa60b0dc63398cd1aa49ef2\nset=2|1\napprovals=11,12\n\nicon_Natalie=https://stories-cdn.duolingo.com/image/ad3623a2d5ee767d1536ff972bfb9961b40df6ca.svg\nspeaker_Mark=Ruben\nspeaker_Natalie=Lotte\n\n[HEADER]\n> In de Kledingwinkel\n$623/ad770e28.mp3;2,0;3,135;14,76\n\n[LINE]\n> Natalie is in een kledingwinkel  met  haar broer,   Mark.\n~ ~       is in a   clothing~store with her  brother  ~    \n$623/b0b5a680.mp3;7,0;3,583;3,157;4,110;14,126;4,743;5,201;6,195;6,352\n\n[LINE]\nSpeaker3160: Deze  kleren  zijn leuk!\n~            These clothes are  nice \n$623/b4e101e6.mp3;4,50;7,437;5,425;5,163\n\n[LINE]\nSpeaker439: Mark, ze   zijn duur!     \n~           ~     they are  expensive \n$623/b870024e.mp3;4,50;3,612;5,150;5,250\n\n[MULTIPLE_CHOICE]\n> Natalie thinks the clothes are cheap.\n+ No, that's not right.\n- Yes, that's true.\n\n[LINE]\nSpeaker3160: Wauw!\n~            Wow  \n$623/bdc36114.mp3;4,50\n\n[LINE]\nSpeaker3160: Deze jas    is gaaf!       \n~            This jacket is awesome\n$623/c107de86.mp3;4,50;4,437;3,275;5,175\n\n[MULTIPLE_CHOICE]\n> Mark thinks ..\n+ .. the jacket is great.\n- .. the shirt is cheap.\n- .. the skirt is great.\n\n[LINE]\nSpeaker3160: En  ik houd~van deze kleur. \n~            And I  love     this colour \n$623/c60f70d8.mp3;2,50;3,175;5,137;4,263;5,137;6,313\n\n[LINE]\nSpeaker439: Mark...\n$623/c9d99176.mp3;4,50\n\n[LINE]\nSpeaker3160: Deze     is perfect voor mij!\n~            This~one is perfect for  me  \n$623/cce385c0.mp3;4,50;3,525;8,162;5,525;4,138\n\n[SELECT_PHRASE]\n> Tap the missing words.\nSpeaker3160: Ik wil [deze jas.]\n$623/d06a3b26.mp3;2,50;4,225;5,200;4,362\n+ deze jas.\n- die kat.\n- dit jas.\n\n[LINE]\nSpeaker439: Mark!\n$623/d41c8170.mp3;4,50\n\n[LINE]\nSpeaker3160: Wat? \n~            What \n$623/d8584936.mp3;3,50\n\n[LINE]\nSpeaker439: De  jas    is vijfhonderd  euro. \n~           The jacket is five~hundred euros \n$623/db1308b4.mp3;2,50;4,125;3,437;12,163;5,800\n\n[MULTIPLE_CHOICE]\n> How much does the jacket cost?\n- Five million euros.\n- Five euros.\n+ Five hundred euros.\n\n[LINE]\nSpeaker439: Hij is te  duur.     \n~           It  is too expensive \n$623/e206505e.mp3;3,50;3,312;3,188;5,125\n\n[LINE]\nSpeaker3160: Vijfhonderd  euro?! \n~            Five~hundred euros  \n$623/e5b6f1a4.mp3;11,50;5,687\n\n[LINE]\nSpeaker439: Helaas..       \n~           Unfortunately  \n$623/eb7eea38.mp3;6,50\n\n[LINE]\nSpeaker3160: Ik heb  een idee!\n~            I  have an  idea \n$623/eebbd95e.mp3;2,50;4,212;4,200;5,125\n\n[LINE]\nSpeaker3160: Mijn verjaardag is binnenkort.\n~            My   birthday   is soon       \n$623/f1ee5426.mp3;4,50;11,275;3,562;11,150\n\n[MULTIPLE_CHOICE]\n> What does Mark mean?\n+ It will be his birthday soon.\n- He's going on vacation soon.\n- It's his wedding anniversary soon.\n\n[LINE]\nSpeaker439: Ja,  dus?\n~           Yes  so  \n$623/f5ef0426.mp3;2,50;4,437\n\n[LINE]\nSpeaker3160: En  jij bent mijn zus!   \n~            And you are  my   sister \n$623/fa3bcaf0.mp3;2,50;4,212;5,275;5,213;4,225\n\n[LINE]\nSpeaker3160: Je  werkt in Parijs ..\n~            You work  in Paris    \n$623/fe58971c.mp3;2,50;6,150;3,300;7,125\n\n[LINE]\nSpeaker3160: Dus je  koopt    daar  ..\n~            So  you will~buy there   \n$623/13e086ce.mp3;3,50;3,250;6,112;5,313\n\n[LINE]\nSpeaker439: .. een goedkope jas    voor mijn broer?  \n~              a   cheap    jacket for  my   brother \n$623/1a6cb514.mp3;3,50;9,125;4,675;5,525;5,162;6,275\n\n[MULTIPLE_CHOICE]\n> What will Natalie do in Paris?\n+ Buy a cheap jacket for her brother's birthday.\n- Buy the jacket for five hundred euros for her brother.\n- Work as a clothing designer.\n\n[MATCH]\n> Tap the pairs.\n- the jacket <> de jas\n- cool <> gaaf \n- the birthday <> de verjaardag\n- soon <> binnenkort\n- cheap <> goedkoop\n\n"
  },
  {
    "path": "database/stories/nl-en/2_2_es-en-el-pasaporte.txt",
    "content": "[DATA]\nfromLanguageName=The Passport\nicon=643347b755001a918130ddf5f6d3e914e63a00ce\nset=2|2\napprovals=11,12\n\n[HEADER]\n> Het Paspoort\n~ the passport \n$5388/8c176cec.mp3;3,50;9,225\n\n[LINE]\n> Vikram en zijn vrouw Priti zijn op het vliegveld.\n~ Vikram and his wife    Priti  are   at the airport    \n$5388/849fdd30.mp3;6,0;3,443;5,124;6,273;6,262;5,466;3,262;4,130;10,98\n\n[LINE]\nSpeaker593: O~jee, waar  is mijn paspoort?\n~           oh~no  where is my   passport  \n$5388/7bd39938.mp3;1,0;4,179;6,320;3,276;5,155;9,155\n\n[LINE]\nSpeaker593: Het~is niet hier!\n~           it's   not here \n$5388/5696412e.mp3;3,0;3,134;5,155;5,211\n\n[LINE]\nSpeaker560: Vikram…\n~           Vikram \n$5388/98a101c4.mp3;6,0\n\n[SELECT_PHRASE]\n> Select the missing phrase\nSpeaker593: Het~zit niet  [in mijn tas]…\n~           it's    not    in my bag      \n$5388/598188f8.mp3;3,0;4,149;5,190;3,200;5,100;4,170\n\n+ in mijn tas\n- in mijn jas\n- in zijn tas\n\n[LINE]\nSpeaker593: En het~zit niet in mijn jack{jek}\n~           and it's not in my jacket   \n$5388/6479ecfa.mp3\n\n[LINE]\nSpeaker560: Vik…\n~           Vik \n$5388/d58a9c8a.mp3;4,0\n\n[LINE]\nSpeaker593: Het ligt nog   in de taxi!\n~           it  's   still in the taxi \n$5388/67ee9840.mp3;3,0;5,149;4,210;3,170;3,121;5,85\n\n[MULTIPLE_CHOICE]\n> Vikram thinks his passport is in the taxi.\n+ Yes, that's right.\n- No, that's wrong.\n\n[LINE]\n> Vikram rent naar~buiten om          de taxi  te~zoeken.\n~ Vikram runs outside     to~look~for the taxi (to~look~for) \n$5388/7048c7b8.mp3;6,50;5,537;5,300;7,150;3,438;3,125;5,112;3,375;7,125\n\n[LINE]\nSpeaker560: Vik, nee!\n~            Vik  no \n$5388/837e31d0.mp3;6,0;5,454\n\n[ARRANGE]\n> Tap what you hear\n> [(Priti) (rent) (achter) (Vikram) (aan).]\n~   Priti   runs achter~...~aan:~after Vikram achter~...~aan:~after\n$5388/977c7c78.mp3;5,0;5,442;7,235;7,292;4,450\n\n[LINE]\nSpeaker593: O  nee, de taxi is  er~niet~meer\n~           oh no   the taxi is not~there~anymore\n$5388/75c069c6.mp3;1,0;4,174;4,300;5,233;3,430;3,150;5,65;5,215\n\n[LINE]\nSpeaker593: Wat een ramp!\n~           What a disaster! \n$5388/79b10194.mp3;3,0;4,200;5,110\n\n[LINE]\nSpeaker560: Vikram!\n~            Vikram \n$5388/b8b58dda.mp3;6,0\n\n[LINE]\nSpeaker560: Je   paspoort ligt~niet in de taxi.\n~           your passport is~not    in the taxi \n$5388/bcc9d9e4.mp3;2,0;9,144;5,556;5,270;3,239;3,135;5,115\n\n[POINT_TO_PHRASE]\n> Choose the option that means \"passport.\"\nSpeaker560: (Je) (hebt) (je) (+pas) (in) (je) (hand)\n~            you have your passport in your hand\n$5388/c19d2142.mp3;2,0;5,129;3,236;4,70;3,243;3,100;5,135\n\n[LINE]\nSpeaker593: Oh…\n~           oh \n$5388/7e3c568c.mp3;2,0\n\n[LINE]\nSpeaker593: Dank  je  lieverd\n~           thank you my~love \n$5388/85d26422.mp3;4,0;3,310;8,55\n\n[MULTIPLE_CHOICE]\n> Where was Vikram's passport?\n- in the taxi\n- at his house\n+ in his hand\n\n[MATCH]\n> Tap the pairs\n- het passport <> de pas\n- is <> staat, zit, ligt\n- the disaster <> de ramp\n- the jacket <> het jack\n- your <> je, jouw\n\n"
  },
  {
    "path": "database/stories/nl-en/2_3_es-en-una-familia-muy-grande.txt",
    "content": "[DATA]\nfromLanguageName=A Very Big Family\nicon=9a2dcd1a9eaff04d1e9b4338e9afcead94c365bf\nset=2|3\napprovals=11,12\n\nspeaker_Moeder=Lotte\nspeaker_Melissa=Lotte\nspeaker_Olivia=Lotte\n\n[HEADER]\n> Een grote familie\n~ a   big   family \n$924/63aa40dc.mp3;3,50;6,150;8,450\n\n[LINE]\n> Olivia ontmoet Melissa haar familie.\n~ ~      meets   ~       's  family  \n$924/688768e6.mp3;6,50;8,587;8,400;5,513;8,187\n\n[MULTIPLE_CHOICE]\n> Olivia is meeting Melissa's family.\n- No, that's not right.\n+ Yes, that's true.\n\n[LINE]\nSpeaker341: Melissa! Mijn lievelingsdochter!\n~           ~        My   favorite~daughter \n$924/6c508f84.mp3;7,0;6,615;18,435\n\n[LINE]\nSpeaker125: Mama, je  hebt maar één dochter! \n~           Mum   you have only one daughter \n$924/71120b6a.mp3;4,50;3,700;5,100;5,287;4,275;8,238\n\n[LINE]\nSpeaker125: Dit  is mijn vriendin,   Olivia.\n~           This is my   girlfriend  Olivia \n$924/739892fa.mp3;3,50;3,312;5,163;9,237;7,738\n\n[LINE]\nSpeaker856: Hallo!\n~           Hello \n$924/79adba58.mp3;5,50\n\n[LINE]\nSpeaker125: Olivia, dit  is mijn broer,   Thomas.\n~           ~       this is my   brother  ~      \n$924/7d24a282.mp3;6,50;4,987;3,200;5,175;6,263;7,637\n\n[LINE]\nSpeaker856: Dag Thomas!\n~           Hi Thomas \n$924/818db6d8.mp3;3,50;7,262\n\n[LINE]\nSpeaker125: En  dit  is mijn oma.        \n~           And this is my   grandmother \n$924/94334032.mp3;2,50;4,237;3,250;5,163;4,275\n\n[LINE]\nSpeaker856: Goedendag.\n~           Hello     \n$924/852b4576.mp3;9,50\n\n[LINE]\nSpeaker125: Dit   zijn mijn vader  en  mijn broer,   David.\n~           These are  my   father and my   brother  ~     \n$924/9fc27af8.mp3;3,50;5,312;5,188;6,250;3,462;5,125;6,275;6,650\n\n[SELECT_PHRASE]\n> Finish the sentence.\nSpeaker856: Hallo. Hoe [gaat het met jullie?]\n$924/8b57dc2a.mp3;5,50;4,1425;5,175;4,312;4,100;7,175\n- gaan met jullie?\n+ gaat het met jullie?\n- ga jullie?\n\n[LINE]\nSpeaker125: Mijn opa,         Dorian.\n~           My   grandfather  ~      \n$924/adf627fa.mp3;4,50;4,387;7,750\n\n[LINE]\nSpeaker856: Aangenaam!\n~           Nice~to~meet~you \n$924/8e14d8d2.mp3;9,50\n\n[ARRANGE]\n> Put the words in the right order.\nSpeaker856: [(Je) (hebt) (een) (grote) (familie)]\n$924/9051aada.mp3;2,50;5,187;4,288;6,87;8,525\n\n[LINE]\nSpeaker341: Maar maar één dochter! \n~           But  only one daughter \n$924/9594e7d2.mp3;4,0;5,164;4,261;8,238\n\n[LINE]\nSpeaker125: Mama?\n~           Mum  \n$924/9a863c78.mp3;4,50\n\n[LINE]\nSpeaker341: Ah, natuurlijk. Ik heb  nu  twee dochters! \n~           Ah  of~course   I  have now two  daughters \n$924/dcd3ef4e.mp3;2,0;12,254;4,806;4,390;3,170;5,160;9,305\n\n[MULTIPLE_CHOICE]\n> What does Melissa's mother mean with 'ik heb nu twee dochters'?\n- She just gave birth to a second daughter.\n+ She considers Olivia part of the family now.\n- She adopted a girl.\n\n[LINE]\nSpeaker341: Welkom  in de  familie, Olivia!\n~           Welcome in the family   ~      \n$924/e25bf4a2.mp3;6,0;3,514;3,130;8,55;8,514\n\n[LINE]\nSpeaker856: Heel~erg~bedankt!   \n~           Thank~you~very~much \n$924/a0e6a5ee.mp3;4,50;4,375;8,225\n\n[MULTIPLE_CHOICE]\n> After Odile was introduced to everyone...\n- ... Melissa's mother asked Olivia to leave.\n+ ... Melissa's mother welcomed Olivia to her family.\n- ... she ran away quickly.\n\n[MATCH]\n> Tap the pairs.\n- the grandmother <> de oma\n- the daughter <> de dochter\n- the grandfather <> de opa\n- the brother <> de broer\n- the (girl)friend <> de vriendin\n"
  },
  {
    "path": "database/stories/nl-en/2_4_es-en-el-doctor-eddy.txt",
    "content": "[DATA]\nfromLanguageName=Doctor Eddy\nicon=29c5abbf74b46e43a4de510ac83e302c0722a100\nset=2|4\napprovals=11\n\n[HEADER]\n> Dokter Eddy\n~ doctor    Eddy\n$5326/aa572766.mp3;6,50;5,500\n\n[LINE]\n> Eddy is in de supermarkt\n~ Eddy is at the supermarket  \n$5326/afbb3d00.mp3;4,50;3,412;3,188;3,125;11,87\n\n[LINE]\n> Een vrouw spreekt hem aan\n~ a   woman adresses  him (to)\n$5326/b095c5ec.mp3;3,50;6,187;8,338;4,437;4,150\n\n[MULTIPLE_CHOICE]\n> Eddy adresses a woman\n- Yes, that's right.\n+ No, that's wrong.\n\n[LINE]\nSpeaker724: Hallo meneer!\n~            hello sir\n$5326/b5ceabe6.mp3;5,50;7,462\n\n[LINE]\nSpeaker414: Ja?\n~           Yes?\n$5326/bcfcc6d2.mp3;2,50\n\n[LINE]\nSpeaker724: Bent u   misschien     een dokter? \n~           are  you by~any~chance a   doctor \n$5326/66844b36.mp3;4,50;2,300;10,100;4,487;7,113\n\n[LINE]\nSpeaker414: Ik? Een dokter? uh...\n~            me a   doctor  uh \n$5326/6b891ddc.mp3;2,50;4,737;7,213;3,925\n\n[LINE]\nSpeaker414: Ja!      Hoe heeft u dat~gezien? \n~           yes~I~am how did you see~that \n$5326/344514dc.mp3;2,50;4,862;6,263;2,300;4,62;7,200\n\n[LINE]\nSpeaker724:   Gelukkig!\n~             that's~good \n$5326/76a34eae.mp3;8,50\n\n[LINE]\n> Maar     Eddy is geen dokter. \n~ However, Eddy is not~a doctor \n$5326/56f9b376.mp3;4,50;5,300;3,325;5,200;7,325\n\n[ARRANGE]\n> Tap what you hear\nSpeaker414: [(Ik~heb)  (veel)    (geld).]\n~             I~have   a~lot~of   money    \n$5326/7d45179c.mp3;2,50;4,187;5,163;5,300\n\n[LINE]\nSpeaker414: Ik woon in een heel groot huis\n~           I live  in a   very big house  \n$5326/2599e220.mp3;2,50;5,187;3,288;4,125;5,125;6,275;5,362\n\n[LINE]\nSpeaker414: Ik heb heel dure      broeken\n~           I have very expensive pants \n$5326/2a115dba.mp3;2,50;4,175;5,162;5,288;8,312\n\n[LINE]\nSpeaker724: Okee maar\n~           OK   but  \n$5326/86a87554.mp3;4,50;5,450\n\n[LINE]\nSpeaker724: Die  man heeft nu    een dokter     nodig\n~           that man needs (now) a   doctor~now (heeft~nodig=needs)  \n$5326/8c7f1bb8.mp3;3,50;4,212;6,313;3,312;4,288;7,112;6,363\n\n[LINE]\nSpeaker724: Hij staat         bij  de melk\n~           he  is~(standing) near the milk  \n$5326/92e9d66e.mp3;3,50;6,237;4,438;3,187;5,88\n\n[LINE]\nSpeaker724: Alstublíeft, deze man heeft~uw~hulp~nodig!\n~           please       that man needs~your~help  \n$5326/96f09ed2.mp3;11,50;5,987;4,338;6,287;3,300;5,175;6,288\n\n[MULTIPLE_CHOICE]\n> The man who needs help is near the…\n- …station.\n- …tomatoes.\n+ …milk.\n\n[POINT_TO_PHRASE]\n> Choose the option that means \"ill.\"\nSpeaker724: (Hij~is)  (erg)  (+ziek)!\n~             he~is   very    ill     \n$5326/9c7066a8.mp3;3,50;3,287;4,238;5,250\n\n[LINE]\nSpeaker414: Oh, nee.\n~            oh  no \n$5326/a35a349e.mp3;2,50;4,550\n\n[LINE]\n> Eddy ziet een vrouw, en spreekt haar  aan\n~ Eddy sees a   woman and adresses  her (spreekt~aan=addresses)\n$5326/de67126e.mp3;4,50;5,312;4,238;6,137;3,675;8,175;5,413;4,187\n\n[LINE]\nSpeaker414: Hallo mevrouw, bent u een dokter? \n~           hello  madam, are you a doctor \n$5326/95501886.mp3;5,50;8,400;5,775;2,287;4,88;7,125\n\n[MULTIPLE_CHOICE]\n> What happened in the story?\n- Eddy started med school to become a doctor.\n+ Eddy pretended to be a doctor to impress a woman.\n- Eddy saved a man at the supermarket.\n\n[MATCH]\n> Tap the pairs\n- the pair of trousers <> de broek\n- not a <> geen\n- sir <> meneer\n- a lot of <> veel\n- to address <> aanspreken\n\n"
  },
  {
    "path": "database/stories/test-en/1_1_es-en-buenos-dias.txt",
    "content": "[DATA]\nfromLanguageName=Testing\nicon=783305780a6dad8e0e4eb34109d948e6a5fc2c35\nset=1|1\napprovals=11,12\npublic=1\n\n[HEADER]\n> This is the Title\n~ Thes is the Title\n\n\n[LINE]\n> A narrator line.\n~ A narrator line.\n\n[LINE]\nSpeaker593: Hello, I am a speaker!\n~           Hello, I am a speaker!\n\n[LINE]\nSpeaker560: I am another speaker!\n~           I am another speaker!\n\n[MULTIPLE_CHOICE]\n> Do you know how MULTIPLE_CHOICE works?\n+ Right answers start with a +\n- negative answers with a -      yes  I~need   to~go to work    \n\n[ARRANGE]\n> Tap what you hear. The ARRANGE question uses () to create text buttons.\nSpeaker593: [(First) (second) (third)]\n~             First   Second   Third   \n\n[POINT_TO_PHRASE]\n> Choose the option that means \"right\". In the POINT_TO_PHRASE\nSpeaker560: The (+right) (word) has a plus (symbol).\n~            sorry    my love   I~am     tired       I~work   a~lot \n\n[SELECT_PHRASE]\n> Choose the best answer: The SELECT_PHRASE has similar answers.\nSpeaker593: What is the right  [word]?         \n~           What is the right   word\n$56/ddacf426.mp3;3,50;3,225;4,125;9,100\n+ word\n- bird\n- skirt\n\n[CONTINUATION]\n> Tap what you hear. CONTINUATION\nSpeaker560: I write with [words].\n~           I write with words.\n+ words\n~ words\n- sentences\n~ sentences\n- texts\n~ texts\n\n[MATCH]\n> Tap the pairs\n- A <> 1\n- B <> 2\n- C <> 3\n- D <> 4\n- E <> 5\n\n"
  },
  {
    "path": "database/stories/test-en/1_2_es-en-una-cita.txt",
    "content": "[DATA]\nfromLanguageName=Minimal Example\nicon=df24f7756b139f6eda927eb776621b9febe1a3f1\nset=1|2\napprovals=11,12\npublic=1\n\n[HEADER]\n> Title\n~ Title\n\n[LINE]\n> The one and only text.\n~ The one and only text.\n"
  },
  {
    "path": "discord_roles/CONTEXT.md",
    "content": "# Discord Roles\n\nThis context describes Discord-side contributor onboarding and role language that should stay separate from the website product glossary.\n\n## Language\n\n**Contributor Application**:\nA Discord-side request from a community member to become a contributor or contribute to another course.\n_Avoid_: Website application, course permission\n\n**Contributor**:\nA user who has been granted global permission to edit project content.\n_Avoid_: Course-scoped editor\n\n**Course Contributor**:\nA contributor credited for making a minimum contribution to a course.\n_Avoid_: Course permission\n\n## Relationships\n\n- A **Contributor Application** is handled in Discord, not on the website.\n- A **Contributor** has global edit access rather than per-course edit permissions.\n- A **Course Contributor** is attribution, not authorization.\n\n## Example dialogue\n\n> **Dev:** \"Does approving a **Contributor Application** grant access only to one course?\"\n> **Domain expert:** \"No — approved contributors get global edit access, even if the application was about helping with a specific course.\"\n\n## Flagged ambiguities\n\n- \"application\" usually refers to the Discord onboarding process, not an in-website workflow.\n- \"course contributor\" sounds like a permission role, but it is only contribution credit.\n"
  },
  {
    "path": "discord_roles/audio_cleanup.py",
    "content": "import mysql.connector\nimport re\nimport os\nfrom pathlib import Path\nimport shutil\n\nmydb = mysql.connector.connect(\n  host=\"localhost\",\n  user=\"carex\",\n  password=\"5hfW-18MSXgYvjrewhbP\",\n  database=\"carex_stories\"\n)\n\ndef move(source, target):\n    if not Path(target).parent.exists():\n        Path(target).parent.mkdir(parents=True)\n    shutil.move(source, target)\n\n\nos.chdir(\"../..\")\nshutil.move(\"audio\", \"audio_old\")\n\npage = 10\noffset = 0\nwhile True:\n    mycursor = mydb.cursor()\n    mycursor.execute(f\"SELECT id, course_id, text FROM story ORDER BY id LIMIT {page} OFFSET {offset}\")\n    myresult = mycursor.fetchall()\n    offset += page\n\n    if len(myresult) == 0:\n        break\n\n    for id, course_id, text in myresult:\n        #print(id, course_id, text)\n        #print(re.findall(r\"\\$(.*[^\\/])\\/([^\\/]*\\.mp3)\", text))\n        print(re.findall(r\"\\$(.*\\.mp3)\", text))\n        for file in re.findall(r\"\\$(.*\\.mp3)\", text):\n            move(Path(\"audio_old\") / file, Path(\"audio\") / file)\n"
  },
  {
    "path": "discord_roles/blame.py",
    "content": "import subprocess\nfrom pathlib import Path\nimport pandas as pd\nimport time\nimport os\n\n\ndef decode_git_stdout(result):\n    # Some historical commit metadata is not valid UTF-8; decode replacement\n    # keeps blame parsing working because we only consume author/content lines.\n    return result.stdout.decode(\"utf-8\", errors=\"replace\")\n\n\ndef get_commits_per_file(filename):\n    a = subprocess.run([\"git\", \"rev-list\", \"HEAD\", \"--oneline\", filename], capture_output=True)\n    out = a.stdout\n    lines = [l.split(b\" \", 1) for l in out.split(b\"\\n\") if l.strip() != b'']\n    return lines\n\ndef get_author_percentages(filename, ignore_rev=None):\n    print(filename)\n    if ignore_rev is None:\n        try:\n            ignore_rev = get_commits_per_file(filename)[-1][0].decode()\n        except IndexError:\n            ignore_rev = None\n    if ignore_rev:\n        #print(subprocess.run([\"git\", \"show\", ignore_rev+\":\"+str(filename)], capture_output=True, text=True).stdout)\n        base_file = [l.strip() for l in subprocess.run([\"git\", \"show\", ignore_rev+\":\"+str(filename)], capture_output=True, text=True).stdout.split(\"\\n\")]\n    else:\n        base_file = []\n\n    a = subprocess.run(\n        [\"git\", \"blame\", \"--line-porcelain\", \"-w\", filename],\n        capture_output=True,\n    )\n    authors, count = parse_blame_porcelain(decode_git_stdout(a), base_file)\n    #for author in authors:\n    #    authors[author] /= count\n    print(\"---------\", filename, authors)\n    return authors, count\n\n\ndef parse_blame_porcelain(output, base_file):\n    authors = {}\n    count = 0\n    current_author = None\n    for line in output.splitlines():\n        if line.startswith(\"author \"):\n            current_author = line[len(\"author \"):]\n            continue\n\n        if not line.startswith(\"\\t\"):\n            continue\n\n        line_content = line[1:].strip()\n        if line_content == \"\" or not current_author:\n            continue\n\n        found = False\n        for l in base_file:\n            if line_content == l:\n                found = True\n                break\n        if found:\n            continue\n\n        if current_author not in authors:\n            authors[current_author] = 0\n        authors[current_author] += 1\n        count += 1\n        current_author = None\n    return authors, count\n\nif 0:\n    filename = \"91/6920.txt\"\n    filename = \"93/6170.txt\"\n    filename = \"9/646.txt\"\n    filename = \"129/4303.txt\"\n    filename = \"132/4716.txt\"\n    get_commits_per_file(filename)\n    get_author_percentages(filename)\n    #get_author_percentages(filename, get_commits_per_file(filename)[-1][0])\n    exit()\n\ndef get_files_since_commit(commit):\n    print(\" \".join([\"git\", \"diff\", \"--name-only\", commit, \"HEAD\"]))\n    a = subprocess.run([\"git\", \"diff\", \"--name-only\", commit, \"HEAD\"], capture_output=True, text=True)\n    print(a.stdout)\n    files = [f for f in a.stdout.split(\"\\n\") if f != '']\n    return files\n\ndef get_new_file_list():\n    try:\n        print(Path(\"last_commit.txt\").read_text())\n        new_files = get_files_since_commit(Path(\"last_commit.txt\").read_text().strip())\n    except FileNotFoundError:\n        new_files = Path(\".\").glob(\"**/*.txt\")\n    print(new_files)\n    return new_files\n\ndef update_repo():\n    os.chdir(\"../../\")\n    if not Path(\"unofficial-duolingo-stories-content\").exists():\n\n        os.system(\"git clone https://github.com/rgerum/unofficial-duolingo-stories-content\")\n    os.chdir(\"unofficial-duolingo-stories-content\")\n    os.system(\"git pull\")\n\n\ndef update_output_csv():\n    start_time = time.time()\n    update_repo()\n    data_old = pd.read_csv(\"output.csv\")\n    data_old[\"story_id\"] = [int(str(file).split(\"/\")[1][:-4]) for file in data_old[\"filename\"]]\n\n    data = []\n    for file in get_new_file_list():\n        #for file in Path(\"99\").glob(\"*.txt\"):\n        print(len(data_old))\n        data_old = data_old[data_old.filename != file]\n        print(len(data_old))\n        authors, count = get_author_percentages(file)\n        for author in authors:\n            data.append(dict(author=author, filename=file, story_id=str(file).split(\"/\")[1][:-4], percentage=authors[author]/count, lines=authors[author]))\n        print(data)\n\n    data = pd.DataFrame(data)\n    data = pd.concat((data, data_old))\n    data = data.sort_values([\"author\", \"percentage\"], ascending=False)\n    counter = 0\n    last_author = 0\n    def count(x):\n        nonlocal counter, last_author\n        #print(x)\n        if x.author != last_author:\n            last_author = x.author\n            counter = 0\n        counter += 1\n        return counter\n\n    data[\"number\"] = data.apply(count, axis=1)\n\n    os.system(\"git rev-parse HEAD > last_commit.txt\")\n\n    data.to_csv(\"output.csv\", index=False)\n    print(data)\n    print(time.time() - start_time, \"s\")\n\n\nif __name__ == \"__main__\":\n    update_output_csv()\n"
  },
  {
    "path": "discord_roles/combine.py",
    "content": "import json\nfrom pathlib import Path\nfrom urllib import error, request\n\nimport pandas as pd\nfrom env_utils import load_env_file\n\n\nparams = load_env_file(Path(__file__).parent / \".env.local\")\nCONVEX_DISCORD_COMBINE_URL = params[\"CONVEX_DISCORD_COMBINE_URL\"]\nDISCORD_ROLE_SYNC_SECRET = params[\"DISCORD_ROLE_SYNC_SECRET\"]\nCACHE_DIR = Path(__file__).parent / \".cache\"\nAPPROVAL_CACHE_FILE = CACHE_DIR / \"approvals_cache.csv\"\nAPPROVAL_CACHE_COLUMNS = [\"approval_id\", \"legacy_user_id\", \"story_id\", \"date\"]\n_contributor_users_cache = None\n_public_story_ids_cache = None\n\n\ndef fetch_combine_resource(kind, *, cursor=None, num_items=200, since_date=None):\n    payload = {\n        \"secret\": DISCORD_ROLE_SYNC_SECRET,\n        \"kind\": kind,\n        \"numItems\": num_items,\n    }\n    if cursor is not None:\n        payload[\"cursor\"] = cursor\n    if since_date is not None:\n        payload[\"sinceDate\"] = int(since_date)\n\n    req = request.Request(\n        CONVEX_DISCORD_COMBINE_URL,\n        data=json.dumps(payload).encode(\"utf-8\"),\n        headers={\"Content-Type\": \"application/json\"},\n        method=\"POST\",\n    )\n\n    try:\n        with request.urlopen(req, timeout=20) as resp:\n            body = json.loads(resp.read().decode(\"utf-8\"))\n    except error.HTTPError as err:\n        details = err.read().decode(\"utf-8\")\n        raise RuntimeError(\n            f\"convex combine data failed: HTTP {err.code}: {details}\"\n        ) from err\n    except Exception as err:\n        raise RuntimeError(f\"convex combine data failed: {err}\") from err\n\n    if not isinstance(body, dict) or not body.get(\"ok\"):\n        raise RuntimeError(f\"convex combine data returned invalid response: {body}\")\n\n    return body\n\n\ndef fetch_contributor_users():\n    global _contributor_users_cache\n    if isinstance(_contributor_users_cache, list):\n        return _contributor_users_cache\n\n    data = fetch_combine_resource(\"users\")\n    rows = data.get(\"users\", [])\n    users = []\n    for row in rows:\n        if not isinstance(row, dict):\n            continue\n        legacy_user_id = row.get(\"legacyUserId\")\n        author = row.get(\"author\")\n        discord_account_id = row.get(\"discordAccountId\")\n        if not isinstance(legacy_user_id, int):\n            continue\n        if not isinstance(author, str):\n            continue\n        users.append(\n            {\n                \"legacy_user_id\": legacy_user_id,\n                \"author\": author,\n                \"discord_account_id\": discord_account_id\n                if isinstance(discord_account_id, str)\n                else None,\n            }\n        )\n\n    _contributor_users_cache = users\n    return _contributor_users_cache\n\n\ndef fetch_public_story_ids():\n    global _public_story_ids_cache\n    if isinstance(_public_story_ids_cache, set):\n        return _public_story_ids_cache\n\n    story_ids = set()\n    cursor = None\n    while True:\n        data = fetch_combine_resource(\"publicStories\", cursor=cursor)\n        for story_id in data.get(\"page\", []):\n            if isinstance(story_id, int):\n                story_ids.add(story_id)\n\n        if data.get(\"isDone\"):\n            break\n\n        cursor = data.get(\"continueCursor\")\n        if not isinstance(cursor, str) or cursor == \"\":\n            break\n\n    _public_story_ids_cache = story_ids\n    return _public_story_ids_cache\n\n\ndef load_approval_cache():\n    CACHE_DIR.mkdir(exist_ok=True)\n    if not APPROVAL_CACHE_FILE.exists():\n        return pd.DataFrame(columns=APPROVAL_CACHE_COLUMNS)\n\n    data = pd.read_csv(\n        APPROVAL_CACHE_FILE,\n        dtype={\n            \"approval_id\": \"string\",\n            \"legacy_user_id\": \"Int64\",\n            \"story_id\": \"Int64\",\n            \"date\": \"Int64\",\n        },\n    )\n    for column in APPROVAL_CACHE_COLUMNS:\n        if column not in data.columns:\n            data[column] = pd.Series(dtype=\"object\")\n    return data[APPROVAL_CACHE_COLUMNS]\n\n\ndef save_approval_cache(data):\n    CACHE_DIR.mkdir(exist_ok=True)\n    normalized = data.copy()\n    if normalized.empty:\n        normalized = pd.DataFrame(columns=APPROVAL_CACHE_COLUMNS)\n    else:\n        normalized = normalized.sort_values([\"date\", \"approval_id\"]).reset_index(\n            drop=True\n        )\n    normalized.to_csv(APPROVAL_CACHE_FILE, index=False)\n\n\ndef update_approval_cache():\n    cache = load_approval_cache()\n    existing_ids = set(cache[\"approval_id\"].dropna().astype(str))\n    since_date = None\n    if not cache.empty:\n        since_date = int(cache[\"date\"].dropna().max())\n\n    new_rows = []\n    cursor = None\n    while True:\n        data = fetch_combine_resource(\n            \"approvals\",\n            cursor=cursor,\n            since_date=since_date,\n        )\n\n        for row in data.get(\"page\", []):\n            if not isinstance(row, dict):\n                continue\n\n            approval_id = row.get(\"id\")\n            legacy_user_id = row.get(\"legacyUserId\")\n            story_id = row.get(\"storyId\")\n            date = row.get(\"date\")\n\n            if not isinstance(approval_id, str):\n                continue\n            if approval_id in existing_ids:\n                continue\n            if not isinstance(legacy_user_id, int) or not isinstance(story_id, int):\n                continue\n            if not isinstance(date, int):\n                continue\n\n            existing_ids.add(approval_id)\n            new_rows.append(\n                {\n                    \"approval_id\": approval_id,\n                    \"legacy_user_id\": legacy_user_id,\n                    \"story_id\": story_id,\n                    \"date\": date,\n                }\n            )\n\n        if data.get(\"isDone\"):\n            break\n\n        cursor = data.get(\"continueCursor\")\n        if not isinstance(cursor, str) or cursor == \"\":\n            break\n\n    if new_rows:\n        cache = pd.concat([cache, pd.DataFrame(new_rows)], ignore_index=True)\n        save_approval_cache(cache)\n\n    return cache\n\n\ndef get_user_to_discord_mapping():\n    user_discord_id = {}\n    for user in fetch_contributor_users():\n        if isinstance(user[\"discord_account_id\"], str):\n            user_discord_id[user[\"author\"]] = user[\"discord_account_id\"]\n    return user_discord_id\n\n\ndef get_user_approval_count():\n    contributor_users = fetch_contributor_users()\n    author_by_legacy_user_id = {\n        user[\"legacy_user_id\"]: user[\"author\"] for user in contributor_users\n    }\n    public_story_ids = fetch_public_story_ids()\n    approvals = update_approval_cache()\n    if approvals.empty:\n        return pd.DataFrame(columns=[\"author\", \"story_id\", \"approval\", \"public\"])\n\n    approvals = approvals.dropna(\n        subset=[\"approval_id\", \"legacy_user_id\", \"story_id\", \"date\"]\n    ).copy()\n    approvals[\"legacy_user_id\"] = approvals[\"legacy_user_id\"].astype(int)\n    approvals[\"story_id\"] = approvals[\"story_id\"].astype(int)\n    approvals[\"date\"] = approvals[\"date\"].astype(int)\n    approvals[\"author\"] = approvals[\"legacy_user_id\"].map(author_by_legacy_user_id)\n\n    approvals = approvals.dropna(subset=[\"author\"])\n    approvals = approvals[approvals[\"story_id\"].isin(public_story_ids)]\n    approvals = approvals.sort_values([\"story_id\", \"date\", \"approval_id\"])\n\n    # The cache is append-only, so revokes/re-approvals can duplicate a user/story\n    # pair over time. Keep the earliest approval we have seen for milestone credit.\n    approvals = approvals.drop_duplicates(\n        subset=[\"story_id\", \"legacy_user_id\"],\n        keep=\"first\",\n    )\n    approvals[\"approval_rank\"] = approvals.groupby(\"story_id\").cumcount() + 1\n    approvals = approvals[approvals[\"approval_rank\"] <= 2]\n    approvals[\"approval\"] = 1\n    approvals[\"public\"] = 1\n\n    return approvals[[\"author\", \"story_id\", \"approval\", \"public\"]]\n\n\ndef join_and_group_data():\n    from blame import update_output_csv\n\n    update_output_csv()\n    data = pd.read_csv(\"output.csv\")\n    data[\"story_id\"] = [int(str(file).split(\"/\")[1][:-4]) for file in data[\"filename\"]]\n\n    data0 = data\n    data = data[data.percentage >= 0.1]\n    data = data[data.lines >= 3]\n\n    data = pd.concat([get_user_approval_count(), data])\n\n    data = data.sort_values(\"story_id\", ascending=False)\n    data = data.groupby([\"story_id\", \"author\"]).max(\n        [\"number\", \"story_id\", \"percentage\", \"lines\"]\n    )\n    data = data.reset_index().sort_values(\"story_id\", ascending=False)\n\n    data = data[data.public == 1]\n    print(data)\n    data.to_csv(\"joined.csv\")\n    author_story_counts = (\n        data.groupby(\"author\")\n        .agg(story_count=(\"story_id\", \"count\"))\n        .sort_values(\"story_count\", ascending=False)\n    )\n    return author_story_counts, data0\n\n\ndef get_milestone_grouped():\n    user_roles = []\n    for row in get_stories_role_sync_rows():\n        if row[\"milestone_stories\"] is None or not row[\"discord_account_id\"]:\n            continue\n        user_roles.append([row[\"discord_account_id\"], row[\"milestone_stories\"]])\n    return user_roles\n\n\ndef get_stories_role_sync_rows():\n    data, data0 = join_and_group_data()\n    milestones = [4, 8, 20, 40, 80, 120]\n    milestone_by_author = {}\n    for author, row in data.iterrows():\n        story_count = int(row.story_count)\n        milestone = None\n        for candidate in milestones[::-1]:\n            if story_count >= candidate:\n                milestone = candidate\n                break\n        milestone_by_author[author] = milestone\n\n    rows = []\n    for user in fetch_contributor_users():\n        milestone = milestone_by_author.get(user[\"author\"])\n        rows.append(\n            {\n                \"legacy_user_id\": user[\"legacy_user_id\"],\n                \"author\": user[\"author\"],\n                \"discord_account_id\": user[\"discord_account_id\"],\n                \"milestone_stories\": milestone,\n            }\n        )\n\n    return rows\n\n\ndef get_milestone_grouped_debug_missing_links():\n    data, data0 = join_and_group_data()\n    milestones = [4, 8, 20, 40, 80, 120]\n\n    user_discord_id = get_user_to_discord_mapping()\n    user_roles = []\n    for mile in milestones[::-1]:\n        print(\"----\", mile, \"stories\", \"----\")\n        d = data[data.story_count >= mile]\n        for i, author in d.iterrows():\n            print(\n                i,\n                author.story_count,\n                f\"({len(data0[data0.author == i])})\",\n                user_discord_id.get(i, \"none\"),\n            )\n            if user_discord_id.get(i, None):\n                user_roles.append([user_discord_id.get(i), mile])\n        data = data[data.story_count < mile]\n    print(user_roles)\n    return user_roles\n\n\ndef get_milestone_grouped2():\n    data, data0 = join_and_group_data()\n    milestones = [4, 8, 20, 40, 80, 120]\n\n    user_discord_id = get_user_to_discord_mapping()\n    user_roles = []\n    for mile in milestones[::-1]:\n        print(\"----\", mile, \"stories\", \"----\")\n        d = data[data.story_count >= mile]\n        for i, author in d.iterrows():\n            print(\n                i,\n                author.story_count,\n                f\"({len(data0[data0.author == i])})\",\n                user_discord_id.get(i, \"none\"),\n            )\n            if not user_discord_id.get(i, None):\n                user_roles.append([i, mile])\n        data = data[data.story_count < mile]\n    print(user_roles)\n    return user_roles\n\n\nif __name__ == \"__main__\":\n    get_milestone_grouped()\n"
  },
  {
    "path": "discord_roles/discord_bot.py",
    "content": "import asyncio\nimport discord\nimport json\nimport time\nfrom urllib import error, request\nfrom pathlib import Path\nfrom env_utils import load_env_file\n\nparams = Path(__file__).parent / \".env.local\"\nparams = load_env_file(params)\n\nCHANNEL_BOT_LOG = 1133529323396145172\nCONVEX_DISCORD_STORIES_ROLE_STATUS_URL = params.get(\n    \"CONVEX_DISCORD_STORIES_ROLE_STATUS_URL\",\n    params[\"CONVEX_DISCORD_SYNC_URL\"].replace(\n        \"/set-contributor-write\",\n        \"/set-stories-role-status\",\n    ),\n)\nCONVEX_DISCORD_SYNC_SECRET = params[\"DISCORD_ROLE_SYNC_SECRET\"]\n\n\ndef sync_stories_role_status(snapshots):\n    payload = {\n        \"secret\": CONVEX_DISCORD_SYNC_SECRET,\n        \"snapshots\": snapshots,\n    }\n    req = request.Request(\n        CONVEX_DISCORD_STORIES_ROLE_STATUS_URL,\n        data=json.dumps(payload).encode(\"utf-8\"),\n        headers={\"Content-Type\": \"application/json\"},\n        method=\"POST\",\n    )\n\n    try:\n        with request.urlopen(req, timeout=20) as resp:\n            body = json.loads(resp.read().decode(\"utf-8\"))\n    except error.HTTPError as err:\n        details = err.read().decode(\"utf-8\")\n        raise RuntimeError(\n            f\"convex stories role sync failed: HTTP {err.code}: {details}\"\n        ) from err\n    except Exception as err:\n        raise RuntimeError(f\"convex stories role sync failed: {err}\") from err\n\n    if not isinstance(body, dict) or not body.get(\"ok\"):\n        raise RuntimeError(\n            f\"convex stories role sync returned invalid response: {body}\"\n        )\n\n    return body\n\n\ndef get_snapshot_row(row, *, sync_status, assigned_stories_count=None, last_error=None):\n    milestone_stories = row.get(\"milestone_stories\")\n    return {\n        \"legacyUserId\": int(row[\"legacy_user_id\"]),\n        \"discordAccountId\": row.get(\"discord_account_id\"),\n        \"eligibleStoriesCount\": int(milestone_stories)\n        if isinstance(milestone_stories, int)\n        else None,\n        \"assignedStoriesCount\": assigned_stories_count,\n        \"syncStatus\": sync_status,\n        \"lastSyncedAt\": int(time.time() * 1000),\n        \"lastError\": last_error,\n    }\n\n\ndef set_user_roles(sync_rows):\n    try:\n        global params\n\n        # Token of your Discord bot\n        TOKEN = params['DISCORD_TOKEN']\n\n        # ID of your server\n        GUILD_ID = 726701782075572277\n\n        # ID of the role you want to assign\n        ROLE_ID = {\n            200: 1129006031868002325,\n            120: 1021418996500799518,\n             80: 1021418781853098005,\n             40: 1021418701158875269,\n             20: 1021418386334416978,\n              8: 1021417953956208650,\n              4: 1021423243627864094,\n        }\n\n        # ID of the user you want to assign the role to\n        USER_ID = 724679808071761982\n\n        # Create a bot instance\n        intents = discord.Intents.default()\n        intents.typing = False\n        intents.presences = False\n        intents.members = False  # Enable the Members intent\n        bot = discord.Client(intents=intents)\n\n        async def log(message):\n                channel = bot.get_channel(CHANNEL_BOT_LOG)\n                await channel.send(message)\n\n        # Event triggered when the bot is ready\n        @bot.event\n        async def on_ready():\n            print(f'Logged in as {bot.user.name}')\n\n            # Fetch the server (guild) from its ID\n            guild = bot.get_guild(GUILD_ID)\n            if guild is None:\n                print('Guild not found')\n                await bot.close()\n                return\n\n            # Fetch the role from its ID\n            roles = {k: guild.get_role(v) for k, v in ROLE_ID.items()}\n            snapshots = []\n\n            # Fetch the member from their ID\n            print(guild.name)\n            for row in sync_rows:\n                discord_account_id = row.get(\"discord_account_id\")\n                target_count = row.get(\"milestone_stories\")\n                print(\"USER_ID\", discord_account_id)\n                if not discord_account_id:\n                    snapshots.append(\n                        get_snapshot_row(row, sync_status=\"not_linked\")\n                    )\n                    continue\n\n                try:\n                    member = await guild.fetch_member(int(discord_account_id))\n                except discord.errors.NotFound:\n                    print(\"NOT FOUND\")\n                    snapshots.append(\n                        get_snapshot_row(\n                            row,\n                            sync_status=\"member_not_found\",\n                        )\n                    )\n                    continue\n                if member is None:\n                    print('Member not found')\n                    snapshots.append(\n                        get_snapshot_row(\n                            row,\n                            sync_status=\"member_not_found\",\n                        )\n                    )\n                    continue\n\n                print(member.roles)\n                role_max = max([0]+[int(role.name[:-len(\" Stories\")]) for role in member.roles if \"Stories\" in role.name])\n                should_assign = isinstance(target_count, int) and role_max < target_count\n                print(\"max\", role_max, should_assign, target_count, member.roles)\n                try:\n                    if should_assign:\n                        await member.add_roles(roles[target_count])\n                        await member.remove_roles(\n                            *[role for k, role in roles.items() if k != target_count]\n                        )\n                        await log(f\"🏅 I gave {member.name} the role {roles[target_count].name}. Previous role '{role_max} Stories'\")\n                        print(f'Role {roles[target_count].name} added to {member.name}')\n                        print(f'Roles {[role.name for k, role in roles.items() if k != target_count]} removed from {member.name}')\n                        snapshots.append(\n                            get_snapshot_row(\n                                row,\n                                sync_status=\"assigned\",\n                                assigned_stories_count=target_count,\n                            )\n                        )\n                    else:\n                        snapshots.append(\n                            get_snapshot_row(\n                                row,\n                                sync_status=\"up_to_date\"\n                                if isinstance(target_count, int)\n                                else \"no_milestone\",\n                                assigned_stories_count=role_max or None,\n                            )\n                        )\n                except Exception as err:\n                    print(err)\n                    snapshots.append(\n                        get_snapshot_row(\n                            row,\n                            sync_status=\"error\",\n                            assigned_stories_count=role_max or None,\n                            last_error=str(err),\n                        )\n                    )\n\n            if snapshots:\n                await asyncio.to_thread(sync_stories_role_status, snapshots)\n\n            await bot.close()\n\n        # Start the bot\n        bot.run(TOKEN)\n        exit()\n    except:\n        return\n\n\nif __name__ == \"__main__\":\n\n    from combine import get_stories_role_sync_rows\n\n    sync_rows = get_stories_role_sync_rows()\n    set_user_roles(sync_rows)\n"
  },
  {
    "path": "discord_roles/discord_reacting_bot.py",
    "content": "import discord\nimport json\nfrom urllib import error, request\nfrom pathlib import Path\nfrom env_utils import load_env_file\n\n\ndef sync_user_role(discord_id, write=None):\n    payload = {\n        \"secret\": CONVEX_DISCORD_SYNC_SECRET,\n        \"discordAccountId\": str(discord_id),\n        \"write\": write if write is None else bool(write),\n    }\n    req = request.Request(\n        CONVEX_DISCORD_SYNC_URL,\n        data=json.dumps(payload).encode(\"utf-8\"),\n        headers={\"Content-Type\": \"application/json\"},\n        method=\"POST\",\n    )\n\n    try:\n        with request.urlopen(req, timeout=10) as resp:\n            body = json.loads(resp.read().decode(\"utf-8\"))\n            return body\n    except error.HTTPError as err:\n        details = err.read().decode(\"utf-8\")\n        raise RuntimeError(f\"convex sync failed: HTTP {err.code}: {details}\") from err\n    except Exception as err:\n        raise RuntimeError(f\"convex sync failed: {err}\") from err\n\n# Replace 'YOUR_BOT_TOKEN' with your actual bot token obtained from the Discord Developer Portal.\nparams = Path(__file__).parent / \".env.local\"\nparams = load_env_file(params)\n\nTOKEN = params['DISCORD_TOKEN']\nCONVEX_DISCORD_SYNC_URL = params['CONVEX_DISCORD_SYNC_URL']\nCONVEX_DISCORD_SYNC_SECRET = params['DISCORD_ROLE_SYNC_SECRET']\nCHANNEL_CONTRIBUTOR_REQUEST = 1132747276234792980\n#CHANNEL_CONTRIBUTOR_REQUEST = 1133167220109877280  # test channel\nCHANNEL_BOT_LOG = 1133529323396145172\n\nROLE_MODERATOR = 735581436903424120\nROLE_CONTRIBUTOR = 941815741143977994\n\nclass MyClient(discord.Client):\n    async def on_ready(self):\n        print(f'Logged on as {self.user}!')\n\n    async def on_message(self, message):\n        if message.author == client.user:\n                return  # Ignore messages sent by the bot itself\n\n        # for the contributor request channel\n        if getattr(message.channel, \"parent\", None) and message.channel.parent.id == CHANNEL_CONTRIBUTOR_REQUEST:\n            channel = message.channel\n            # get the applicants message\n            first_message = await channel.fetch_message(channel.id)\n\n            # check if they are connected\n            try:\n                result = sync_user_role(first_message.author.id, None)\n                if result.get(\"linked\"):\n                    await first_message.add_reaction('🔗')\n                    await first_message.remove_reaction('✖️', client.user)\n                    await first_message.remove_reaction('❌', client.user)\n                else:\n                    await first_message.add_reaction('❌')\n                    await first_message.remove_reaction('🔗', client.user)\n                    if message.id == first_message.id:\n                        await message.channel.send(\"Please connect your Duostories account to your Discord account (on <https://duostories.org/profile>). Then post another message here and I will check again.\")\n            except Exception as err:\n                print(err)\n                await self.log(f\"⚠️ could not check Duostories account linkage for {first_message.author.name}, a database error occurred.\\n```{err}```\")\n\n    def _is_contributor_request_channel(self, channel):\n        \"\"\"Check if the channel is a thread in the contributor request forum.\"\"\"\n        try:\n            return channel.parent.id == CHANNEL_CONTRIBUTOR_REQUEST\n        except AttributeError:\n            return False\n\n    async def _get_first_message_if_match(self, channel, message_id):\n        \"\"\"Return the first message in the thread if message_id matches it, else None.\n        A thread's ID equals its starter message's ID, so we can skip the API call\n        when they don't match.\"\"\"\n        if message_id != channel.id:\n            return None\n        return await channel.fetch_message(channel.id)\n\n    async def check_reaction(self, reaction):\n        # reaction.member is None for reaction remove events\n        if reaction.member is None:\n            return None\n\n        # Check if the reacting user is a moderator\n        is_moderator = discord.utils.get(reaction.member.roles, id=ROLE_MODERATOR)\n\n        if reaction.member == client.user:\n            return  # Ignore reactions on the bot's own messages\n\n        if is_moderator and reaction.emoji.name == '✅':\n\n            # Get the channel where the reaction occurred\n            channel = client.get_channel(reaction.channel_id)\n\n            if not self._is_contributor_request_channel(channel):\n                return None\n\n            return await self._get_first_message_if_match(channel, reaction.message_id)\n        return None\n\n    async def on_raw_reaction_add(self, reaction):\n        if message := await self.check_reaction(reaction):\n            await message.add_reaction('✅')  # React to the moderator's reaction with a thumbs-up emoji\n\n            user = message.author\n            guild = client.get_guild(reaction.guild_id)\n            user_member = await guild.fetch_member(user.id)\n\n            role_to_give = discord.utils.get(guild.roles, id=ROLE_CONTRIBUTOR)\n\n            if user_member and role_to_give:\n                await user_member.add_roles(role_to_give)\n                await self.log(f\"🧑‍💻️ I gave {user.name} the role {role_to_give.name}.\")\n                print(f\"Gave {user.name} the role: {role_to_give.name}\")\n\n                try:\n                    result = sync_user_role(user.id, True)\n                    user_data = result.get(\"user\") if isinstance(result, dict) else None\n                    if result.get(\"linked\") and user_data:\n                        role_name = user_data.get(\"role\", \"unknown\")\n                        user_id = user_data.get(\"id\")\n                        username = user_data.get(\"name\", \"\")\n                        await self.log(f\"📝 added write permissions for {user.name}. Duostories id={user_id} username={username} role={role_name} <https://duostories.org/admin/users/{user_id}>\")\n                        await message.channel.send(\"I gave you the **Contributor** role and activated your account on Duostories.\\nIf you are currently logged in on <https://duostories.org>, please log out and in again for the changes to take effect.\\nYou can then access the editor at <https://duostories.org/editor>.\")\n                    else:\n                        await message.channel.send(\"I gave you the **Contributor** role but I could not activate your account on Duostories because you haven't connected your Duostories account to Discord.\")\n                except Exception as err:\n                    print(err)\n                    await self.log(f\"⚠️ could not add write permissions for {user.name}, a database error occurred.\\n```{err}```\")\n                    await message.channel.send(\"I gave you the **Contributor** role, but I could not activate your account on Duostories because a database error occurred.\")\n\n    async def on_raw_reaction_remove(self, reaction):\n        if reaction.emoji.name != '✅':\n            return\n        channel = client.get_channel(reaction.channel_id)\n        if not self._is_contributor_request_channel(channel):\n            return\n        # reaction.member is None for remove events; fetch to verify moderator status\n        guild = client.get_guild(reaction.guild_id)\n        if guild is None:\n            return\n        try:\n            member = await guild.fetch_member(reaction.user_id)\n        except discord.NotFound:\n            return\n        if not discord.utils.get(member.roles, id=ROLE_MODERATOR):\n            return\n        message = await self._get_first_message_if_match(channel, reaction.message_id)\n        if message:\n            await message.remove_reaction('✅', client.user)\n\n    async def on_member_update(self, before, after):\n        # Check if roles have been added or removed\n        roles_added = set(after.roles) - set(before.roles)\n        roles_removed = set(before.roles) - set(after.roles)\n\n        if roles_added:\n            for role in roles_added:\n                if role.id == ROLE_CONTRIBUTOR:\n                    print(\"update database\")\n                    try:\n                        result = sync_user_role(after.id, True)\n                        user_data = result.get(\"user\") if isinstance(result, dict) else None\n                        if result.get(\"linked\") and user_data and result.get(\"updated\"):\n                            role_name = user_data.get(\"role\", \"unknown\")\n                            user_id = user_data.get(\"id\")\n                            username = user_data.get(\"name\", \"\")\n                            await self.log(f\"📝 added write permissions for {after.name}. Duostories id={user_id} username={username} role={role_name} <https://duostories.org/admin/users/{user_id}>\")\n                        elif not result.get(\"linked\"):\n                            await self.log(f\"⚠️ could not add write permissions for {after.name}, account is not linked to duostories.\")\n                    except Exception as err:\n                        print(err)\n                        await self.log(f\"⚠️ could not add write permissions for {after.name}, a database error occurred.\\n```{err}```\")\n                print(f\"User {after.name} has been given the role: {role.name}\")\n\n            # Add your reaction logic here for when roles are added to a user.\n            # For example, you could send a message or give another role.\n\n        if roles_removed:\n            for role in roles_removed:\n                if role.id == ROLE_CONTRIBUTOR:\n                    print(\"update database\")\n                    try:\n                        result = sync_user_role(after.id, False)\n                        user_data = result.get(\"user\") if isinstance(result, dict) else None\n                        if result.get(\"linked\") and user_data:\n                            role_name = user_data.get(\"role\", \"unknown\")\n                            user_id = user_data.get(\"id\")\n                            username = user_data.get(\"name\", \"\")\n                            await self.log(f\"❌ removed write permissions for {after.name}. Duostories id={user_id} username={username} role={role_name} <https://duostories.org/admin/users/{user_id}>\")\n                        else:\n                            await self.log(f\"⚠️ could not remove write permissions for {after.name}, account is not linked to duostories.\")\n                    except Exception as err:\n                        print(err)\n                        await self.log(f\"⚠️ could not remove write permissions for {after.name}, a database error occurred.\\n```{err}```\")\n                print(f\"User {after.name} has lost the role: {role.name}\")\n\n            # Add your reaction logic here for when roles are removed from a user.\n            # For example, you could send a message or remove another role.\n\n    async def log(self, message):\n        channel = self.get_channel(CHANNEL_BOT_LOG)\n        await channel.send(message)\n\n\nintents = discord.Intents.default()\nintents.message_content = True\nintents.reactions = True\nintents.members = True\n\nclient = MyClient(intents=intents)\n# Run the bot with the specified token\nclient.run(TOKEN)\n"
  },
  {
    "path": "discord_roles/env_utils.py",
    "content": "from pathlib import Path\n\n\ndef load_env_file(path: Path) -> dict[str, str]:\n    env: dict[str, str] = {}\n    for raw_line in path.read_text().splitlines():\n        line = raw_line.strip()\n        if not line or line.startswith(\"#\") or \"=\" not in line:\n            continue\n\n        key, value = line.split(\"=\", 1)\n        key = key.strip()\n        value = value.strip()\n\n        if not key:\n            continue\n\n        if len(value) >= 2 and value[0] == value[-1] and value[0] in {'\"', \"'\"}:\n            value = value[1:-1]\n\n        env[key] = value\n\n    return env\n"
  },
  {
    "path": "discord_roles/requirements.txt",
    "content": "mysql-connector-python\npandas\nnumpy\ndiscord\n"
  },
  {
    "path": "docs/bulk-audio-editor-spec.md",
    "content": "# Bulk Audio Editor Spec\n\n## Goal\n\nCreate a dedicated story-level audio workspace that lets editors upload many audio files, review line-to-file matching, set word timing markers quickly, test playback, and apply all changes back to the story editor in one pass.\n\n## Why This Exists\n\nThe current audio workflow is line-by-line:\n\n- open one line\n- upload one file\n- adjust timings\n- save\n- move to the next line\n\nThat is too click-heavy for story-wide recording passes. Bulk audio editing should keep the editor in a single focused workspace.\n\n## Constraints\n\n- Story audio is still stored inline in the story text as `$filename;timings`.\n- Each audio-capable story element is mapped through `audio.ssml.inser_index`.\n- Replacing many audio lines must avoid line-number drift while editing the document.\n- Existing single-line audio editing stays available for cleanup and edge cases.\n\n## V1 Scope\n\n### Entry Point\n\n- Add a `Bulk Audio` action to the story editor header.\n- Open a dedicated modal instead of reusing the single-line overlay.\n\n### Data Model\n\nThe bulk editor works from parsed story elements and only includes audio-capable items:\n\n- header audio\n- line audio\n\nEach row carries:\n\n- display order\n- story `line_index`\n- speaker\n- source text\n- existing filename\n- existing keypoints\n- SSML insertion metadata\n\n### Layout\n\nTwo-pane modal:\n\n- Left pane: scrollable queue of audio rows with status and file assignment.\n- Right pane: active row editor with audio player, timing tools, and token list.\n\n### File Workflow\n\n- Accept many files via drag-and-drop or file picker.\n- Auto-match by leading filename number when possible.\n- Fallback to assignment by row order for unmatched files.\n- Allow replacing the file for the active row manually.\n- Keep unmatched files visible so the user can resolve them.\n\n### Timing Workflow\n\n- Show tokenized story text for the active row.\n- Let the user assign the current playback position to the selected token.\n- Allow clearing timings and removing the last marker.\n- Preserve existing timings until the user changes them.\n\n### Status Model\n\nRows surface these states:\n\n- missing\n- staged\n- uploaded\n- timed\n- failed\n\nTop-level summary shows counts for:\n\n- total rows\n- ready rows\n- timed rows\n- missing rows\n\n### Apply Flow\n\n- Upload any newly staged local files.\n- Convert timing markers into keypoints.\n- Serialize each changed row back into `$filename;timings`.\n- Apply all row edits safely to the CodeMirror document.\n\n## V1 Non-Goals\n\n- Automatic forced alignment\n- Story-level waveform stitching\n- Audio deletion and blob cleanup\n- Server-side batch upload endpoint\n- Cross-session draft persistence\n\n## Follow-Up Ideas\n\n- Auto-advance while marking timings during playback\n- Keyboard-first transport and marking shortcuts\n- Smarter filename matching with speaker/text hints\n- Even-spacing starter markers for untimed files\n- Batch retry and resumable draft state\n"
  },
  {
    "path": "import_tools/README.md",
    "content": "# Import Tools\n\nThis folder contains tools to import the stories from the Duolingo website.\n"
  },
  {
    "path": "import_tools/app.py",
    "content": "from flask import Flask\nfrom flask import Flask, render_template, send_from_directory\nimport os\nfrom flask import request\n\napp = Flask(__name__)\n\n@app.route(\"/\")\ndef hello_world():\n    return \"<p>Hello, World!</p>\"\n\n\nroot = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"whereyourfilesare\")\nfrom pathlib import Path\n@app.route('/login', methods=['GET'])\ndef login():\n    print(request.path)\n    p = Path(request.args.get('key', ''))\n    print(p)\n    return send_from_directory(p.parent, p.name)\n\n@app.route('/store', methods=['GET', 'POST'])\ndef getfiles():\n    import re\n    import tifffile\n    filename = request.args.get('id', '')\n    filename = \"duolingo_data/\"+filename+\".txt\"\n    print(filename)\n    json = request.form['json']\n    with open(filename, \"w\") as fp:\n        fp.write(json)\n    return \"done \" + filename\n"
  },
  {
    "path": "import_tools/greasmonkey.js",
    "content": "// ==UserScript==\n// @name     DuolingoImport\n// @version  1\n// @include  https*duolingo*\n// @grant    none\n// ==/UserScript==\n\nfunction fetch_post(url, data) {\n  /** like fetch but with post instead of get */\n  var fd = new FormData();\n  //very simply, doesn't handle complete objects\n  for (var i in data) {\n    fd.append(i, data[i]);\n  }\n  var req = new Request(url, {\n    method: \"POST\",\n    body: fd,\n    mode: \"cors\",\n  });\n  return fetch(req);\n}\n\nconsole.log(\"grease monkey 2\");\nasync function getStories(learningLanguage, fromLanguage) {\n  console.log(\"grease monkey 2xxx\");\n  data = await fetch(\n    `https://stories.duolingo.com/api2/stories?crowns=163&filterMature=false&fromLanguage=${fromLanguage}&illustrationFormat=svg&learningLanguage=${learningLanguage}&masterVersions=false&proposed=false&setSize=4&unlockingMechanism=crowns&_=1636940908268`,\n  );\n  json = await data.json();\n  console.log(\"json\", json);\n\n  data = await fetch_post(`http://127.0.0.1:5000/store?id=_stories`, {\n    json: JSON.stringify(json, null, 2),\n  });\n  txt = await data.text();\n  console.log(\"response\", txt);\n\n  for (set_index in json.sets) {\n    let set = json.sets[set_index];\n    if (set < 15) continue;\n    for (story_index in set) {\n      let story = set[story_index];\n      console.log(set_index, story_index, story.id);\n      data = await fetch(\n        `https://stories.duolingo.com/api2/stories/${story.id}?crowns=173&debugShowAllChallenges=false&illustrationFormat=svg&isDesktop=true&masterVersion=false&mode=read&supportedElements=ARRANGE,CHALLENGE_PROMPT,DUO_POPUP,FREEFORM_WRITING,FREEFORM_WRITING_EXAMPLE_RESPONSE,FREEFORM_WRITING_PROMPT,HEADER,HINT_ONBOARDING,LINE,MATCH,MULTIPLE_CHOICE,POINT_TO_PHRASE,SELECT_PHRASE,SUBHEADING,TYPE_TEXT&_=1640882394614`,\n      );\n      let json2 = await data.json();\n      console.log(\"json2\", json2);\n\n      data = await fetch_post(`http://127.0.0.1:5000/store?id=${story.id}`, {\n        json: JSON.stringify(json2, null, 2),\n      });\n      txt = await data.text();\n      console.log(\"response\", txt);\n      //break\n    }\n    //break\n  }\n  //console.log(json.sets[0][0].id);\n}\ngetStories(\"es\", \"en\");\ngetStories(\"fr\", \"en\");\nwindow.getStories = getStories;\ndocument.getStories = getStories;\n//https://stories.duolingo.com/api2/stories/es-en-buenos-dias?crowns=163&debugShowAllChallenges=false&illustrationFormat=svg&isDesktop=true&masterVersion=false&mode=read&supportedElements=ARRANGE,CHALLENGE_PROMPT,FREEFORM_WRITING,HEADER,HINT_ONBOARDING,LINE,MATCH,MULTIPLE_CHOICE,POINT_TO_PHRASE,SELECT_PHRASE,SUBHEADING,TYPE_TEXT&_=1636940601358\n"
  },
  {
    "path": "instrumentation-client.ts",
    "content": "import posthog from \"posthog-js\";\n\nconst posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;\n\nif (posthogKey) {\n  posthog.init(posthogKey, {\n    api_host: \"/ingest\",\n    ui_host: \"https://us.posthog.com\",\n    // Include the defaults option as required by PostHog\n    defaults: \"2025-11-30\",\n    // Enables capturing unhandled exceptions via Error Tracking\n    capture_exceptions: true,\n    // Turn on debug in development mode\n    debug: process.env.NODE_ENV === \"development\",\n  });\n}\n"
  },
  {
    "path": "jsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "knip.json",
    "content": "{\n  \"$schema\": \"https://unpkg.com/knip@latest/schema.json\",\n  \"entry\": [\n    \"scripts/backfill-course-contributors.ts\",\n    \"scripts/backfill-discord-avatars.ts\",\n    \"scripts/find-missing-story-images.ts\",\n    \"convex/convex.config.ts\",\n    \"convex/betterAuth/convex.config.ts\"\n  ],\n  \"ignore\": [\n    \".storybook/**\",\n    \"convex/betterAuth/_generated/**\",\n    \"convex/betterAuth/adapter.ts\",\n    \"database/**\",\n    \"import_tools/**\",\n    \"public/docs/**\",\n    \"public/sw.js\",\n    \"public/darklight.js\"\n  ],\n  \"ignoreBinaries\": [\n    \"next\"\n  ],\n  \"ignoreDependencies\": [\n    \"react-dom\"\n  ],\n  \"ignoreIssues\": {\n    \"src/components/editor/story/parser.ts\": [\n      \"exports\"\n    ],\n    \"convex/betterAuth/auth.ts\": [\n      \"exports\"\n    ]\n  }\n}\n"
  },
  {
    "path": "next.config.js",
    "content": "module.exports = {\n  // next.js config\n  reactCompiler: true,\n  compiler: {\n    styledComponents: true,\n  },\n  typescript: {\n    ignoreBuildErrors: true,\n  },\n  // PostHog reverse proxy to avoid ad blockers\n  async rewrites() {\n    return [\n      {\n        source: \"/ingest/static/:path*\",\n        destination: \"https://us-assets.i.posthog.com/static/:path*\",\n      },\n      {\n        source: \"/ingest/:path*\",\n        destination: \"https://us.i.posthog.com/:path*\",\n      },\n    ];\n  },\n  // Required for PostHog trailing slash API requests\n  skipTrailingSlashRedirect: true,\n  images: {\n    remotePatterns: [\n      {\n        protocol: \"https\",\n        hostname: \"opencollective.com\",\n        port: \"\",\n        pathname: \"/duostories/contribute/**\",\n      },\n      {\n        protocol: \"https\",\n        hostname: \"stories-cdn.duolingo.com\",\n        port: \"\",\n        pathname: \"/image/**\",\n      },\n    ],\n  },\n};\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"test\": \"pnpm exec tsx --test src/**/*.test.ts\",\n    \"format\": \"pnpm exec biome format src convex --write\",\n    \"format:check\": \"pnpm exec biome format src convex\",\n    \"lint\": \"pnpm run format:check && pnpm exec biome lint src convex\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"knip\": \"pnpm dlx knip --production\",\n    \"optimize:flags\": \"pnpm dlx svgo -f flags --multipass\",\n    \"audit:missing-story-images\": \"pnpm exec tsx scripts/find-missing-story-images.ts\",\n    \"backfill:discord-avatars\": \"pnpm exec tsx scripts/backfill-discord-avatars.ts\",\n    \"backfill:course-contributors\": \"pnpm exec tsx scripts/backfill-course-contributors.ts\"\n  },\n  \"dependencies\": {\n    \"@aws-sdk/client-polly\": \"^3.1038.0\",\n    \"@codemirror/language\": \"^6.12.3\",\n    \"@codemirror/state\": \"^6.6.0\",\n    \"@convex-dev/better-auth\": \"^0.12.0\",\n    \"@lezer/highlight\": \"^1.2.3\",\n    \"@mdx-js/mdx\": \"^3.1.1\",\n    \"@octokit/rest\": \"^22.0.1\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@tanstack/react-virtual\": \"^3.13.24\",\n    \"@vercel/blob\": \"^2.3.3\",\n    \"@wavesurfer/react\": \"^1.0.12\",\n    \"base64-arraybuffer\": \"^1.0.2\",\n    \"better-auth\": \"1.6.9\",\n    \"clsx\": \"^2.1.1\",\n    \"codemirror\": \"^6.0.2\",\n    \"convex\": \"^1.36.1\",\n    \"fflate\": \"^0.8.2\",\n    \"framer-motion\": \"^12.38.0\",\n    \"immer\": \"^11.1.4\",\n    \"js-md5\": \"^0.8.3\",\n    \"lamejs\": \"^1.2.1\",\n    \"lucide-react\": \"^1.12.0\",\n    \"microsoft-cognitiveservices-speech-sdk\": \"^1.49.0\",\n    \"next\": \"^16.2.6\",\n    \"next-mdx-remote\": \"^6.0.0\",\n    \"posthog-js\": \"^1.372.3\",\n    \"posthog-node\": \"^5.30.6\",\n    \"radix-ui\": \"^1.4.3\",\n    \"react\": \"^19.2.5\",\n    \"react-dom\": \"^19.2.5\",\n    \"react-swipeable\": \"^7.0.2\",\n    \"rehype-katex\": \"^7.0.1\",\n    \"remark-gfm\": \"^4.0.1\",\n    \"remark-math\": \"^6.0.0\",\n    \"tailwind-merge\": \"^3.5.0\",\n    \"uuid\": \"^14.0.0\",\n    \"vfile\": \"^6.0.3\",\n    \"wavesurfer.js\": \"^7.12.6\",\n    \"ws\": \"^8.20.0\",\n    \"yaml\": \"^2.8.3\",\n    \"zod\": \"^4.3.6\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"2.4.13\",\n    \"@tailwindcss/postcss\": \"^4.2.4\",\n    \"@types/hast\": \"^3.0.4\",\n    \"@types/mdx\": \"^2.0.13\",\n    \"@types/node\": \"^25.6.0\",\n    \"@types/pg\": \"^8.20.0\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"babel-plugin-react-compiler\": \"^1.0.0\",\n    \"dotenv\": \"^17.4.2\",\n    \"shadcn\": \"^4.6.0\",\n    \"tailwindcss\": \"^4.2.4\",\n    \"tsx\": \"^4.21.0\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"typescript\": \"^6.0.3\"\n  }\n}\n"
  },
  {
    "path": "postcss.config.mjs",
    "content": "const config = {\n  plugins: {\n    \"@tailwindcss/postcss\": {},\n  },\n};\n\nexport default config;\n"
  },
  {
    "path": "process.d.ts",
    "content": "declare module \"*.svg\" {\n  const src: string;\n  export default src;\n}\n\ndeclare module \"ws\";\n"
  },
  {
    "path": "public/.well-known/assetlinks.json",
    "content": "[\n  {\n    \"relation\": [\"delegate_permission/common.handle_all_urls\"],\n    \"target\": {\n      \"namespace\": \"android_app\",\n      \"package_name\": \"org.duostories.twa\",\n      \"sha256_cert_fingerprints\":\n      [\"AA:AA:9B:32:C3:F9:AF:1E:11:E6:E3:9E:CC:95:4A:F5:8D:84:E5:A7:BE:C3:7D:EF:2A:1B:4C:96:2B:51:BC:6C\", \"CD:32:C5:69:5C:21:52:6B:0A:0A:F9:BB:D9:23:F6:FF:01:83:44:22:9A:49:E4:B9:CE:BD:C6:9B:89:45:CA:7F\"]\n    }\n  }\n]\n"
  },
  {
    "path": "public/darklight.js",
    "content": "function get_current_theme() {\n  // it's currently saved in the document?\n  if (document.body.dataset.theme) {\n    return document.body.dataset.theme;\n  }\n  // it has been previously saved in the window?\n  if (\n    window.localStorage.getItem(\"theme\") !== undefined &&\n    window.localStorage.getItem(\"theme\") !== \"undefined\"\n  ) {\n    return window.localStorage.getItem(\"theme\");\n  }\n  // or the user has a preference?\n  if (window.matchMedia(\"(prefers-color-scheme: dark)\").matches) return \"dark\";\n  return \"light\";\n}\n\nconsole.log(\"activeTheme...\");\nfunction load() {\n  let activeTheme = get_current_theme();\n  console.log(\"activeTheme\", activeTheme);\n  document.body.dataset.theme = activeTheme;\n  window.localStorage.setItem(\"theme\", activeTheme);\n}\nload();\n\ndocument.addEventListener(\"DOMContentLoaded\", load);\n"
  },
  {
    "path": "public/docs/audio-generation/character-editor.mdx",
    "content": "---\ntitle: \"Character Editor\"\ndescription: \"Assign voices to characters.\"\n---\n\nIn the character editor you can assign voices to the characters that appear in the stories.\n\n### Character Names\nYou can also assign a name to the character. The name is just for reference if the character is addressed in a story\nto always use the same spelling.\n\nThe characters in the first row are the basic Duolingo cast. We want to keep their names\nas close to the original as possible. Just adjust them to the spelling of the target language.\n\n<Image>\n![First row of the Character editor](/docs/audio-generation/base_cast.png \"Base Characters\")\n</Image>\n\nFor example, if your language does not have a \"v\" and uses a \"w\" instead, you are able to change Vikram's name to \"Wikram\"\n\n![How Vikram can be changed to Wikram](/docs/audio-generation/vikram.png \"Vikram -> Wikram\")\n\nAs for these ones, feel free to change them to whatever you want to fit your language! For example, If you're making\nFinnish stories, use Finnish names. It will help the stories seem more realistic.\n\n<Image>\n![List of the side characters](/docs/audio-generation/other_cast.png \"The side characters\")\n</Image>\n\n### Vocal Modifiers\n\nYou can add tags on to the end of your TTS to modify it: the rate at which the character speaks and the pitch at which they speak. For example, to make Junior sound more like a child, I can make him speak faster and higher by adding tags, so I put  it-IT-ElsaNeural(pitch=x-high)(rate=fast) in the Speaker section.\n\nPitch has 5 levels:\n\n`x-low`, `low`, `medium`, `high`, `x-high`\n\nRate has 5 levels:\n\n`x-slow`, `slow`, `medium`, `fast`, `x-fast`\n\nFeel free to use these, or not.\n\n![Display of the sliders for pitch and speed](/docs/audio-generation/pitch_speed.png \"The sliders for pitch and speed\")\n"
  },
  {
    "path": "public/docs/audio-generation/edit.mdx",
    "content": "---\ntitle: \"TTS Edit\"\ndescription: \"Change the audio generation with replacement rules.\"\n---\n\nSometimes the generated audio does not fit how you want the word or sentence to sound. Or maybe you want to\nuse a voice form a different language if your language does not have voices. Therefore, you can adjust the\ntext that is send to the Speech Engine.\n\nIn the TTS Editor you can define rules to change the text before it is\nsend to the TTS service. There are three types of rules. Letter replacements, Fragment replacements\nand Word replacements.\n\nTo open it click on the Edit button in the top right corner of the character editor:\n\n![The button to open the TTS editor.](/docs/audio-generation/tts_edit_button.png \"Opens the TTS editor.\")\n\n\n### Letter Replacements\n\nLetter replacements are defined by a list of letters followed by a colon and a replacement string.\nThey are always replaced in the given order. On the left side of the colon there can only be one letter.\n```\nLETTERS:\n    o: u\n    e: i\n```\n\n### Fragment Replacements\nFragment replacements can be used to change syllables in a word. If the fragment ends in \\b it only replaces words that\nend in the fragment. If the fragment starts with \\b the fragment has to be at the beginning of the word.\n```\nFRAGMENTS:\n    ion\\b: flug\n    sem: dem\n```\n\n### Word replacements\nTo replace whole words. A word will only be replaced with it is surrounded by white space or punctuation characters, e.g.\nnot if it is found within another word.\n```\nWORDS:\n    oh: uuuh\n    Worcester: WOO-STER\n```\nIf you want to provide the word in the [International Phonetic Alphabet (IPA)](https://en.wikipedia.org/wiki/International_Phonetic_Alphabet) instead,\nyou can write after the word `:ipa`.\n"
  },
  {
    "path": "public/docs/audio-generation/engines.mdx",
    "content": "---\ntitle: \"Speech Engines\"\ndescription: \"There are different text-to-speech (TTS) engines that Duostories can use.\"\n---\n\n\nDuostories uses different services for text to speech generation.\nDepending on which voice name you select a different engine will be uses.\n\n\n### Azure TTS\n\nThe voices have the format `en-GB-SoniaNeural`.\n\nYou can get a list of voices: [here](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts).\n\n\n### Google TTS\n\nThe voices have the format `tr-TR-Wavenet-A` or `tr-TR-Standard-A`.\nThe voices with \"Wavenet\" use an AI method for voice generation and produce typically a better output.Duostories\n\nYou can get a list of voices: [here](https://cloud.google.com/text-to-speech/docs/voices)\n\n\n### Amazon Polly\n\nThese voices have a format `Justin`.\n\nYou can ge a list of voices: [here](https://docs.aws.amazon.com/polly/latest/dg/voicelist.html)\n"
  },
  {
    "path": "public/docs/audio-generation/fix-problems.mdx",
    "content": "---\ntitle: \"Fix Problems\"\ndescription: \"What do I do if the TTS is pronouncing words incorrectly?\"\n---\n\n### Use another voice/service\nSome TTS voices/services might be better in your language than others. If you are fortunate to have multiple voices to\nchoose from, you might be able to just avoid using the ones that don't work well.\n\n### Trick the TTS system\nYou can \"trick\" the TTS by using any text you want when you generate the audio. After you create the audio, you can\nchange the text back to be written correctly. You might need to experiment with a few different ways of writing the\ntext until the pronunciation sounds correct and natural. Tip: keep track of when you do this with #comment lines, so\nthat if you need to edit anything in the line later, it will be easy to copy-paste the \"trick\" text in-and-out to create\nnew audio. Ex:\n\n![The import button](/docs/audio-generation/replace.png \"Keeping track of replaced words\")\n\n### Curly Braces Method\nYou can use the \"curly braces\" method. If you write that section of text as `show~word{speak~word}`, the TTS will show the\nfirst part (which is spelled/written correctly), and speak the part in the curly braces (spelled/written however you\nneed to get the correct pronunciation).\n\nCaution: there is a known bug that this method stops the audio generator from\nadding the time markers for the \"read along\" highlighting of the text. You might only have highlighting for part of your\nline, or no highlighting at all.\n"
  },
  {
    "path": "public/docs/audio-generation/generate.mdx",
    "content": "---\ntitle: \"Generate Audio\"\ndescription: \"How to apply the voices in a story.\"\n---\n\nIn the story, click the Audio button in the header.\n\n<Image>\n![Location of the audio button in the story editor.](/docs/audio-generation/audio_button.png \"The button to display audio generation.\")\n</Image>\n\nThen, for every line, click the spinner next to it.\n\n<Warning>It is recommend waiting for each line to generate before moving to the next one,\nthere's currently a small timing issue in it.</Warning>\n\n<Image>\n![The spinner icon next to the line.](/docs/audio-generation/spinner.png \"The creation of audio.\")\n</Image>\n\nIf you generate them too quickly, the audio line may end up getting misplaced, like this. To fix it, just cut the\nhint line, and paste it above the yellow one.\n\n<Image>\n![The line is created in the wrong position.](/docs/audio-generation/error.png \"An error that could occur.\")\n</Image>\n\n### The audio line\nThe line that is inserted after the audio has been created show the location of the audio file and the numbers for the\nword timings. You generally do not need to edit it.\n"
  },
  {
    "path": "public/docs/audio-generation/overview.mdx",
    "content": "---\ntitle: \"Overview\"\ndescription: \"The basic steps to generate audio.\"\n---\n\nTo make the Duostories more engaging we use audio for the text in the stories.\nThis audio is machine generated using text-to-speech (TTS) engines.\n\nIf you have translated a story and want to add audio, you first need to assign voices to the\ncharacters in the story using the \"Character Editor\" if they do not already have voices. Then you\ncan generate each audio line separately, see \"Generate Audio\"."
  },
  {
    "path": "public/docs/become-contributor/application.mdx",
    "content": "---\ntitle: \"Application\"\ndescription: \"Join our community as a contributor.\"\n---\n\nIf you would like to contribute by translating stories into other languages:\n\n### Requirements\nTo contribute stories, you must:\n\n- Be a native speaker of the language,\n- Have native-level proficiency in the language, or\n- Have a native-speaker \"partner\" who can revise your work.\n\n<Info> If you plan on working with a native-speaker partner, it is best to have that native speaker join this server and submit an application as well, so that you both can be given access to the Editor.</Info>\n\n\nThe only exceptions are for those languages that have no (or very few) native speakers including dead languages, nearly-extinct languages, and conlangs.\n\nPlease, only apply to be a contributor if you’re willing to translate at least one Set of (4) Stories.\n\n<Warning>If you are applying to translate stories into a dialect, regionally-specific language, conlang, or auxlang: please see our policies related to those, and include in your application any information about the language you would like us to consider.</Warning>\n\n### Application Process\n\n- Create an account on [Duostories.org](https://duostories.org/), if you don't already have one. This account will be used for accessing the Editor and Stories.\n- On your [profile page](https://duostories.org/profile), link your Duostories account to this Discord account.\n- Write a post in <Channel href=\"https://discord.com/channels/726701782075572277/1132747276234792980\">#contributor-applications</Channel> with the following [you will have access to the channel once you read the directions]:\n    1. In English, tell us which language you would like to translate into (e.g. \"I would like to translate into Russian …\") and from (e.g. \"… for speakers of Spanish\").\n    2. In English, provide your Duostories.org account name.\n    3. Write us an application message in the language you want to translate into (e.g. if you want to create Russian stories, write your application message in Russian). Show off your skills! Tell us about your background, your language experience and qualifications, your interests, and anything else you like. Just be sure to demonstrate native-level proficiency in your language. (If you're not a native speaker, be sure to have your native-speaker partner revise this before sending.)\n- Project admins, current Story contributors, and native speakers will review your application. If you're able to demonstrate native-level proficiency in your language and your language is currently technically possible, you will be given access to the Story Editor and further instructions."
  },
  {
    "path": "public/docs/become-contributor/colang.mdx",
    "content": "---\ntitle: \"Conlangs/Dialects\"\ndescription: \"Does your language qualify for duostories.\"\n---\n\n### Conlangs\n#### If my language is a constructed language (conlang) or auxiliary language (auxlang), can I still translate stories into that language?\n\nWhile the primary focus of this project is to feature natural languages, we acknowledge that some conlangs/auxlangs are used to a similar extent as some minor natural languages. Esperanto is a well-known example with thousands of speakers worldwide. Especially because it is also taught on Duolingo, it makes sense to include it here.\n\n#### When is a conlang/auxlang established enough to be featured on the website?\n\nIn order to maximize the benefits to learners, we will be more interested in featuring a conlang/auxlang when we see some of these factors, so please be sure to discuss them in your application.\n\n- There are a significant number of speakers/learners of the language (e.g. > 100)\n- The language has been in development for a significant period of time (e.g. 10+ years)\n- There are other websites or texts that provide material on the language (e.g. an official dictionary)\n- The language has received some degree of notoriety in the conlang/auxlang community (e.g. received awards, featured in articles/videos)\n- The language has its own Wikipedia edition (e.g. https://eo.wikipedia.org/, https://en.wikipedia.org/wiki/List_of_Wikipedias) and/or an ISO code (e.g. Esperanto, “epo”)\n\n\nIf your language is a personal conlang/auxlang project or a very new project from a small group, we are hesitant to support the language as we do not see clear value for our community of learners. When we do allow these smaller languages access to the project, we will not feature them on the main page of Duostories.org. The course contributor would be given a direct link to share with interested learners.\n\n### Dialects\n#### Can I translate stories into a dialect or regionally-specific language?\n\nWe are hesitant to support languages that are too regionally-specific because at times they are not well-defined enough that a course would even make sense to learners. Applications for dialects/regional languages will be considered against a set of factors on a case-by-case basis. It might be a “yes” if your language:\n\n- Is classified as an “endangered” language\n- Has a well-defined written form and spelling\n- Has an ISO code\n- Has a language foundation or association to support it\n- Has a broad body of published literature\n"
  },
  {
    "path": "public/docs/docs.json",
    "content": "{\n  \"navigation\": [\n    {\n      \"group\": \"\",\n      \"pages\": [\"introduction\"]\n    },\n    {\n      \"group\": \"Become a Contributor\",\n      \"pages\": [\n        \"become-contributor/application\",\n        \"become-contributor/colang\"\n      ]\n    },\n    {\n      \"group\": \"Story Importing\",\n      \"pages\": [\n        \"story-creation/import\",\n        \"story-creation/translate\"\n      ]\n    },\n    {\n      \"group\": \"Story Editing\",\n      \"pages\": [\n        \"story-editing/overview\",\n        \"story-editing/translation-hints\",\n        \"story-editing/exercises\"\n      ]\n    },\n    {\n      \"group\": \"Audio Generation\",\n      \"pages\": [\n        \"audio-generation/overview\",\n        \"audio-generation/character-editor\",\n        \"audio-generation/engines\",\n        \"audio-generation/edit\",\n        \"audio-generation/generate\",\n        \"audio-generation/fix-problems\"\n      ]\n    },\n    {\n      \"group\": \"Story Publishing\",\n      \"pages\": [\n        \"story-publishing/publishing\",\n        \"story-publishing/without_tts\"\n      ]\n    }\n  ]\n}"
  },
  {
    "path": "public/docs/introduction.mdx",
    "content": "---\ntitle: \"Introduction\"\ndescription: \"The guide to contributing to duostories.\"\n---\n\nWelcome to Duostories Documentation. Here you can find explanations\non how our contributors platform works.\n\nLearn how to become a contributor, how to edit stories and how to create audio.\n"
  },
  {
    "path": "public/docs/search.js",
    "content": "let data = null;\nlet pages = null;\n\nasync function getPageData(page) {\n  try {\n    const res = await fetch(\"/docs/\" + page + \".mdx\").then((res) => res.text());\n    let data = res.split(\"---\");\n    let metadata = {};\n    for (let line of data[1].split(\"\\n\")) {\n      let pos = line.indexOf(\":\");\n      if (pos === -1) continue;\n      let key = line.slice(0, pos).trim();\n      let value = line.slice(pos + 1).trim();\n      metadata[key.trim()] = value.match(/\\s*\"(.*)\"\\s*/)[1];\n    }\n    metadata.body = data[2];\n    let parts = [];\n    let current_link = page;\n    let current_index = undefined;\n    for (let line of metadata.body.split(\"\\n\")) {\n      line = line.trim();\n      if (line.substring(0, 1).match(/\\w/)) {\n        if (current_index !== undefined) {\n          parts[current_index].text += \" \" + line;\n        } else {\n          parts.push({ type: \"text\", text: line, link: current_link });\n          current_index = parts.length - 1;\n        }\n        continue;\n      }\n      current_index = undefined;\n      if (line.startsWith(\"#\")) {\n        parts.push({\n          type: \"heading\",\n          text: line.match(\"#*s*(.*)\")[1],\n          link: current_link,\n        });\n      }\n    }\n    metadata.parts = parts;\n    metadata.link = page;\n    return metadata;\n  } catch (e) {\n    return { title: path, body: \"\", link: path };\n  }\n}\nasync function search() {\n  if (!data) data = await (await fetch(\"/docs/docs.json\")).json();\n  if (!pages) {\n    pages = [];\n    let promises = [];\n    for (let group of data.navigation) {\n      for (let page of group.pages) {\n        promises.push(getPageData(page).then((page) => pages.push(page)));\n      }\n    }\n    await Promise.all(promises);\n  }\n  let innerHTML = \"\";\n  for (let page of pages) {\n    let found = false;\n    for (let part of page.parts) {\n      if (part.text.includes(this.value)) {\n        if (!found) {\n          found = true;\n          innerHTML += `<a href=\"/docs/${page.link}\" data-type=\"main\">${page.title}</a>`;\n        }\n        innerHTML += `<a href=\"/docs/${part.link}\" data-type=\"sub\">${part.text}</a>`;\n      }\n    }\n  }\n  document.getElementById(\"search_results\").innerHTML = innerHTML;\n}\n\nfunction display_search(do_show) {\n  if (do_show) {\n    document.getElementById(\"search_modal\").setAttribute(\"show\", true);\n    document.getElementById(\"blur2\").setAttribute(\"show\", true);\n    document.getElementById(\"search_input\").value = \"\";\n    document.getElementById(\"search_input\").focus();\n    search();\n  } else {\n    document.getElementById(\"search_modal\").setAttribute(\"show\", false);\n    document.getElementById(\"blur2\").setAttribute(\"show\", false);\n  }\n}\n\nfunction toggle(value) {\n  return;\n  if (value === undefined) {\n    if (document.getElementById(\"container\").getAttribute(\"show\") == \"true\") {\n      value = \"false\";\n    } else {\n      value = \"true\";\n    }\n  }\n  document.getElementById(\"container\").setAttribute(\"show\", value);\n}\n\nfunction init() {\n  document.getElementById(\"search_input\").onkeyup = search;\n  document.getElementById(\"search\").onclick = () => display_search(true);\n  document.getElementById(\"blur2\").onclick = () => display_search(false);\n  document.addEventListener(\"keydown\", (e) => {\n    if (e.key === \"Escape\") {\n      display_search(false);\n    }\n    if (e.key === \"k\" && e.ctrlKey) {\n      display_search(true);\n      e.preventDefault();\n    }\n  });\n\n  document.getElementById(\"toggle\").onclick = (e) => toggle();\n  document.getElementById(\"blur\").onclick = (e) => toggle();\n  document.getElementById(\"close\").onclick = (e) => toggle();\n}\ndocument.addEventListener(\"DOMNodeInserted\", (event) => {\n  init();\n});\ndocument.addEventListener(\"DOMNodeRemoved\", (event) => {\n  init();\n});\ninit();\n"
  },
  {
    "path": "public/docs/story-creation/import.mdx",
    "content": "---\ntitle: \"Import Story\"\ndescription: \"Initialize a new story translation.\"\n---\n\nClick the import button in the menu for your language, and click the story you'd like to add. You'll be redirected to the editor for that story.\n\n![The import button](/docs/story-creation/import.png \"Import\")\n\n### Importing from Spanish\n\nWe import stories from Spanish, as they're the most extensive on Duolingo. But sometimes it can make sense\nto import from another language.\n\nWe import from the \"Spanish from English\" course and not from the \"English from Spanish\" as this already has\nthe hints in English, so it reduces the amount of text you need to change.\n\n### Importing from other Languages\n\nYou can change the last part of the import url, e.g. `es-en` to any other course to import from that course.\n```\nhttps://duostories.org/editor/course/nl-en/import/es-en\n```\n\n### Intro and welcome stories\nIf you're translating to a smaller language, or a conlang, a welcome story can be a good idea to show users wandering\nthrough the site what your language actually is, or if there is some useful information to show about the language.\nTo make one, import a new story (you can choose any story here) and set the Set Number to 0 in the editor.\n\n![Set 0|0](/docs/story-creation/set00.png \"Set 0|0\")\n\nI'd recommend looking through the stories you can import to find one with a nice icon.\nThen, write whatever you want to explain your language.\n\nSome good examples are @𝕰𝖓𝖝𝖆666's story for [Catalan](https://www.duostories.org/story/2179)\nand @Candy Butcher's story for [Interslavic](https://www.duostories.org/story/2030).\n\nOnce your Welcome Story has been reviewed by another contributor and 👍 approved—contact\na Moderator to manually publish it to the site.\n\n### Can I write my own stories?\nShort answer is: no. The scope of the project is to bring the existing stories from Duolingo to new languages.\nThese stories have been developed by a team of linguists, it would be very difficult to match their quality with our\nteam of volunteers.\n\nIt also makes it easier to review stories as we only need to review the translation and not the content of the new story.\nIs the content appropriate? Is the content well suited for a learner with the particular knowledge level, is the length of\nthe story right, are the characters in the story according to the personality of the character cast, etc.\n\nIf we have translated all stories we might consider expanding this but for now the best way is to stick with the stories\nfrom Duolingo to provide high quality content for our learners.\n\n### Can I skip stories?\nPlease follow the order of the stories from Duolingo. If we skip ahead it will make the organisation of the stories more\ndifficult and can be an unpleasant experience for the learner if the difficulty level changes to much from one story to\nthe next.\n\nIf you think a story needs to be skipped for your language, please consult with a Moderator first.\n"
  },
  {
    "path": "public/docs/story-creation/translate.mdx",
    "content": "---\ntitle: \"How to Translate\"\ndescription: \"What to consider when translating.\"\n---\n\n### Translating the Story\n\nTo translate, swap the Spanish (or other language) for the language you're translating to, and rearrange the English words to match\nup with your language's word order.\n\nIf you need to change the English words to better fit your language, go ahead.\n\n### What to change\n\nSimilarly, feel free to change the currency to that of your language!\n\n![This jacket is 100 dollars. In this example the currency is dollars.](/docs/story-creation/currency.png \"You can use dollars!\")\n\nFor example, if you're making German stories, change the currency to Euros.\nThe same thing applies to places mentioned\n\n![I have a ticket to California. In this example the location is California.](/docs/story-creation/place.png \"You can use other places!\")\n\nFeel free to use whatever geographical places correspond with your language.\n\n### English\nStories on Duostories use the American English dialect just like stories on Duolingo.\n\nMost Duolingo courses use American English translations and teach American English in reverse courses.\nThe stories are aligned with that material and use American English as well.\nAs such, Duostories contributors should not try to convert stories to using any other dialects\n(including British English).\n\nThis helps stories on Duostories stay aligned with Duolingo course material including\ncurrent Duolingo stories. This also avoids story quality issues created by changing existing content,\nsuch as hints, to another dialect, and helps maintain consistency between stories of different courses.\n\nWe understand that some contributors may not be as familiar with American English, so if you have any questions as\nyou're contributing, feel free to reach out in\n<Channel href=\"https://discord.com/channels/726701782075572277/978160778010062919\">#general-contributors</Channel>."
  },
  {
    "path": "public/docs/story-editing/exercises.mdx",
    "content": "---\ntitle: \"Exercises\"\ndescription: \"Reading comprehension questions to engage the learner.\"\n---\n\nThe Duostories are like the original Duolingo stories interactive,\nthe learner is prompted with questions to make sure the learner understands the story.\n\n### Multiple Choice\n\n```\n[MULTIPLE_CHOICE]\n> Priti can't find her keys.\n+ Yes, that's right.\n- No, that's wrong.\n```\n\nThe exercise consists of a question and two or more answers.\nThe right answer line starts with a `+` sign, the others with a `-` sign.\n\n![Example of Multiple Choice exercise](/docs/story-editing/multiple_choice.png \"Multiple Choice\")\n\n\n### Arrange\n\n```\n[ARRANGE]\n> Tap what you hear\nSpeaker560: ¡[(Necesito) (las~llaves) (de) (mi) (carro)!]\n~              I~need     the~keys     of   my   car\n```\n\nThe audio is played for the learner so that the learner\nnow needs to reproduce the sentence by bringing the missing words into the right\norder. The part between the square brackets `[]` will be hidden and words in\nparentheses `()` are\nconverted into buttons that the user can click to arrange them.\n\n![Example of Arrange exercise](/docs/story-editing/arrange.png \"Arrange Choice\")\n\n\n### Point to Phrase\n```\n[POINT_TO_PHRASE]\n> Choose the option that means \"tired.\"\nSpeaker560: (Perdón), mi amor, (estoy) (+cansada). ¡(Trabajo) mucho!\n~            sorry    my love   I~am     tired       I~work   a~lot\n```\n\nFirst the sentence is played in the story like a normal sentence.\nThen the story hides the hints and the learner has to pick the word from the sentence with the right meaning.\nMark some words with parentheses `()` that will be shown as buttons. The right answer\nis indicated by a plus sign `+` after the opening parentheses `(` .\n\n![Example of Point to Phrase exercise](/docs/story-editing/arrange.png \"Point to Phrase\")\n\n\n### Select Phrase\n```\n[SELECT_PHRASE]\n> Select the missing phrase\nSpeaker507: Hoy   tengo  [un~partido~importante].\n~           today I~have  an~important~game\n+ un partido importante\n- un batido importante\n- una parte imponente\n```\n\nHere the audio is played for the whole sentences but a part of the sentences is hidden. This\npart is enclosed in square brackets `[]`. Then the user has to select from three\nsimilar sounding alternatives what was really said in the sentences.\n\n![Example of Select Phrase exercise](/docs/story-editing/select_phrase.png \"Select Phrase\")\n\n### Continuation\n```\n[CONTINUATION]\n> What's next?\nSpeaker508: Tienes   cuatro botellas [de vino].\n~           you~have four   bottles   of wine\n- la  mesa\n~ the table\n- de pastel\n~ of cake\n+ de vino\n~ of wine\n```\nThe learner sees a sentences where a part is missing. This part will also be omitted by the audio.\nThe learner then has to fill in the gap with one of three different alternatives.\n\n<Info>Although this seems very similar to `[SELECT_PHRASE]`, they are different as here its about\nfilling in a missing word from the context whereas in `[SELECT_PHRASE]` is about listening to the audio.</Info>\n\n![Example of Continuation exercise](/docs/story-editing/select_phrase.png \"Continuation\")\n\n### Match\n```\n[MATCH]\n> Tap the pairs\n- estás <> you are\n- mucho <> a lot\n- es <> is\n- las llaves <> the keys\n- la <> the\n```\nThe last exercise of a story is to match 5 words that occurred in the story with their translation.\nWrite the word in the target language on the left followed by `<>` and on the right the translation.\n\n![Example of Match exercise](/docs/story-editing/match.png \"Match\")\n"
  },
  {
    "path": "public/docs/story-editing/overview.mdx",
    "content": "---\ntitle: \"Overview\"\ndescription: \"The editor with the two views.\"\n---\n\nDuostories is not like the software *Word* where you edit the final output directly, but you write\nthe stories in some kind of \"coding\" language that will be converted to the final story display.\n\nThis sounds complicated but in practice is easy to use and helps to see what is going on exactly.\n\n### Editor\nThe editor has two panels, on the left you can see the code that you can edit and on the\nright you see how the story will look like. Both views are synchronized when scrolling so that\nyou have always the matching view.\n\n### Comments\n```\n# This line is a comment\n```\nSometimes it can be helpful to leave comments in the code. For example\nif you want to mark a translation where you want to consult another contributor.\n\nYou can mark a line with the hash sign `#` to mark it as a comment that should not\nappear in the final story.\n\n### Save\nYou can save a story with a click on the ![save](/docs/story-editing/save_button.png \"save\") symbol. Changes\nto stories are tracked on [GitHub](https://github.com/rgerum/unofficial-duolingo-stories-content).\n\n### Meta data\n```\n[DATA]\nfromLanguageName=A Family Dinner\nicon=076bc5bf725308c211b9c04f07d3622683e84d78\nset=8|4\n```\n\nOn the top of the story there is some meta data that defines the name\nas it is shown in the story list, the icon that the story uses and the set.\nThe set number is composed o the set id (e.g. Set 8) and the position within the set, the set index (e.g. the 4th story\nof the set).\n\n<Warning>In some legacy stories you still see some character icon or speaker definitions here.\nThey are only for compatibility with old stories and should not be used in new story translations.</Warning>\n"
  },
  {
    "path": "public/docs/story-editing/translation-hints.mdx",
    "content": "---\ntitle: \"Translation Hints\"\ndescription: \"How to provide translation hints.\"\n---\n\nAn important part of learning with Duostories is to have the\ntranslation of a word in the story directly available. Therefore, when translation\nthe story you also need to provide translation hints.\n\nThe system works as follows: you write the sentence in one line and the translation below:\n\n```\n[LINE]\n> Jan is thuis met  zijn vrouw, Marian.\n~ ~   is home  with his  wife   ~\n```\n\nThe sentence will be split into words and each word will be matched with the word on the sentence below.\nIf a word is translated with \"~\", no translation will be displayed for the word.\nThis is used for names where there is not need for a translation.\n\n<Info>The syntax highlighting will highlight words alternating in blue and green to help you see\nhow it will perform the mapping.</Info>\n\n### Joining Words\nSometimes translations are more complicated than a one to one match of words.\n\n```\n[LINE]\nSpeaker292: Weet~jij    waar mijn lesboek Engels is?\n~           do~you~know Where  my textbook English is\n```\n\nHere you can \"glue\" words together using the tilde sign `~`. They are treated as one word for\nthe sake of translation. So *\"Weet jij\"* will have the joined hint *\"do you know\"*.\n\nWords in the target language as well as the translation can be joined to create one to many, many to one,\nor many to many mappings.\n\n### Splitting Words\nSometimes languages do not separate words with spaces. A prominent example here is chinese script.\n\n```\n[LINE]\nSpeaker560: 我的|钥匙|在哪里？\n~           my  keys (are)~where\n```\nHere you can use the vertical bar `|` (also known as pipe). Its the opposite of the title and splits words\nfor the translation hints while it still appears without spaces in the final story.\n\n### Show Hints\n\nWith a click on the ![hints](/docs/story-editing/hints_button.png \"Hints\") button you can show the hints displayed\ndirectly below each word. Especially when reviewing stories this is a great tool.\n\n![Example of hints display](/docs/story-editing/hint.png \"Hints\")\n\n### Pronunciation Hints\n\nYou can add an optional pronunciation hint line using `^` directly below the text (or below `~` if both are present).\nThis works for pinyin and other pronunciation systems.\n\n```\n[LINE]\nSpeaker560: 我的|钥匙|在哪里？\n~           my  keys (are)~where\n^           wǒ~de yào~shi zài~nǎ~lǐ\n```\n\nThe `^` line uses the same alignment rules as translation hints:\n- use spaces to align tokens,\n- use `~` to join words/tokens,\n- use `|` to split words/tokens.\n\nYou can also attach pronunciation inline to a translation token by adding `{...}` in the `~` line:\n\n```\n~ ... sit~and~{いー~or~うぃー} ...\n```\n\nThis keeps the translation hint as `sit and` and sets the pronunciation hint for that same mapped phrase to `いー or うぃー`.\n\nToken alignment is shared with the main text, so each text token can have:\n- a translation hint from `~`,\n- a pronunciation hint from `^`,\n- both, or neither (`~` can still be used to suppress a hint for one token).\n\nPronunciation hints from `^` are shown directly above the words.\nTranslation hints from `~` keep the existing hint behavior (hover in story mode, inline in editor hint mode).\n"
  },
  {
    "path": "public/docs/story-publishing/publishing.mdx",
    "content": "---\ntitle: \"Publish a Story\"\ndescription: \"The review process for publishing.\"\n---\n\nTo make sure that the translated stories meet certain quality criteria,\nstories need to be reviewed first before being published.\n\nStories are generally published in full sets of 4.\n\nWhen you have finished working on a story, you can click the \"👍\" icon to approve it and change the status to \"🗨 feedback\".\n\nYou can now ask your team mates on discord to review the story and give their approval.\n\nWhen one or more people have checked the story and also gave their approval \"👍\" the status changes to \"✅ finished\".\n\nWhen one complete set is finished it will switch to \"📢 published\".\n\n<Info>If this is the first set of your language to be published,\nthe chances are high that the course itself is not set to public yet. Talk to\na moderator on Discord to publish your course.</Info>\n\n### Criteria for Approval\nWhen you approve a story be sure to check the content carefully.\n\n- Are all the sentences translated into the target language?\n- Do the sentences have proper punctuations? E.g. end with a period `.`\n- Do all words (except names) have translation hints?\n- Does the story have audio? (Only if the language has audio available)\n- If the course does not have audio: Are all ARRANGE exercises converted to POINT_TO_PHRASE and SELECT_PHRASE to CONTINUATION? (see [Publish without Audio](https://duostories.org/docs/story-publishing/without_tts))\n\n<Info>This is not a \"Like\" as you would give it on social media but a signature\n      that you certify that the story is ready to be published.</Info>\n\n### Need a set of stories reviewed?\nIf you are a solo contributor for a course, or none of your teammates are available, and you have a full set of stories ready for review, create a post for your language, and others can volunteer to help out!\n\nGo to the <Channel href=\"https://discord.com/channels/726701782075572277/1114267302825824368\">#review-request</Channel> channel.\n\nPlease title your post with the names of the languages, so folks can find languages they are familiar with! It is also helpful if you include a link to your course in the Editor. You are welcome to collaborate on review and editing in your forum post here, if you don't have a language-specific contrib channel.\n\n### Publish a \"Intro/Welcome\" story\nAs welcome stories (see [Intro and welcome stories](https://duostories.org/docs/story-creation/import#intro-and-welcome-stories)) are not part\nof a complete set, they need to be approved by a Moderator. Please contact a Moderator on Discord once your\n\"Intro/Welcome\" story has at least two approvals 👍.\n"
  },
  {
    "path": "public/docs/story-publishing/without_tts.mdx",
    "content": "---\ntitle: \"Publish without Audio\"\ndescription: \"How to publish stories without TTS audio.\"\n---\n\nOur goal is to have audio in all of the stories we publish, to best match the Duolingo experience, and to give\nlearners practice at listening comprehension. But we can probably all agree that teaching incorrect pronunciation is\nworse than having no listening practice at all!\n\nAfter experimenting with the different TTS services and voices we have available (and the above tips and tricks),\nif you cannot find any that correctly pronounce your language, there are some edits you will need to make to your\nstories. Converting the \"listening\" questions to similar \"comprehension\" questions will give readers a better learning\nexperience.\n\n<Info>If you want to make it easy to convert back to listening exercises when TTS voices become available in the future\n🤞🏽, you can make a duplicate of the exercise, comment-out #the lines of the old one, and convert the new one.</Info>\n\n### Convert \"Arrange\" to \"Point to Phrase\"\n\n- Change the exercise title from `[ARRANGE]` to `[POINT_TO_PHRASE]`.\n- Change the prompt-line from `> Tap what you hear` to `> Choose the option that means \".\"`\n- Remove the square brackets `[ ]` around the \"Speaker\"-line.\n- Select which word/phrase you will ask the learner to recognize, and mark it with a plus-sign inside the\n  parentheses: `(+CorrectAnswer)`.\n- Add the hint for that word/phrase in the prompt-line: `> Choose the option that means \"From~Language Hint.\"`\n\n\nOld:\n```\n#[ARRANGE]\n#> Tap what you hear\n#Speaker100: [(Babam)  (Kanadalı)   (ve)   (annem)  (Türkiyeli).]\n#~      my~father (is)~from~Canada   and   my~mother   (is)~from~Turkey\n```\n\nNew:\n```\n[POINT_TO_PHRASE]\n> Choose the option that means \"my~mother.\"\nSpeaker100: (Babam)  (Kanadalı)   (ve)   (+annem)  (Türkiyeli).\n~      my~father (is)~from~Canada   and   my~mother   (is)~from~Turkey\n```\n\n### Convert \"Select Phrase\" to \"Continuation\"\n\n- Change the exercise title from `[SELECT_PHRASE]` to `[CONTINUATION]`.\n- Change the prompt-line from `> Select the missing phrase` to `> What comes next?`\n- Change your alternate answers from the \"sound-alikes\" useful on the listening exercise to answers that will not make\n  sense in your sentence.\n- Add hint-lines after each of the multiple choice answers, like: `~ From~Language Hint`\n  (using tildes and pipes as needed to align the hints and answers).\n\n\nOld:\n```\n#[SELECT_PHRASE]\n#> Select the missing phrase\n#Speaker507: Bugün [önemli~bir maçım] var.\n#~           today an~important my~game I~have\n#+ önemli bir maçım\n#- önemli bir maçımız\n#- önemsiz bir maçım\n```\n\nNew:\n```\n[CONTINUATION]\n> What comes next?\nSpeaker507: Bugün [önemli~bir maçım] var.\n~           today an~important my~game I~have\n+ önemli~bir maçım\n~ an~important my~game\n- mor kurbağalarım\n~ purple my~frogs\n- altı çarşambam\n~ six my~Tuesday\n```\n"
  },
  {
    "path": "public/robots.txt",
    "content": "# Block all crawlers for /editor\nUser-agent: *\nDisallow: /editor\n\n# Block all crawlers for /admin\nUser-agent: *\nDisallow: /admin\n\n# Allow all crawlers\nUser-agent: *\nAllow: /"
  },
  {
    "path": "public/sw.js",
    "content": "/**\n * Copyright 2018 Google Inc. All Rights Reserved.\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *     http://www.apache.org/licenses/LICENSE-2.0\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// If the loader is already loaded, just stop.\nif (!self.define) {\n  let registry = {};\n\n  // Used for `eval` and `importScripts` where we can't get script URL by other means.\n  // In both cases, it's safe to use a global var because those functions are synchronous.\n  let nextDefineUri;\n\n  const singleRequire = (uri, parentUri) => {\n    uri = new URL(uri + \".js\", parentUri).href;\n    return registry[uri] || (\n      \n        new Promise(resolve => {\n          if (\"document\" in self) {\n            const script = document.createElement(\"script\");\n            script.src = uri;\n            script.onload = resolve;\n            document.head.appendChild(script);\n          } else {\n            nextDefineUri = uri;\n            importScripts(uri);\n            resolve();\n          }\n        })\n      \n      .then(() => {\n        let promise = registry[uri];\n        if (!promise) {\n          throw new Error(`Module ${uri} didn’t register its module`);\n        }\n        return promise;\n      })\n    );\n  };\n\n  self.define = (depsNames, factory) => {\n    const uri = nextDefineUri || (\"document\" in self ? document.currentScript.src : \"\") || location.href;\n    if (registry[uri]) {\n      // Module is already loading or loaded.\n      return;\n    }\n    let exports = {};\n    const require = depUri => singleRequire(depUri, uri);\n    const specialDeps = {\n      module: { uri },\n      exports,\n      require\n    };\n    registry[uri] = Promise.all(depsNames.map(\n      depName => specialDeps[depName] || require(depName)\n    )).then(deps => {\n      factory(...deps);\n      return exports;\n    });\n  };\n}\ndefine(['./workbox-8817a5e5'], (function (workbox) { 'use strict';\n\n  importScripts();\n  self.skipWaiting();\n  workbox.clientsClaim();\n  workbox.registerRoute(\"/\", new workbox.NetworkFirst({\n    \"cacheName\": \"start-url\",\n    plugins: [{\n      cacheWillUpdate: async ({\n        request,\n        response,\n        event,\n        state\n      }) => {\n        if (response && response.type === 'opaqueredirect') {\n          return new Response(response.body, {\n            status: 200,\n            statusText: 'OK',\n            headers: response.headers\n          });\n        }\n        return response;\n      }\n    }]\n  }), 'GET');\n  workbox.registerRoute(/.*/i, new workbox.NetworkOnly({\n    \"cacheName\": \"dev\",\n    plugins: []\n  }), 'GET');\n\n}));\n"
  },
  {
    "path": "scripts/backfill-course-contributors.ts",
    "content": "import dotenv from \"dotenv\";\n\ndotenv.config({ path: \".env.local\" });\n\nconst CONVEX_SITE_URL =\n  process.env.NEXT_PUBLIC_CONVEX_SITE_URL ??\n  process.env.CONVEX_SITE_URL ??\n  process.env.NEXT_PUBLIC_CONVEX_URL ??\n  process.env.CONVEX_URL;\nconst COURSE_CONTRIBUTOR_BACKFILL_SECRET =\n  process.env.COURSE_CONTRIBUTOR_BACKFILL_SECRET;\nconst BATCH_SIZE = parsePositiveNumber(\n  process.env.COURSE_CONTRIBUTOR_BACKFILL_BATCH_SIZE,\n  10,\n);\nconst BATCH_DELAY_MS = parsePositiveNumber(\n  process.env.COURSE_CONTRIBUTOR_BACKFILL_BATCH_DELAY_MS,\n  0,\n);\nconst DRY_RUN = parseBooleanEnv(\n  process.env.COURSE_CONTRIBUTOR_BACKFILL_DRY_RUN,\n  false,\n);\n\nif (!CONVEX_SITE_URL) {\n  console.error(\n    \"Error: NEXT_PUBLIC_CONVEX_SITE_URL/CONVEX_SITE_URL/CONVEX_URL is not set.\",\n  );\n  process.exit(1);\n}\n\nif (!COURSE_CONTRIBUTOR_BACKFILL_SECRET) {\n  console.error(\"Error: COURSE_CONTRIBUTOR_BACKFILL_SECRET is not set.\");\n  process.exit(1);\n}\n\ntype BackfillResult = {\n  processed: number;\n  updatedCourses: number;\n  nextCursor: string | null;\n  isDone: boolean;\n  errors: Array<{\n    courseId: number;\n    message: string;\n  }>;\n};\n\nfunction parsePositiveNumber(value: string | undefined, fallback: number) {\n  if (value === undefined || value.trim() === \"\") return fallback;\n  const parsed = Number(value);\n  if (!Number.isFinite(parsed) || parsed <= 0) {\n    console.error(\"Error: batch values must be positive numbers.\");\n    process.exit(1);\n  }\n  return Math.floor(parsed);\n}\n\nfunction parseBooleanEnv(value: string | undefined, defaultValue: boolean) {\n  if (value === undefined) return defaultValue;\n  const normalized = value.trim().toLowerCase();\n  if ([\"1\", \"true\", \"yes\", \"y\", \"on\"].includes(normalized)) return true;\n  if ([\"0\", \"false\", \"no\", \"n\", \"off\"].includes(normalized)) return false;\n  return defaultValue;\n}\n\nfunction sleep(ms: number) {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nasync function runBatch(baseUrl: string, cursor: string | null) {\n  const response = await fetch(`${baseUrl}/admin/backfill-course-contributors`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify({\n      secret: COURSE_CONTRIBUTOR_BACKFILL_SECRET,\n      batchSize: BATCH_SIZE,\n      cursor,\n      dryRun: DRY_RUN,\n    }),\n  });\n\n  const text = await response.text();\n  let parsed: unknown = null;\n  try {\n    parsed = JSON.parse(text);\n  } catch {\n    parsed = text;\n  }\n\n  if (!response.ok) {\n    throw new Error(\n      `Backfill request failed with ${response.status}: ${JSON.stringify(parsed)}`,\n    );\n  }\n\n  return parsed as BackfillResult;\n}\n\nasync function main() {\n  const baseUrl = String(CONVEX_SITE_URL).replace(/\\/+$/, \"\");\n  let cursor: string | null = null;\n  let processedTotal = 0;\n  let updatedCoursesTotal = 0;\n  const errors: BackfillResult[\"errors\"] = [];\n  let batchNumber = 0;\n\n  while (true) {\n    batchNumber += 1;\n    console.log(\n      `Running batch ${batchNumber} (size=${BATCH_SIZE}, cursor=${cursor ?? \"start\"})...`,\n    );\n\n    const result = await runBatch(baseUrl, cursor);\n    processedTotal += result.processed;\n    updatedCoursesTotal += result.updatedCourses;\n    errors.push(...result.errors);\n\n    console.log(\n      `Batch ${batchNumber} complete: processed=${result.processed}, updatedCourses=${result.updatedCourses}, errors=${result.errors.length}`,\n    );\n\n    if (result.isDone || !result.nextCursor || result.processed === 0) {\n      break;\n    }\n\n    cursor = result.nextCursor;\n    if (BATCH_DELAY_MS > 0) {\n      await sleep(BATCH_DELAY_MS);\n    }\n  }\n\n  console.log(\"Course contributor backfill completed.\");\n  console.log(\n    JSON.stringify(\n      {\n        processed: processedTotal,\n        updatedCourses: updatedCoursesTotal,\n        errors,\n      },\n      null,\n      2,\n    ),\n  );\n}\n\nmain().catch((error) => {\n  console.error(\"Course contributor backfill failed.\");\n  console.error(error);\n  process.exit(1);\n});\n"
  },
  {
    "path": "scripts/backfill-discord-avatars.ts",
    "content": "import dotenv from \"dotenv\";\n\ndotenv.config({ path: \".env.local\" });\n\nconst CONVEX_SITE_URL =\n  process.env.NEXT_PUBLIC_CONVEX_SITE_URL ??\n  process.env.CONVEX_SITE_URL ??\n  process.env.NEXT_PUBLIC_CONVEX_URL ??\n  process.env.CONVEX_URL;\nconst DISCORD_AVATAR_SYNC_SECRET = process.env.DISCORD_AVATAR_SYNC_SECRET;\nconst TOTAL_LIMIT = parseOptionalNumber(process.env.DISCORD_AVATAR_SYNC_LIMIT);\nconst BATCH_SIZE = parsePositiveNumber(\n  process.env.DISCORD_AVATAR_SYNC_BATCH_SIZE,\n  25,\n);\nconst BATCH_DELAY_MS = parsePositiveNumber(\n  process.env.DISCORD_AVATAR_SYNC_BATCH_DELAY_MS,\n  0,\n);\nconst DRY_RUN = parseBooleanEnv(process.env.DISCORD_AVATAR_SYNC_DRY_RUN, false);\n\nif (!CONVEX_SITE_URL) {\n  console.error(\n    \"Error: NEXT_PUBLIC_CONVEX_SITE_URL/CONVEX_SITE_URL/CONVEX_URL is not set.\",\n  );\n  process.exit(1);\n}\n\nif (!DISCORD_AVATAR_SYNC_SECRET) {\n  console.error(\"Error: DISCORD_AVATAR_SYNC_SECRET is not set.\");\n  process.exit(1);\n}\n\ntype BackfillResult = {\n  processed: number;\n  updatedUsers: number;\n  updatedAccounts: number;\n  skipped: number;\n  nextCursor: string | null;\n  isDone: boolean;\n  errors: Array<{\n    accountId: string | null;\n    userId: string | null;\n    message: string;\n  }>;\n};\n\nfunction parseOptionalNumber(value: string | undefined) {\n  if (value === undefined || value.trim() === \"\") return undefined;\n  const parsed = Number(value);\n  if (!Number.isFinite(parsed)) {\n    console.error(\"Error: DISCORD_AVATAR_SYNC_LIMIT must be a valid number.\");\n    process.exit(1);\n  }\n  return parsed;\n}\n\nfunction parsePositiveNumber(value: string | undefined, fallback: number) {\n  if (value === undefined || value.trim() === \"\") return fallback;\n  const parsed = Number(value);\n  if (!Number.isFinite(parsed) || parsed <= 0) {\n    console.error(\"Error: batch values must be positive numbers.\");\n    process.exit(1);\n  }\n  return Math.floor(parsed);\n}\n\nfunction parseBooleanEnv(value: string | undefined, defaultValue: boolean) {\n  if (value === undefined) return defaultValue;\n  const normalized = value.trim().toLowerCase();\n  if ([\"1\", \"true\", \"yes\", \"y\", \"on\"].includes(normalized)) return true;\n  if ([\"0\", \"false\", \"no\", \"n\", \"off\"].includes(normalized)) return false;\n  return defaultValue;\n}\n\nfunction sleep(ms: number) {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nasync function runBatch(\n  baseUrl: string,\n  cursor: string | null,\n  batchSize: number,\n) {\n  const response = await fetch(`${baseUrl}/admin/backfill-discord-avatars`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify({\n      secret: DISCORD_AVATAR_SYNC_SECRET,\n      batchSize,\n      cursor,\n      dryRun: DRY_RUN,\n    }),\n  });\n\n  const text = await response.text();\n  let parsed: unknown = null;\n  try {\n    parsed = JSON.parse(text);\n  } catch {\n    parsed = text;\n  }\n\n  if (!response.ok) {\n    throw new Error(\n      `Backfill request failed with ${response.status}: ${JSON.stringify(parsed)}`,\n    );\n  }\n\n  return parsed as BackfillResult;\n}\n\nasync function main() {\n  const baseUrl = String(CONVEX_SITE_URL).replace(/\\/+$/, \"\");\n  let cursor: string | null = null;\n  let processedTotal = 0;\n  let updatedUsersTotal = 0;\n  let updatedAccountsTotal = 0;\n  let skippedTotal = 0;\n  const errors: BackfillResult[\"errors\"] = [];\n  let batchNumber = 0;\n\n  while (true) {\n    const remaining =\n      typeof TOTAL_LIMIT === \"number\" ? TOTAL_LIMIT - processedTotal : undefined;\n    if (remaining !== undefined && remaining <= 0) break;\n\n    const currentBatchSize =\n      remaining !== undefined ? Math.min(BATCH_SIZE, remaining) : BATCH_SIZE;\n    batchNumber += 1;\n\n    console.log(\n      `Running batch ${batchNumber} (size=${currentBatchSize}, cursor=${cursor ?? \"start\"})...`,\n    );\n\n    const result = await runBatch(baseUrl, cursor, currentBatchSize);\n    processedTotal += result.processed;\n    updatedUsersTotal += result.updatedUsers;\n    updatedAccountsTotal += result.updatedAccounts;\n    skippedTotal += result.skipped;\n    errors.push(...result.errors);\n\n    console.log(\n      `Batch ${batchNumber} complete: processed=${result.processed}, updatedUsers=${result.updatedUsers}, updatedAccounts=${result.updatedAccounts}, skipped=${result.skipped}, errors=${result.errors.length}`,\n    );\n\n    if (\n      result.isDone ||\n      !result.nextCursor ||\n      result.processed === 0 ||\n      (remaining !== undefined &&\n        typeof TOTAL_LIMIT === \"number\" &&\n        processedTotal >= TOTAL_LIMIT)\n    ) {\n      break;\n    }\n\n    cursor = result.nextCursor;\n    if (BATCH_DELAY_MS > 0) {\n      await sleep(BATCH_DELAY_MS);\n    }\n  }\n\n  console.log(\"Discord avatar backfill completed.\");\n  console.log(\n    JSON.stringify(\n      {\n        processed: processedTotal,\n        updatedUsers: updatedUsersTotal,\n        updatedAccounts: updatedAccountsTotal,\n        skipped: skippedTotal,\n        errors,\n      },\n      null,\n      2,\n    ),\n  );\n}\n\nmain().catch((error) => {\n  console.error(\"Discord avatar backfill failed.\");\n  console.error(error);\n  process.exit(1);\n});\n"
  },
  {
    "path": "scripts/find-missing-story-images.ts",
    "content": "import dotenv from \"dotenv\";\nimport { ConvexHttpClient } from \"convex/browser\";\nimport { api } from \"../convex/_generated/api\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\n\ndotenv.config({ path: \".env.local\" });\n\nconst CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL;\nconst CONCURRENCY = Number(process.env.STORY_IMAGE_AUDIT_CONCURRENCY ?? \"20\");\nconst PUBLISHED_ONLY = parseBooleanEnv(\n  process.env.STORY_IMAGE_AUDIT_PUBLISHED_ONLY,\n  true,\n);\n\nif (!CONVEX_URL) {\n  console.error(\"Error: NEXT_PUBLIC_CONVEX_URL/CONVEX_URL is not set.\");\n  process.exit(1);\n}\n\nif (!Number.isFinite(CONCURRENCY) || CONCURRENCY <= 0) {\n  console.error(\"Error: STORY_IMAGE_AUDIT_CONCURRENCY must be a positive number.\");\n  process.exit(1);\n}\n\nconst client = new ConvexHttpClient(CONVEX_URL);\n\ntype StorySummary = {\n  id: number;\n  name: string;\n  course_id: number;\n  image: string;\n  course_short: string | null;\n  public: boolean;\n};\n\ntype Finding = {\n  storyId: number;\n  storyName: string;\n  courseId: number;\n  courseShort: string;\n  reasons: string[];\n};\n\nasync function mapWithConcurrency<T, R>(\n  items: T[],\n  concurrency: number,\n  worker: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n  const results: R[] = new Array(items.length);\n  let cursor = 0;\n\n  async function runWorker() {\n    while (true) {\n      const index = cursor;\n      cursor += 1;\n      if (index >= items.length) return;\n      results[index] = await worker(items[index], index);\n    }\n  }\n\n  const workers = Array.from(\n    { length: Math.min(concurrency, items.length) },\n    () => runWorker(),\n  );\n  await Promise.all(workers);\n  return results;\n}\n\nfunction parseBooleanEnv(value: string | undefined, defaultValue: boolean) {\n  if (value === undefined) return defaultValue;\n  const normalized = value.trim().toLowerCase();\n  if ([\"1\", \"true\", \"yes\", \"y\", \"on\"].includes(normalized)) return true;\n  if ([\"0\", \"false\", \"no\", \"n\", \"off\"].includes(normalized)) return false;\n  return defaultValue;\n}\n\nasync function getAllStories(): Promise<StorySummary[]> {\n  const sidebar = await client.query(api.editorRead.getEditorSidebarData, {});\n  const courses = sidebar.courses ?? [];\n\n  const storiesByCourse = await mapWithConcurrency(\n    courses,\n    Math.min(CONCURRENCY, 10),\n    async (course) => {\n      const identifier = String(course.id);\n      const stories = await client.query(\n        api.editorRead.getEditorStoriesByCourseLegacyId,\n        { identifier },\n      );\n\n      return stories.map((story) => ({\n        id: story.id,\n        name: story.name,\n        course_id: story.course_id,\n        image: story.image,\n        course_short: course.short,\n        public: story.public,\n      }));\n    },\n  );\n\n  return storiesByCourse.flat();\n}\n\nasync function main() {\n  console.log(\"Loading course/story lists...\");\n  const allStories = await getAllStories();\n  const stories = PUBLISHED_ONLY\n    ? allStories.filter((story) => story.public)\n    : allStories;\n\n  console.log(\n    `Found ${allStories.length} stories total; scanning ${stories.length} ${PUBLISHED_ONLY ? \"published\" : \"all\"} stories for image fields...`,\n  );\n\n  const findings = (\n    await mapWithConcurrency(stories, CONCURRENCY, async (story, index) => {\n      if (index > 0 && index % 200 === 0) {\n        console.log(`Checked ${index}/${stories.length} stories...`);\n      }\n\n      const reasons: string[] = [];\n      const detail = await client.query(api.storyRead.getStoryByLegacyId, {\n        storyId: story.id,\n      });\n\n      if (!detail) {\n        reasons.push(\"missing_story_payload\");\n      } else {\n        if (!detail.illustrations.active.trim()) {\n          reasons.push(\"missing_illustration_active\");\n        }\n        if (!detail.illustrations.gilded.trim()) {\n          reasons.push(\"missing_illustration_gilded\");\n        }\n        if (!detail.illustrations.locked.trim()) {\n          reasons.push(\"missing_illustration_locked\");\n        }\n      }\n\n      if (!story.image.trim()) {\n        reasons.push(\"missing_story_image_id\");\n      }\n\n      if (reasons.length === 0) return null;\n\n      return {\n        storyId: story.id,\n        storyName: story.name,\n        courseId: story.course_id,\n        courseShort: story.course_short ?? \"\",\n        reasons,\n      } satisfies Finding;\n    })\n  ).filter((finding): finding is Finding => finding !== null);\n\n  const outputPath = PUBLISHED_ONLY\n    ? \"tmp/missing-story-images-published.json\"\n    : \"tmp/missing-story-images-all.json\";\n\n  await mkdir(\"tmp\", { recursive: true });\n  await writeFile(\n    outputPath,\n    JSON.stringify(\n      {\n        generatedAt: new Date().toISOString(),\n        publishedOnly: PUBLISHED_ONLY,\n        totalStoriesFound: allStories.length,\n        totalStoriesScanned: stories.length,\n        totalIssues: findings.length,\n        findings,\n      },\n      null,\n      2,\n    ) + \"\\n\",\n    \"utf8\",\n  );\n\n  console.log(\"\\n=== Image Audit Summary ===\");\n  console.log(`Total stories scanned: ${stories.length}`);\n  console.log(`Stories with issues: ${findings.length}`);\n\n  if (findings.length === 0) {\n    console.log(\"No stories with missing images were found.\");\n    console.log(`Results saved to ${outputPath}`);\n    return;\n  }\n\n  const reasonCount = new Map<string, number>();\n  for (const finding of findings) {\n    for (const reason of finding.reasons) {\n      reasonCount.set(reason, (reasonCount.get(reason) ?? 0) + 1);\n    }\n  }\n\n  console.log(\"\\nIssue counts by reason:\");\n  for (const [reason, count] of Array.from(reasonCount.entries()).sort((a, b) =>\n    a[0].localeCompare(b[0]),\n  )) {\n    console.log(`- ${reason}: ${count}`);\n  }\n\n  console.log(\"\\nAffected stories:\");\n  for (const finding of findings.sort((a, b) => a.storyId - b.storyId)) {\n    console.log(\n      `- story=${finding.storyId} course=${finding.courseId} (${finding.courseShort}) name=${JSON.stringify(finding.storyName)} reasons=${finding.reasons.join(\",\")}`,\n    );\n  }\n\n  console.log(`\\nResults saved to ${outputPath}`);\n}\n\nmain().catch((error) => {\n  console.error(\"Story image audit failed:\");\n  console.error(error);\n  process.exit(1);\n});\n"
  },
  {
    "path": "skills-lock.json",
    "content": "{\n  \"version\": 1,\n  \"skills\": {\n    \"convex\": {\n      \"source\": \"get-convex/agent-skills\",\n      \"sourceType\": \"github\",\n      \"skillPath\": \"skills/convex/SKILL.md\",\n      \"computedHash\": \"70ecfb9cd4439ccbf6570b6dc23eab53f7ce7dcf70ef63bbfdf8f4f21353dfb4\"\n    },\n    \"convex-create-component\": {\n      \"source\": \"get-convex/agent-skills\",\n      \"sourceType\": \"github\",\n      \"skillPath\": \"skills/convex-create-component/SKILL.md\",\n      \"computedHash\": \"e4ad9cbe6d2bb0d5171dfd04019bc4ff228f26fb52312429376c885d2ec4935a\"\n    },\n    \"convex-migration-helper\": {\n      \"source\": \"get-convex/agent-skills\",\n      \"sourceType\": \"github\",\n      \"skillPath\": \"skills/convex-migration-helper/SKILL.md\",\n      \"computedHash\": \"c6416032d2f2e947ebe9d6b2389d89592d0229a0e6c4202f9a1197f2bd76019f\"\n    },\n    \"convex-performance-audit\": {\n      \"source\": \"get-convex/agent-skills\",\n      \"sourceType\": \"github\",\n      \"skillPath\": \"skills/convex-performance-audit/SKILL.md\",\n      \"computedHash\": \"c048b44beca5616108bfebc9822b6238cbff5c99facb88b3cf3d3a2af0dac502\"\n    },\n    \"convex-quickstart\": {\n      \"source\": \"get-convex/agent-skills\",\n      \"sourceType\": \"github\",\n      \"skillPath\": \"skills/convex-quickstart/SKILL.md\",\n      \"computedHash\": \"c95728c430a441325c865b06f0f0e912923c34deecbf6f24e9f03e13046b469c\"\n    },\n    \"convex-setup-auth\": {\n      \"source\": \"get-convex/agent-skills\",\n      \"sourceType\": \"github\",\n      \"skillPath\": \"skills/convex-setup-auth/SKILL.md\",\n      \"computedHash\": \"f60559165edd5b616fda726ed5726c798e33f905361ed9892ab6013e53ab2588\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/EditorCommandPaletteClient.tsx",
    "content": "\"use client\";\n\nimport EditorCommandPalette from \"@/app/editor/_components/editor_command_palette\";\nimport { authClient } from \"@/lib/auth-client\";\n\ntype SessionUser = {\n  role?: string | null;\n};\n\nexport default function EditorCommandPaletteClient() {\n  const { data: session } = authClient.useSession();\n  const sessionUser = (session?.user ?? null) as SessionUser | null;\n  const role = sessionUser?.role ?? null;\n  const showCommandPalette = role === \"contributor\" || role === \"admin\";\n\n  if (!showCommandPalette) return null;\n\n  return <EditorCommandPalette canAdmin={role === \"admin\"} />;\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/[course_id]/course_page_client.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { api } from \"@convex/_generated/api\";\nimport { Preloaded, usePreloadedQuery, useQuery } from \"convex/react\";\nimport Header from \"../header\";\nimport StoryButton from \"./story_button\";\nimport get_localisation_func from \"@/lib/get_localisation_func\";\nimport ContributorList from \"@/components/ContributorList\";\nimport Switch from \"@/components/ui/switch\";\n\nfunction SetTitle({ children }: { children: React.ReactNode }) {\n  return (\n    <div className=\"col-[1/-1] w-full overflow-x-hidden text-center text-[calc(27/16*1rem)] font-bold flex before:flex-1 after:flex-1 items-center before:relative before:right-4 before:-ml-1/2 before:inline-block before:h-[2px] before:w-1/2 before:align-middle before:bg-[var(--overview-hr)] before:content-[''] after:relative after:left-4 after:-mr-1/2 after:inline-block after:h-[2px] after:w-1/2 after:align-middle after:bg-[var(--overview-hr)] after:content-[''] max-[480px]:text-[calc(22/16*1rem)]\">\n      {children}\n    </div>\n  );\n}\n\nfunction SetGrid({\n  setId,\n  setName,\n  children,\n}: {\n  setId: number;\n  setName: React.ReactNode;\n  children: React.ReactNode;\n}) {\n  return (\n    <ol\n      id={`${setId}`}\n      style={{ scrollMarginTop: \"3.5rem\" }}\n      className=\"m-0 mx-auto grid max-w-[720px] list-none grid-cols-[repeat(auto-fill,clamp(140px,50%,180px))] justify-center justify-items-center p-0\"\n    >\n      <SetTitle>{setName}</SetTitle>\n      {children}\n    </ol>\n  );\n}\n\nfunction About({ about }: { about: string }) {\n  if (!about) return <></>;\n  return (\n    <div className=\"mx-auto max-w-[720px]\">\n      <SetTitle>About</SetTitle>\n      <p>{about}</p>\n    </div>\n  );\n}\n\nfunction Contributors({\n  contributors,\n  contributorsPast,\n}: {\n  contributors: Array<{\n    legacyUserId: number;\n    name: string;\n    image: string | null;\n    discordLinked: boolean;\n  }>;\n  contributorsPast: Array<{\n    legacyUserId: number;\n    name: string;\n    image: string | null;\n    discordLinked: boolean;\n  }>;\n}) {\n  const allContributors = [...contributors, ...contributorsPast].filter(\n    (contributor, index, list) =>\n      index ===\n      list.findIndex(\n        (candidate) =>\n          candidate.legacyUserId === contributor.legacyUserId &&\n          candidate.name === contributor.name,\n      ),\n  );\n\n  if (allContributors.length === 0) return null;\n\n  return (\n    <div className=\"mx-auto max-w-[720px]\">\n      <SetTitle>Contributors</SetTitle>\n      <div className=\"mt-4\">\n        <ContributorList contributors={allContributors} size=\"md\" />\n      </div>\n    </div>\n  );\n}\n\nfunction NoNativeWarning() {\n  return (\n    <div className=\"mx-auto mt-4 mb-4 max-w-[720px] rounded-xl border border-yellow-700/50 bg-yellow-50 px-4 py-3 text-yellow-900\">\n      <p className=\"font-bold\">Course quality note</p>\n      <p className=\"mt-1\">\n        This course does not currently have a native speaker translator or\n        proofreader, so some stories may not be 100% correct. If you are a\n        native speaker and want to help improve this course, please join our{\" \"}\n        <a\n          className=\"underline underline-offset-2\"\n          href=\"https://discord.gg/4NGVScARR3\"\n          target=\"_blank\"\n          rel=\"noreferrer\"\n        >\n          Discord server\n        </a>\n        .\n      </p>\n    </div>\n  );\n}\n\nexport default function CoursePageClient({\n  course_id,\n  preloadedCourse,\n}: {\n  course_id: string;\n  preloadedCourse: Preloaded<typeof api.landing.getPublicCoursePageData>;\n}) {\n  const listeningStorageKey = React.useMemo(\n    () => `course_listening_mode:${course_id}`,\n    [course_id],\n  );\n  const [listeningMode, setListeningMode] = React.useState(false);\n\n  React.useEffect(() => {\n    if (typeof window === \"undefined\") return;\n    setListeningMode(window.localStorage.getItem(listeningStorageKey) === \"1\");\n  }, [listeningStorageKey]);\n\n  const toggleListeningMode = React.useCallback(() => {\n    setListeningMode((prev) => {\n      const next = !prev;\n      if (typeof window !== \"undefined\") {\n        window.localStorage.setItem(listeningStorageKey, next ? \"1\" : \"0\");\n      }\n      return next;\n    });\n  }, [listeningStorageKey]);\n\n  const course = usePreloadedQuery(preloadedCourse);\n  const localizationMap = React.useMemo(() => {\n    const data: Record<string, string> = {};\n    for (const row of course?.localization ?? []) data[row.tag] = row.text;\n    return data;\n  }, [course]);\n  const localization = React.useMemo(\n    () => get_localisation_func(localizationMap),\n    [localizationMap],\n  );\n\n  const doneStoryIds = useQuery(\n    api.storyDone.getDoneStoryIdsForCurrentUserInCourse,\n    { courseShort: course_id },\n  );\n  const doneMap = React.useMemo(() => {\n    const done: Record<number, boolean> = {};\n    for (const storyId of doneStoryIds ?? []) done[storyId] = true;\n    return done;\n  }, [doneStoryIds]);\n\n  const storiesBySet = React.useMemo(() => {\n    if (!course) return [];\n    const grouped: Record<number, typeof course.stories> = {};\n    for (const story of course.stories) {\n      if (!grouped[story.set_id]) grouped[story.set_id] = [];\n      grouped[story.set_id].push(story);\n    }\n    return Object.entries(grouped)\n      .map(([setId, stories]) => ({\n        setId: Number.parseInt(setId, 10),\n        stories: stories.sort((a, b) => a.set_index - b.set_index),\n      }))\n      .sort((a, b) => a.setId - b.setId);\n  }, [course]);\n  if (!course) {\n    return (\n      <Header>\n        <h1>Course not found.</h1>\n      </Header>\n    );\n  }\n  const rawTags = course.tags ?? [];\n  const normalizedTags = rawTags.map((tag) => tag.trim().toLowerCase());\n  const showNoNativeWarning = normalizedTags.includes(\"no-native\");\n\n  return (\n    <>\n      <Header>\n        <h1>\n          {localization(\"course_page_title\", {\n            $language: course.learning_language_name,\n          }) ?? `${course.learning_language_name} Duolingo Stories`}\n        </h1>\n        <p>\n          {localization(\"course_page_sub_title\", {\n            $language: course.learning_language_name,\n            $count: `${course.count}`,\n          }) ??\n            `Learn ${course.learning_language_name} with ${course.count} stories.`}\n        </p>\n        <p className=\"[&_a]:underline [&_a]:underline-offset-2\">\n          {localization(\"course_page_discuss\", {}, [\n            \"https://discord.gg/4NGVScARR3\",\n            \"/faq\",\n          ])}\n        </p>\n      </Header>\n      <div>\n        {showNoNativeWarning ? <NoNativeWarning /> : null}\n        <div\n          className=\"mx-auto mb-6 flex w-full max-w-[720px] cursor-pointer items-center justify-between gap-3 rounded-xl border border-[var(--overview-hr)] px-4 py-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--overview-hr)]\"\n          role=\"button\"\n          tabIndex={0}\n          aria-pressed={listeningMode}\n          onClick={toggleListeningMode}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\" || e.key === \" \") {\n              e.preventDefault();\n              toggleListeningMode();\n            }\n          }}\n        >\n          <div>\n            <div className=\"font-bold\">Listening mode (skip questions)</div>\n            <div className=\"text-[calc(13/16*1rem)] text-[var(--text-color-dim)]\">\n              Opens stories in autoplay and skips interactive questions.\n            </div>\n          </div>\n          <div\n            onClick={(e) => {\n              e.stopPropagation();\n            }}\n          >\n            <Switch checked={listeningMode} onClick={toggleListeningMode} />\n          </div>\n        </div>\n        {course.about ? <About about={course.about} /> : null}\n        <Contributors\n          contributors={course.contributors}\n          contributorsPast={course.contributors_past}\n        />\n        {storiesBySet.map((set) => (\n          <SetGrid\n            key={set.setId}\n            setId={set.setId}\n            setName={\n              localization(\"set_n\", { $count: `${set.setId}` }) ??\n              `Set ${set.setId}`\n            }\n          >\n            {set.stories.map((story) => (\n              <li key={story.id}>\n                <StoryButton\n                  story={story}\n                  done={doneMap[story.id]}\n                  listeningMode={listeningMode}\n                />\n              </li>\n            ))}\n          </SetGrid>\n        ))}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/[course_id]/not-found.tsx",
    "content": "import Header from \"../header\";\nimport Link from \"next/link\";\n\nexport default function NotFound() {\n  return (\n    <Header>\n      <h1>Course Not Found</h1>\n      <p>This course does not exist or is not published yet.</p>\n      <p>\n        Go back to the <Link href={\"/\"}>main page</Link>.\n      </p>\n    </Header>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/[course_id]/page.tsx",
    "content": "import React from \"react\";\nimport { notFound } from \"next/navigation\";\n\nimport CoursePageClient from \"./course_page_client\";\nimport { get_localisation_by_convex_language_id } from \"@/lib/get_localisation\";\nimport { get_course_data, get_course } from \"../get_course_data\";\nimport { ResolvingMetadata } from \"next\";\nimport { preloadQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\n\nexport async function generateMetadata(\n  { params }: { params: Promise<{ course_id: string }> },\n  parent: ResolvingMetadata,\n) {\n  const params0 = await params;\n  if (\n    params0.course_id.indexOf(\"-\") === -1 ||\n    params0.course_id.indexOf(\".\") !== -1\n  ) {\n    return notFound();\n  }\n  const course = await get_course(params0.course_id);\n  if (!course) notFound();\n  const localization = await get_localisation_by_convex_language_id(\n    course.fromLanguageId,\n  );\n\n  const meta = await parent;\n\n  return {\n    title:\n      localization(\"meta_course_title\", {\n        $language: course.learning_language_name,\n      }) || `${course.learning_language_name} Duolingo Stories`,\n    description:\n      localization(\"meta_course_description\", {\n        $language: course.learning_language_name,\n      }) ||\n      `Improve your ${course.learning_language_name} learning by community-translated Duolingo stories.`,\n    alternates: {\n      canonical: `https://duostories.org/${params0.course_id}`,\n    },\n    openGraph: {\n      images: [\n        `/api/og-course?lang=${params0.course_id.split(\"-\")[0]}&count=${\n          course.count\n        }&name=${course.learning_language_name}`,\n      ],\n      url: `https://duostories.org/${params0.course_id}`,\n      type: \"website\",\n    },\n    keywords: [course.learning_language_name, ...(meta.keywords || [])],\n  };\n}\n\nexport async function generateStaticParams() {\n  try {\n    const courses = await get_course_data();\n    return courses.map((course) => ({\n      course_id: course.short,\n    }));\n  } catch (error) {\n    console.error(\"generateStaticParams failed for /[course_id]:\", error);\n    return [];\n  }\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ course_id: string }>;\n}) {\n  const course_id = (await params).course_id;\n  if (course_id.indexOf(\"-\") === -1 || course_id.indexOf(\".\") !== -1) {\n    return notFound();\n  }\n\n  const preloadedCourse = await preloadQuery(\n    api.landing.getPublicCoursePageData,\n    {\n      short: course_id,\n    },\n  );\n\n  return (\n    <CoursePageClient course_id={course_id} preloadedCourse={preloadedCourse} />\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/[course_id]/story_button.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport Image from \"next/image\";\n\ninterface StoryData {\n  id: number;\n  name: string;\n  active: string;\n  gilded: string;\n  active_lip: string;\n}\n\nexport default function StoryButton({\n  story,\n  done,\n  listeningMode = false,\n}: {\n  story?: StoryData;\n  done?: boolean;\n  listeningMode?: boolean;\n}) {\n  if (!story) {\n    return (\n      <div className=\"my-[7px] mr-[17px] mb-[10px] ml-[17px] inline-block w-[134px] rounded-[5px] outline-offset-[5px] max-[335px]:m-0 max-[268px]:mx-auto\">\n        <div className=\"mt-[15px] ml-[10px] inline-block h-[113px] w-[113px] animate-pulse rounded-[17px] bg-slate-200\" />\n        <div className=\"mt-4 mb-[7px] h-[21px] w-[134px] animate-pulse rounded bg-slate-200\" />\n      </div>\n    );\n  }\n\n  return (\n    <Link\n      data-cy={\"story_button_\" + story.id}\n      className=\"group my-[7px] mr-[17px] mb-[10px] ml-[17px] inline-block w-[134px] cursor-pointer rounded-[5px] text-center no-underline outline-offset-[5px] max-[335px]:m-0 max-[268px]:mx-auto\"\n      href={\n        listeningMode ? `/story/${story.id}/auto_play` : `/story/${story.id}`\n      }\n      onClick={() => {\n        if (listeningMode) return;\n        if (typeof window !== \"undefined\") {\n          window.sessionStorage.setItem(\n            \"story_autoplay_ts\",\n            String(Date.now()),\n          );\n        }\n      }}\n    >\n      <div\n        className=\"relative mt-[15px] ml-[10px] inline-block h-[113px] w-[113px] overflow-visible rounded-[17px] bg-[var(--story-button-gold)]\"\n        data-done={done}\n        style={done ? {} : { background: \"#\" + story.active_lip }}\n      >\n        {listeningMode ? (\n          <span\n            className=\"pointer-events-none absolute top-[5px] right-[5px] z-[2] inline-flex h-[22px] w-[22px] items-center justify-center rounded-full bg-[rgba(255,255,255,0.92)] shadow-[0_1px_2px_rgba(0,0,0,0.2)]\"\n            role=\"img\"\n            aria-label=\"Listening mode\"\n          >\n            <img\n              src=\"https://d35aaqx5ub95lt.cloudfront.net/images/d636e9502812dfbb94a84e9dfa4e642d.svg\"\n              alt=\"\"\n              aria-hidden=\"true\"\n              className=\"h-[13px] w-4\"\n            />\n          </span>\n        ) : null}\n        <Image\n          src={done ? story.gilded : story.active}\n          alt=\"\"\n          width={135}\n          height={124}\n          className=\"absolute top-[-11px] left-[-11px] h-[124px] w-[135px] max-w-none -translate-y-[5px] transition-transform duration-300 ease-out group-hover:-translate-y-[7px] group-active:translate-y-0 motion-reduce:transition-none\"\n        />\n      </div>\n      <div className=\"mt-4 mb-[7px] w-full text-center text-[calc(17/16*1rem)] leading-[21px] font-bold text-[var(--text-color-dim)]\">\n        {story.name}\n      </div>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/course-dropdown.tsx",
    "content": "\"use client\";\nimport Link from \"next/link\";\nimport LanguageFlag from \"@/components/ui/language-flag\";\nimport { useSelectedLayoutSegment } from \"next/navigation\";\nimport { CourseData } from \"@/app/(stories)/(main)/get_course_data\";\nimport { api } from \"@convex/_generated/api\";\nimport { useQuery } from \"convex/react\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/shadcn\";\n\nfunction LanguageButtonSmall({ course }: { course?: CourseData }) {\n  /**\n   * A button in the language drop down menu (flag + name)\n   */\n  if (!course) return null;\n\n  return (\n    <DropdownMenuItem asChild>\n      <Link\n        className=\"flex h-[42px] items-center overflow-hidden whitespace-nowrap px-[10px] py-[5px] no-underline\"\n        href={`/${course.short}`}\n        data-cy=\"button_lang_dropdown\"\n      >\n        <LanguageFlag languageId={course.learningLanguageId} width={40} />\n        <span className=\"min-w-0 pl-[10px] overflow-hidden text-ellipsis text-[18px] font-bold whitespace-nowrap\">\n          {course.name}\n        </span>\n      </Link>\n    </DropdownMenuItem>\n  );\n}\n\nexport default function CourseDropdown() {\n  const course_data = useQuery(api.landing.getPublicCourseList, {});\n  const course_data_active = useQuery(\n    api.storyDone.getDoneCourseIdsForUser,\n    {},\n  );\n\n  function get_course_by_id(id: number) {\n    if (!course_data) return undefined;\n    for (let course of course_data) {\n      if (course.id === id) return course;\n    }\n  }\n  function get_course_by_short(short: string) {\n    if (!course_data) return undefined;\n    for (let course of course_data) {\n      if (course.short === short) return course;\n    }\n  }\n\n  const segment = useSelectedLayoutSegment();\n  let course = get_course_by_short(segment || \"\");\n\n  if (!course_data_active || course_data_active.length === 0) return null;\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <button\n          type=\"button\"\n          className=\"mx-4 flex items-center justify-center rounded-[14px] p-0 outline-none transition hover:brightness-95 focus-visible:ring-2 focus-visible:ring-[var(--button-background)] focus-visible:ring-offset-2\"\n          aria-label=\"Open language menu\"\n        >\n          <LanguageFlag languageId={course?.learningLanguageId} width={40} />\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        align=\"center\"\n        sideOffset={10}\n        className=\"w-[240px] overflow-visible\"\n      >\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute -top-[12px] left-1/2 h-[13px] w-[28px] -translate-x-1/2 overflow-hidden\"\n        >\n          <svg\n            viewBox=\"0 0 28 13\"\n            className=\"absolute inset-0 h-[13px] w-[28px]\"\n            aria-hidden=\"true\"\n          >\n            <path\n              d=\"M14 0.5 Q15.5 0.5 16.5 1.7 L28 13 H0 L11.5 1.7 Q12.5 0.5 14 0.5 Z\"\n              fill=\"var(--header-border)\"\n            />\n            <path\n              d=\"M14 1.5 Q15 1.5 15.8 2.4 L26 13 H2 L12.2 2.4 Q13 1.5 14 1.5 Z\"\n              fill=\"var(--body-background)\"\n            />\n          </svg>\n        </div>\n        <nav className=\"max-h-[calc(100vh-55px)] overflow-y-auto\">\n          {course_data_active.map((id) => (\n            <LanguageButtonSmall key={id} course={get_course_by_id(id)} />\n          ))}\n        </nav>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/course_list.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport LanguageButton, {\n  type LandingCourseButtonData,\n} from \"./language_button\";\nimport { api } from \"@convex/_generated/api\";\nimport { type Preloaded, usePreloadedQuery } from \"convex/react\";\nimport type { Id } from \"@convex/_generated/dataModel\";\n\ninterface LandingGroupData {\n  fromLanguageId: Id<\"languages\">;\n  labels: {\n    storiesFor: string;\n    nStoriesTemplate: string;\n  };\n  courses: LandingCourseButtonData[];\n}\n\nfunction RenderCourseGroups({ groups }: { groups: LandingGroupData[] }) {\n  let startIndex = 0;\n  return (\n    <>\n      {groups.map((group) => {\n        const currentStart = startIndex;\n        startIndex += group.courses.length;\n        return (\n          <div key={group.fromLanguageId} className=\"flex flex-col\">\n            <hr className=\"my-0 mt-[30px] mb-[22px] h-0 w-full border-0 border-t-2 border-[var(--overview-hr)]\" />\n            <div className=\"mb-[14px] w-full pl-[5px] text-[calc(24/16*1rem)] font-bold\">\n              {group.labels.storiesFor}\n            </div>\n            <ol className=\"grid w-full list-none grid-cols-[repeat(auto-fill,minmax(min(190px,calc(50%-12px)),1fr))] gap-3 p-0\">\n              {group.courses.map((course, index) => (\n                <li key={course.id}>\n                  <LanguageButton\n                    course={course}\n                    storiesTemplate={group.labels.nStoriesTemplate}\n                    eagerFlagImage={currentStart + index < 8}\n                  />\n                </li>\n              ))}\n            </ol>\n          </div>\n        );\n      })}\n    </>\n  );\n}\n\nexport default function CourseList({\n  preloadedLandingData,\n}: {\n  preloadedLandingData: Preloaded<typeof api.landing.getPublicLandingPageData>;\n}) {\n  const landingData = usePreloadedQuery(preloadedLandingData);\n  return <RenderCourseGroups groups={landingData.groups} />;\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/faq/page.tsx",
    "content": "import React from \"react\";\nimport Link from \"next/link\";\n\nexport const metadata = {\n  title: \"Duostories FAQ\",\n  description: \"Information about the duostories project.\",\n  alternates: {\n    canonical: \"https://duostories.org/faq\",\n  },\n};\n\nexport default async function Page() {\n  return (\n    <div className=\"mx-auto w-full max-w-[900px] px-4 py-6 text-[calc(19/16*1rem)] leading-[1.6] [&_a]:underline [&_a]:underline-offset-2 [&_h2]:mt-8 [&_h2]:mb-2 [&_h2]:text-[calc(28/16*1rem)] [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:ml-[30px] [&_h3]:text-[calc(22/16*1rem)] [&_h3]:font-bold [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:ml-[30px] [&_ul]:list-disc [&_ul]:pl-6\">\n      <h1 className=\"mb-6 text-[calc(36/16*1rem)] font-bold\">\n        Frequently Asked Questions\n      </h1>\n      <h2>Can I support this project financially?</h2>\n      <p>\n        Yes, we have a page on{\" \"}\n        <Link href={\"https://opencollective.com/duostories\"}>\n          OpenCollective\n        </Link>\n        . We use the money to cover the hosting costs and for the TTS services.\n        {\" You can \"}\n        <Link href=\"https://opencollective.com/duostories/contribute\">\n          contribute\n        </Link>\n        .\n      </p>\n      <h2>Is this website open source?</h2>\n      <p>\n        Yes it is! The code is hosted on Github{\" \"}\n        <Link href=\"https://github.com/rgerum/unofficial-duolingo-stories\">\n          rgerum/unofficial-duolingo-stories\n        </Link>\n      </p>\n      <p>\n        If you like it you can give it a star.{\" \"}\n        <Link href=\"https://github.com/rgerum/unofficial-duolingo-stories\">\n          <img\n            alt=\"GitHub Repo stars\"\n            src=\"https://img.shields.io/github/stars/rgerum/unofficial-duolingo-stories?style=social\"\n          />\n        </Link>\n      </p>\n\n      <h2>When will these stories be on the official Duolingo website?</h2>\n      <p>\n        Probably never. This project is not linked to Duolingo in any way.\n        Duolingo has in the past worked with volunteers but they stopped the\n        volunteer program. Therefore, it is highly unlikely that Duolingo will\n        adopt these stories.\n      </p>\n\n      <h2>Are you allowed to use the material of Duolingo?</h2>\n      <p>\n        Yes, we asked Duolingo for permission and came to an agreement that we\n        are allowed to use the story material for this purpose. If you want to\n        use Duolingo material, please ask them. Our licence agreement only\n        covers this website.\n      </p>\n\n      <h2>Can I contribute?</h2>\n      <p>\n        Yes! The project is run by volunteers that want to bring the Duolingo\n        stories to new languages. You can join us on{\" \"}\n        <Link href=\"https://discord.gg/4NGVScARR3\">Discord</Link>.\n      </p>\n\n      <h2>Will you add a course in language X?</h2>\n      <p>\n        If we have a volunteer, or better yet, a group of volunteers, then yes.\n        Maybe you can spread the word, find some native speakers in your target\n        language, and bring them to our{\" \"}\n        <Link href=\"https://discord.gg/4NGVScARR3\">Discord</Link> server.\n      </p>\n\n      <h3>What about a dialect or regionally-specific language?</h3>\n      <p className=\"ml-[30px]\">\n        We are hesitant to support languages that are too regionally-specific\n        because at times they are not well-defined enough that a course would\n        even make sense to learners. Applications for dialects/regional\n        languages will be considered against a set of factors on a case-by-case\n        basis. It might be a “yes” if your language:\n      </p>\n      <ul>\n        <li>Is classified as an “endangered” language</li>\n        <li>Has a well-defined written form and spelling</li>\n        <li>Has an ISO code</li>\n        <li>Has a language foundation or association to support it</li>\n        <li>Has a broad body of published literature</li>\n      </ul>\n\n      <h3>\n        What about a constructed language (conlang) or auxiliary language\n        (auxlang)?\n      </h3>\n      <p className=\"ml-[30px]\">\n        While the primary focus of this project is to feature natural languages,\n        we acknowledge that some conlangs/auxlangs are used to a similar extent\n        as some minor natural languages. Esperanto is a well-known example with\n        thousands of speakers worldwide. Especially because it is also taught on\n        Duolingo, it makes sense to include it here.{\" \"}\n      </p>\n\n      <p className=\"ml-[30px]\">\n        In order to maximize the benefits to learners, we will be more\n        interested in featuring a conlang/auxlang when we see some of these\n        factors, so please be sure to discuss them in your application.\n      </p>\n      <ul>\n        <li>\n          There are a significant number of speakers/learners of the language\n          (e.g. &gt; 100)\n        </li>\n        <li>\n          The language has been in development for a significant period of time\n          (e.g. 10+ years)\n        </li>\n        <li>\n          There are other websites or texts that provide material on the\n          language (e.g. an official dictionary)\n        </li>\n        <li>\n          The language has received some degree of notoriety in the\n          conlang/auxlang community (e.g. received awards, featured in\n          articles/videos)\n        </li>\n        <li>\n          The language has its own Wikipedia edition (e.g.{\" \"}\n          <Link href=\"https://eo.wikipedia.org/\">Esperanto Wikipedia</Link>,{\" \"}\n          <Link href=\"https://en.wikipedia.org/wiki/List_of_Wikipedias\">\n            List of Wikipedias\n          </Link>\n          ) and/or an ISO code (e.g. Esperanto, “epo”)\n        </li>\n      </ul>\n      <p className=\"ml-[30px]\">\n        If your language is a personal conlang/auxlang project or a very new\n        project from a small group, we are hesitant to support the language as\n        we do not see clear value for our community of learners. When we do\n        allow these smaller languages access to the project, we will not feature\n        them on the main page of Duostories.org. The course contributor would be\n        given a direct link to share with interested learners.\n      </p>\n\n      <h2>Can I write my own stories as a contributor?</h2>\n      <p>\n        Our current goal is to create good translations of the existing Duolingo\n        stories. Duolingo has put great effort into developing stories that help\n        learners to learn a new language using stories. We do not have the\n        resources to create similar high quality stories, nor do we see the need\n        to go beyond the current stories. Maybe when we have finished\n        translating all of them ;-).\n      </p>\n\n      <h2>I found a mistake!</h2>\n      <p>\n        Yes, despite our continuous efforts, there might be mistakes in the\n        translations. You can reach us on{\" \"}\n        <Link href=\"https://discord.gg/4NGVScARR3\">Discord</Link> to report\n        mistakes.\n      </p>\n\n      <h2>I found a bug on the page or want to suggest a new feature.</h2>\n      <p>\n        We have a{\" \"}\n        <Link\n          href={\"https://github.com/rgerum/unofficial-duolingo-stories/issues\"}\n        >\n          bugtracker\n        </Link>{\" \"}\n        on Github where you can report issues or feature requests. Or again\n        discuss them with us on{\" \"}\n        <Link href=\"https://discord.gg/4NGVScARR3\">Discord</Link>.\n      </p>\n\n      <h2>Who runs this website?</h2>\n      <p>\n        {`The website was developed by me, \"randrian\". You can find me on `}\n        <Link href=\"https://www.duolingo.com/profile/Randriano\">Duolingo</Link>{\" \"}\n        or on <Link href=\"https://github.com/rgerum\">Github</Link>. Some people\n        did minor contributions to the website, see the Github repository. You\n        are welcome to be part of them.\n      </p>\n      <p>I am in no way associated with Duolingo.</p>\n      <p>\n        But of course this website would be nothing without its active group of\n        contributors! Meet them on{\" \"}\n        <Link href=\"https://discord.gg/4NGVScARR3\">Discord</Link>.\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/footer_links.tsx",
    "content": "import Link from \"next/link\";\nimport React from \"react\";\n\nexport default async function FooterLinks({}) {\n  return (\n    <>\n      <footer className=\"mt-auto\">\n        <div className=\"mx-auto mt-[50px] flex w-full max-w-[1000px] justify-end px-4\">\n          <Link\n            href=\"https://opencollective.com/duostories/contribute\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"inline-block rounded-[999px] border-[var(--button-blue-border)] border-b-4 border-l-2 border-r-2 border-t-2 bg-[var(--button-blue-background)] px-6 py-2 text-[calc(14/16*1rem)] font-bold uppercase tracking-[0.12em] text-[var(--button-blue-color)] no-underline transition-[filter] duration-100 hover:brightness-110\"\n          >\n            Contribute to our collective\n          </Link>\n        </div>\n        <div className=\"mt-5 mb-3 border-t-2 border-[var(--overview-hr)]\" />\n        <div className=\"mx-auto mb-5 grid w-full max-w-[1000px] grid-cols-[repeat(auto-fit,minmax(140px,1fr))] justify-around px-4 text-[calc(16/16*1rem)] opacity-60 [&_figcaption]:mb-0 [&_figcaption]:font-bold [&_li]:list-none [&_li]:before:content-['•_'] [&_ul]:m-0 [&_ul]:pl-0 [@media(pointer:coarse)]:[&_li_a]:leading-[calc(48/16*1rem)]\">\n          <figure>\n            <figcaption>Social</figcaption>\n            <nav>\n              <ul>\n                <li>\n                  <Link href=\"https://discord.gg/4NGVScARR3\">Discord</Link>\n                </li>\n                <li>\n                  <Link href=\"https://twitter.com/DuostoriesNews\">Twitter</Link>\n                </li>\n                <li>\n                  <Link href=\"https://www.instagram.com/duostoriesproject/\">\n                    Instagram\n                  </Link>\n                </li>\n              </ul>\n            </nav>\n          </figure>\n          <figure>\n            <figcaption>Contribute</figcaption>\n            <nav>\n              <ul>\n                <li>\n                  <Link href=\"https://opencollective.com/duostories/contribute\">\n                    Contribute\n                  </Link>\n                </li>\n                <li>\n                  <Link href=\"/docs\">Docs</Link>\n                </li>\n              </ul>\n            </nav>\n          </figure>\n          <figure>\n            <figcaption>Legal</figcaption>\n            <nav>\n              <ul>\n                <li>\n                  <Link href=\"/privacy_policy\">Privacy Policy</Link>\n                </li>\n              </ul>\n            </nav>\n          </figure>\n          <figure>\n            <figcaption>Open Source</figcaption>\n            <nav>\n              <ul>\n                <li>\n                  <Link href=\"https://github.com/rgerum/unofficial-duolingo-stories\">\n                    Github Code\n                  </Link>\n                </li>\n                <li>\n                  <Link href=\"https://github.com/rgerum/unofficial-duolingo-stories-content\">\n                    Github Content\n                  </Link>\n                </li>\n              </ul>\n            </nav>\n          </figure>\n          <figure>\n            <figcaption>Apps</figcaption>\n            <nav>\n              <ul>\n                <li>\n                  <Link href=\"https://play.google.com/store/apps/details?id=org.duostories.twa\">\n                    Google Play\n                  </Link>\n                </li>\n              </ul>\n            </nav>\n          </figure>\n        </div>\n      </footer>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/get_course_data.ts",
    "content": "import { api } from \"@convex/_generated/api\";\nimport type { Id } from \"@convex/_generated/dataModel\";\nimport { fetchQuery } from \"convex/nextjs\";\n\nexport interface CourseData {\n  id: number;\n  short: string;\n  name: string;\n  count: number;\n  about: string;\n  tags: string[];\n  from_language: number;\n  fromLanguageId: Id<\"languages\">;\n  from_language_name: string;\n  learning_language: number;\n  learningLanguageId: Id<\"languages\">;\n  learning_language_name: string;\n}\n\nexport async function get_course_data() {\n  return await fetchQuery(api.landing.getPublicCourseList, {});\n}\n\nexport async function get_course(short: string) {\n  for (let course of await get_course_data()) {\n    if (course.short === short) {\n      return course;\n    }\n  }\n  return null;\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/header.tsx",
    "content": "export default function Header({ children }: { children: React.ReactNode }) {\n  return (\n    <header\n      className={\n        \"py-[22px] text-center \" +\n        \"[&>h1]:m-0 [&>h1]:mb-[18px] [&>h1]:text-center [&>h1]:text-[calc(36/16*1rem)] [&>h1]:font-bold [&>h1]:leading-[1.2] \" +\n        \"[&>p]:mx-auto [&>p]:mb-4 [&>p]:max-w-[700px] [&>p]:text-center [&>p]:text-[calc(19/16*1rem)] [&>p]:leading-[1.5] \" +\n        \"[&>p:last-child]:mb-0 \" +\n        \"max-[480px]:[&>h1]:mb-[10px] max-[480px]:[&>h1]:text-[calc(25/16*1rem)] \" +\n        \"max-[480px]:[&>p]:text-[calc(19/16*1rem)] max-[480px]:[&>p]:text-[var(--title-color-dim)] max-[480px]:[&_a]:text-[var(--title-color-dim)]\"\n      }\n    >\n      {children}\n    </header>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/icons.tsx",
    "content": "import Link from \"next/link\";\nimport {\n  IconDiscord,\n  IconGithub,\n  IconInstagram,\n  IconOpenCollective,\n  IconTwitter,\n} from \"@/components/icons\";\nimport { IconPlayStore } from \"@/components/icons\";\n\nexport default function Icons() {\n  return (\n    <p className=\"m-0 mt-[-8px] flex justify-center gap-0 leading-[0.5] [&>a]:p-2\">\n      <Link href=\"https://discord.gg/4NGVScARR3\">\n        <IconDiscord />\n      </Link>\n      <Link href=\"https://github.com/rgerum/unofficial-duolingo-stories\">\n        <IconGithub />\n      </Link>\n      <Link href=\"https://opencollective.com/duostories\">\n        <IconOpenCollective />\n      </Link>\n      <Link href=\"https://www.instagram.com/duostoriesproject/\">\n        <IconInstagram />\n      </Link>\n      <Link href=\"https://twitter.com/DuostoriesNews\">\n        <IconTwitter />\n      </Link>\n      <Link href=\"https://play.google.com/store/apps/details?id=org.duostories.twa\">\n        <IconPlayStore />\n      </Link>\n    </p>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/landing_stats_client.tsx",
    "content": "\"use client\";\n\nimport { api } from \"@convex/_generated/api\";\nimport { Preloaded, usePreloadedQuery, useQuery } from \"convex/react\";\n\nfunction LandingStatsText({\n  stats,\n}: {\n  stats:\n    | {\n        courseCount: number;\n        storyCount: number;\n      }\n    | undefined;\n}) {\n  if (!stats) {\n    return <>... stories in ... courses and counting!</>;\n  }\n\n  return (\n    <>\n      {stats.storyCount} stories in {stats.courseCount} courses and counting!\n    </>\n  );\n}\n\nfunction LandingStatsClientPreloaded({\n  preloadedLandingData,\n}: {\n  preloadedLandingData: Preloaded<typeof api.landing.getPublicLandingPageData>;\n}) {\n  const landingData = usePreloadedQuery(preloadedLandingData);\n  return <LandingStatsText stats={landingData?.stats} />;\n}\n\nfunction LandingStatsClientQuery() {\n  const landingData = useQuery(api.landing.getPublicLandingPageData, {});\n  return <LandingStatsText stats={landingData?.stats} />;\n}\n\nexport default function LandingStatsClient({\n  preloadedLandingData,\n}: {\n  preloadedLandingData?: Preloaded<typeof api.landing.getPublicLandingPageData>;\n}) {\n  if (preloadedLandingData) {\n    return (\n      <LandingStatsClientPreloaded\n        preloadedLandingData={preloadedLandingData}\n      />\n    );\n  }\n  return <LandingStatsClientQuery />;\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/language_button.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport Flag from \"@/components/ui/flag\";\n\nexport interface LandingCourseButtonData {\n  id: number;\n  short: string;\n  name: string;\n  count: number;\n  learningLanguage: {\n    short: string;\n    flag?: number | string;\n    flag_file?: string;\n  };\n}\n\nexport default function LanguageButton({\n  course,\n  storiesTemplate,\n  loading,\n  eagerFlagImage,\n}: {\n  course?: LandingCourseButtonData;\n  storiesTemplate?: string;\n  loading?: boolean;\n  eagerFlagImage?: boolean;\n}) {\n  if (loading) {\n    return (\n      <div className=\"flex h-[210px] cursor-default flex-col items-center justify-center rounded-2xl border-2 border-[var(--overview-hr)] bg-[var(--body-background)] p-0 text-center\">\n        <div className=\"h-full w-full animate-pulse rounded-2xl bg-slate-200/80\" />\n      </div>\n    );\n  }\n\n  if (!course) return null;\n\n  return (\n    <Link\n      data-cy={\"language_button_big_\" + course.short}\n      className=\"relative flex h-[210px] cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-[var(--overview-hr)] bg-[var(--body-background)] p-0 text-center no-underline transition hover:brightness-90\"\n      href={`/${course.short}`}\n      prefetch={false}\n    >\n      <Flag\n        iso={course.learningLanguage.short}\n        flag={\n          typeof course.learningLanguage.flag === \"number\"\n            ? course.learningLanguage.flag\n            : Number.isFinite(Number(course.learningLanguage.flag))\n              ? Number(course.learningLanguage.flag)\n              : undefined\n        }\n        flag_file={course.learningLanguage.flag_file ?? undefined}\n        loading={eagerFlagImage ? \"eager\" : \"lazy\"}\n      />\n      <span className=\"mt-[10px] block text-[calc(19/16*1rem)] font-bold text-[var(--text-color-dim)]\">\n        {course.name}\n      </span>\n      <span className=\"text-[calc(15/16*1rem)] text-[var(--text-color-dim)] opacity-50\">\n        {storiesTemplate?.replaceAll(\"$count\", `${course.count}`) ??\n          `${course.count} stories`}\n      </span>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/layout.tsx",
    "content": "import Link from \"next/link\";\nimport React from \"react\";\nimport CourseDropdown from \"./course-dropdown\";\nimport EditorCommandPaletteClient from \"./EditorCommandPaletteClient\";\nimport FooterLinks from \"./footer_links\";\nimport Legal from \"@/components/layout/legal\";\nimport Image from \"next/image\";\nimport { LoggedInButtonWrappedClient } from \"@/components/login/LoggedInButtonWrappedClient\";\n\nexport const metadata = {\n  title:\n    \"Duostories: improve your Duolingo learning with community translated Duolingo stories.\",\n  description:\n    \"Supplement your Duolingo course with community-translated Duolingo stories.\",\n  alternates: {\n    canonical: \"https://duostories.org\",\n  },\n  keywords: [\n    \"language\",\n    \"learning\",\n    \"stories\",\n    \"Duolingo\",\n    \"community\",\n    \"volunteers\",\n  ],\n  openGraph: {\n    title: \"Duostories\",\n    description:\n      \"Supplement your Duolingo course with community-translated Duolingo stories.\",\n    type: \"website\",\n    url: `https://duostories.org`,\n  },\n};\n\nexport default function Layout({ children }: { children: React.ReactNode }) {\n  return (\n    <div className=\"relative isolate mx-auto flex min-h-full w-full flex-col\">\n      <div className=\"sticky top-0 z-[1] w-full bg-[var(--body-background)] after:absolute after:left-1/2 after:w-full after:-translate-x-1/2 after:border-b-2 after:border-[var(--header-border)] after:content-['']\">\n        <nav className=\"mx-auto flex max-w-[1000px] items-center px-5 py-1.5\">\n          <Link\n            href=\"/\"\n            className=\"block text-[29px] font-bold text-[var(--duostories-title)] no-underline\"\n            data-cy=\"logo\"\n          >\n            <Image\n              src=\"/Duostories.svg\"\n              alt=\"Duostories\"\n              height={25}\n              width={150}\n            />\n          </Link>\n          <div className=\"ml-auto flex items-center gap-2\">\n            <CourseDropdown />\n            <EditorCommandPaletteClient />\n            <LoggedInButtonWrappedClient\n              page={\"stories\"}\n              course_id={\"segment\"}\n            />\n          </div>\n        </nav>\n      </div>\n      <main className=\"isolate flex flex-col max-w-[1000px] w-full mx-auto px-4\">\n        {children}\n      </main>\n      <FooterLinks />\n      <Legal language_name={undefined} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/not-found.tsx",
    "content": "import Header from \"./header\";\nimport Link from \"next/link\";\n\nexport default function NotFound() {\n  return (\n    <Header>\n      <h1>Page Not Found</h1>\n      <p>This Page does not exist.</p>\n      <p>\n        Go back to the <Link href={\"/\"}>main page</Link>.\n      </p>\n    </Header>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/page.tsx",
    "content": "import Link from \"next/link\";\nimport Header from \"./header\";\nimport CourseList from \"./course_list\";\nimport Icons from \"./icons\";\nimport React from \"react\";\nimport { preloadQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\nimport LandingStatsClient from \"./landing_stats_client\";\n\nexport default async function Page({}) {\n  const preloadedLandingData = await preloadQuery(\n    api.landing.getPublicLandingPageData,\n    {},\n  );\n\n  return (\n    <>\n      <Header>\n        <h1>Unofficial Duolingo Stories</h1>\n        <p className=\"[&_a]:underline [&_a]:underline-offset-2\">\n          A community project to bring the original{\" \"}\n          <Link href=\"https://www.duolingo.com/stories\">Duolingo Stories</Link>{\" \"}\n          to new languages.\n          <br />\n          <LandingStatsClient preloadedLandingData={preloadedLandingData} />\n        </p>\n        <p className=\"[&_a]:underline [&_a]:underline-offset-2\">\n          If you want to contribute or discuss the stories, meet us on{\" \"}\n          <Link href=\"https://discord.gg/4NGVScARR3\">Discord</Link>\n          <br />\n          or learn more about the project in our <Link href=\"/faq\">FAQ</Link>.\n        </p>\n        <Icons />\n      </Header>\n      <CourseList preloadedLandingData={preloadedLandingData} />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/privacy_policy/page.tsx",
    "content": "import React from \"react\";\nimport Link from \"next/link\";\nimport { Metadata } from \"next\";\n\nexport const metadata: Metadata = {\n  title: \"Duostories Privacy Policy\",\n  description: \"Privacy information for the duostories project.\",\n  alternates: {\n    canonical: \"https://duostories.org/privacy_policy\",\n  },\n};\n\nexport default async function Page() {\n  return (\n    <div className=\"mx-auto w-full max-w-[900px] px-4 py-6 text-[calc(19/16*1rem)] leading-[1.6] [&_a]:underline [&_a]:underline-offset-2 [&_h2]:mt-8 [&_h2]:mb-2 [&_h2]:text-[calc(28/16*1rem)] [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-[calc(22/16*1rem)] [&_h3]:font-bold [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:ml-[30px] [&_ul]:list-disc [&_ul]:pl-6\">\n      <h1 className=\"mb-6 text-[calc(36/16*1rem)] font-bold\">\n        Privacy Policy for duostories.org\n      </h1>\n\n      <p>\n        <strong>Effective Date: Nov 16, 2023</strong>\n      </p>\n\n      <h2>Welcome to duostories.org!</h2>\n      <p>\n        {`Your privacy is critically important to us. At duostories.org, we are\n        committed to protecting your personal data and ensuring transparency\n        about how it's used. This Privacy Policy outlines the types of\n        information we collect, how it's used, and the measures we take to\n        protect it. If you have any questions, contact us on `}\n        <Link href=\"https://discord.gg/4NGVScARR3\">Discord</Link>.\n      </p>\n\n      <h3>1. Data Collection:</h3>\n      <p>\n        When you register on duostories.org, we collect the following\n        information:\n      </p>\n      <ul>\n        <li>\n          <strong>Username:</strong> To create a unique identity on our\n          platform.\n        </li>\n        <li>\n          <strong>Email Address:</strong> For account verification,\n          communication, and password recovery.\n        </li>\n        <li>\n          <strong>Hashed Password:</strong> To securely manage access to your\n          account.\n        </li>\n      </ul>\n      <p>\n        For both registered and non-registered users, we track story completion\n        to analyze readership trends and improve our services. This data is\n        collected anonymously for users who are not logged in.\n      </p>\n\n      <h3>2. Use of Data:</h3>\n      <p>The data we collect is used for the following purposes:</p>\n      <ul>\n        <li>To analyze the popularity and readership of our stories.</li>\n        <li>\n          To provide registered users with a personalized track of stories they\n          have read.\n        </li>\n      </ul>\n\n      <h3>3. Consent and User Choice:</h3>\n      <p>\n        By registering on duostories.org, you consent to the collection and use\n        of your personal data as described in this policy. If you choose to use\n        our website anonymously, we only collect non-personal data related to\n        story completions.\n      </p>\n\n      <h3>4. Data Storage and Security:</h3>\n      <p>\n        Your personal data is stored in a secure MySQL database. We use advanced\n        security measures, including password hashing with salt, to protect your\n        data from unauthorized access.\n      </p>\n\n      <h3>5. User Rights:</h3>\n      <p>\n        You have the right to request the deletion of your personal data. To do\n        so, please contact us via our Discord channel at{\" \"}\n        <Link href=\"https://discord.gg/4NGVScARR3\">\n          https://discord.gg/4NGVScARR3\n        </Link>\n        . We will typically delete your username and email address upon request.\n      </p>\n\n      <h3>6. Changes to the Privacy Policy:</h3>\n      <p>\n        We may update this Privacy Policy from time to time. We will notify you\n        of any changes by posting the new Privacy Policy on this page and\n        updating the &quot;Effective Date&quot; at the top.\n      </p>\n\n      <h3>7. Contact Information:</h3>\n      <p>\n        For any questions or concerns regarding your privacy, please contact us\n        at:\n      </p>\n      <ul>\n        <li>\n          Discord:{\" \"}\n          <Link href=\"https://discord.gg/4NGVScARR3\">\n            https://discord.gg/4NGVScARR3\n          </Link>\n        </li>\n        <li>\n          Email:{\" \"}\n          <Link href=\"mailto:google.compel855@passinbox.com\">\n            google.compel855@passinbox.com\n          </Link>\n        </li>\n      </ul>\n      <h3>8. Use of Cookies and Tracking:</h3>\n      <p>\n        At duostories.org, we value your privacy and aim for transparency in all\n        our data practices. In line with this commitment:\n      </p>\n      <ul>\n        <li>\n          <strong>Use of Cookies:</strong> We use cookies solely for the purpose\n          of maintaining user sessions. These cookies enable you to stay logged\n          in to your account, providing a seamless experience as you navigate\n          through our stories.\n        </li>\n        <li>\n          <strong>No Cross-Website Tracking:</strong> We do not engage in\n          cross-website tracking or analytics. Your activity on duostories.org\n          is not monitored across other websites.\n        </li>\n        <li>\n          <strong>No Third-Party Cookies:</strong> We do not use third-party\n          cookies. All cookies on our site are strictly limited to the\n          functionalities of duostories.org and enhancing your user experience.\n        </li>\n      </ul>\n      <p>\n        You can manage cookies through your browser settings, though please note\n        that disabling cookies may impact your experience on our site.\n      </p>\n      <hr className=\"my-8\" />\n      <p>\n        Thank you for being a part of duostories.org. We are committed to\n        protecting your privacy and creating a safe and enjoyable experience for\n        all of our users.\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/profile/actions.ts",
    "content": "\"use server\";\n\nimport { fetchAuthMutation } from \"@/lib/auth-server\";\nimport { cookies } from \"next/headers\";\nimport { HIDE_STORY_QUESTIONS_COOKIE } from \"@/lib/story-preferences\";\nimport { api } from \"@convex/_generated/api\";\n\nconst STORY_PREFERENCE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;\n\nexport async function setHideStoryQuestionsPreference(hideQuestions: boolean) {\n  await fetchAuthMutation(api.userPreferences.setCurrentStoryPreferences, {\n    hideStoryQuestions: hideQuestions,\n  });\n\n  const cookieStore = await cookies();\n\n  cookieStore.set(HIDE_STORY_QUESTIONS_COOKIE, hideQuestions ? \"1\" : \"0\", {\n    path: \"/\",\n    maxAge: STORY_PREFERENCE_COOKIE_MAX_AGE,\n    sameSite: \"lax\",\n    secure: process.env.NODE_ENV === \"production\",\n  });\n}\n\nexport async function deleteCurrentUserAccount() {\n  await fetchAuthMutation(api.account.deleteCurrentUser, {});\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/profile/data.ts",
    "content": "import { cookies } from \"next/headers\";\nimport { fetchAuthQuery } from \"@/lib/auth-server\";\nimport {\n  HIDE_STORY_QUESTIONS_COOKIE,\n  isStoryQuestionsDisabled,\n} from \"@/lib/story-preferences\";\nimport { getUser, isAdmin, isContributor } from \"@/lib/userInterface\";\nimport { api } from \"@convex/_generated/api\";\n\nexport interface ProfileData {\n  name: string;\n  username: string;\n  email: string;\n  image: string | null;\n  role: string[];\n  provider_linked: Record<string, boolean>;\n  hide_story_questions: boolean;\n}\n\nexport async function getProfileData() {\n  const providersBase = [\"facebook\", \"github\", \"google\", \"discord\"];\n  const user = await getUser();\n  if (!user) return undefined;\n  if (!user.email) throw new Error(\"No user email available\");\n  const cookieStore = await cookies();\n\n  const [providersFromAuth, storyPreferences] = await Promise.all([\n    fetchAuthQuery(api.auth.getLinkedProvidersForCurrentUser, {}) as Promise<\n      string[]\n    >,\n    fetchAuthQuery(\n      api.userPreferences.getCurrentStoryPreferences,\n      {},\n    ) as Promise<{\n      hasSavedPreference: boolean;\n      hideStoryQuestions: boolean;\n    }>,\n  ]);\n\n  const providerLinked = Object.fromEntries(\n    providersBase.map((provider) => [provider, false]),\n  ) as Record<string, boolean>;\n\n  for (const provider of providersFromAuth) {\n    if (provider in providerLinked) {\n      providerLinked[provider] = true;\n    }\n  }\n\n  const role = [];\n  if (isAdmin(user)) role.push(\"Admin\");\n  if (isContributor(user)) role.push(\"Contributor\");\n  const displayName =\n    user.name ?? user.username ?? user.email.split(\"@\")[0] ?? \"User\";\n  const username = user.username ?? displayName;\n\n  return {\n    name: displayName,\n    username,\n    email: user.email,\n    image: user.image ?? null,\n    role,\n    provider_linked: providerLinked,\n    hide_story_questions: storyPreferences.hasSavedPreference\n      ? storyPreferences.hideStoryQuestions\n      : isStoryQuestionsDisabled(\n          cookieStore.get(HIDE_STORY_QUESTIONS_COOKIE)?.value,\n        ),\n  } satisfies ProfileData;\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/profile/page.tsx",
    "content": "import Header from \"../header\";\nimport Profile from \"./profile\";\nimport { Metadata } from \"next\";\nimport { getProfileData } from \"./data\";\n\nexport const metadata: Metadata = {\n  alternates: {\n    canonical: \"https://duostories.org/profile\",\n  },\n};\n\nexport default async function Page() {\n  const providers = await getProfileData();\n\n  if (providers === undefined) {\n    return (\n      <Header>\n        <p data-cy=\"profile-error\">Not Logged in</p>\n        <p>You need to be logged in to see your profile.</p>\n      </Header>\n    );\n  }\n\n  return (\n    <>\n      <Profile providers={providers} />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/(main)/profile/profile.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport Link from \"next/link\";\nimport Header from \"../header\";\nimport Button from \"@/components/ui/button\";\nimport Input from \"@/components/ui/input\";\nimport Switch from \"@/components/ui/switch\";\nimport { GetIcon } from \"@/components/icons\";\nimport { authClient } from \"@/lib/auth-client\";\nimport { resetPostHogUser } from \"@/lib/posthog-user\";\nimport type { ProfileData } from \"./data\";\nimport {\n  deleteCurrentUserAccount,\n  setHideStoryQuestionsPreference,\n} from \"./actions\";\n\nconst pageShellClass =\n  \"mx-auto mb-10 max-w-[860px] rounded-[28px] border border-[color:color-mix(in_srgb,var(--header-border)_60%,transparent)] bg-[color:color-mix(in_srgb,var(--body-background)_94%,white)] p-4 shadow-[0_18px_56px_color-mix(in_srgb,#000_10%,transparent)] sm:p-6\";\nconst cardClass =\n  \"rounded-[22px] border border-[color:color-mix(in_srgb,var(--header-border)_55%,transparent)] bg-[color:color-mix(in_srgb,var(--body-background)_88%,transparent)] p-5\";\nconst rowClass =\n  \"rounded-[18px] border border-[color:color-mix(in_srgb,var(--header-border)_38%,transparent)] bg-[color:color-mix(in_srgb,var(--body-background)_72%,var(--body-background-faint))] px-4 py-4\";\nconst eyebrowClass =\n  \"text-[0.72rem] font-bold uppercase tracking-[0.18em] text-[var(--title-color-dim)]\";\nconst labelClass =\n  \"mb-1 block text-[0.82rem] font-bold uppercase tracking-[0.08em] text-[var(--title-color-dim)]\";\nconst successMessageClass = \"mt-2 block text-[var(--button-border)]\";\nconst errorMessageClass = \"mt-2 block text-[var(--error-red)]\";\n\nfunction roleBadgeTone(role: string) {\n  if (role === \"Admin\") {\n    return \"border-[color:color-mix(in_srgb,#ff9b55_60%,var(--header-border))] bg-[color:color-mix(in_srgb,#ff9b55_14%,transparent)]\";\n  }\n\n  return \"border-[color:color-mix(in_srgb,var(--button-blue-background)_45%,var(--header-border))] bg-[color:color-mix(in_srgb,var(--button-blue-background)_12%,transparent)]\";\n}\n\nfunction StatusText({\n  state,\n  error,\n  success,\n  dataCy,\n}: {\n  state: \"idle\" | \"pending\" | \"success\" | \"error\";\n  error?: string;\n  success?: string;\n  dataCy?: string;\n}) {\n  if (state === \"error\" && error) {\n    return <span className={errorMessageClass}>{error}</span>;\n  }\n\n  if (state === \"success\" && success) {\n    return (\n      <span className={successMessageClass} data-cy={dataCy}>\n        {success}\n      </span>\n    );\n  }\n\n  return null;\n}\n\nfunction SettingRow({\n  label,\n  value,\n  helper,\n  action,\n  children,\n}: {\n  label: string;\n  value: React.ReactNode;\n  helper?: React.ReactNode;\n  action?: React.ReactNode;\n  children?: React.ReactNode;\n}) {\n  return (\n    <div className={rowClass}>\n      <div className=\"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between\">\n        <div className=\"min-w-0 flex-1\">\n          <p className={labelClass}>{label}</p>\n          <div className=\"text-[1rem] font-bold\">{value}</div>\n          {helper ? (\n            <div className=\"mt-1 text-[0.92rem] text-[var(--title-color-dim)]\">\n              {helper}\n            </div>\n          ) : null}\n        </div>\n        {action ? <div className=\"shrink-0\">{action}</div> : null}\n      </div>\n      {children ? (\n        <div className=\"mt-4 border-t border-[color:color-mix(in_srgb,var(--header-border)_26%,transparent)] pt-4\">\n          {children}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nfunction LinkedAccountRow({\n  provider,\n  linked,\n}: {\n  provider: string;\n  linked: boolean;\n}) {\n  const [linkError, setLinkError] = React.useState<string | null>(null);\n\n  const handleLink = async () => {\n    setLinkError(null);\n    const { data, error } = await authClient.linkSocial({\n      provider,\n      callbackURL: window.location.href,\n    });\n\n    if (error) {\n      setLinkError(error.message || \"Could not link account.\");\n      return;\n    }\n    if (data?.url) {\n      window.location.href = data.url;\n      return;\n    }\n\n    window.location.reload();\n  };\n\n  return (\n    <div className=\"rounded-[18px] border border-[color:color-mix(in_srgb,var(--header-border)_40%,transparent)] bg-[color:color-mix(in_srgb,var(--body-background)_72%,var(--body-background-faint))] p-4\">\n      <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n        <div className=\"flex h-11 w-11 items-center justify-center rounded-[14px] bg-[color:color-mix(in_srgb,var(--body-background)_82%,white)]\">\n          <GetIcon name={provider} />\n        </div>\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"m-0 text-[1rem] font-bold capitalize\">{provider}</p>\n          <p className=\"m-0 text-[0.9rem] text-[var(--title-color-dim)]\">\n            {linked ? \"Linked for sign in.\" : \"Available to link.\"}\n          </p>\n        </div>\n        {linked ? (\n          <span className=\"inline-flex rounded-full border border-[color:color-mix(in_srgb,var(--header-border)_60%,transparent)] px-3 py-1.5 text-[0.78rem] font-bold uppercase tracking-[0.08em]\">\n            Linked\n          </span>\n        ) : (\n          <Button\n            type=\"button\"\n            variant=\"primary\"\n            size=\"sm\"\n            onClick={handleLink}\n            className=\"mt-0 min-w-[120px]\"\n          >\n            Link\n          </Button>\n        )}\n      </div>\n      {linkError ? (\n        <span className={errorMessageClass}>{linkError}</span>\n      ) : null}\n    </div>\n  );\n}\n\nfunction ProfileAvatar({\n  image,\n  username,\n}: {\n  image: string | null;\n  username: string;\n}) {\n  const initial = username.slice(0, 1).toUpperCase();\n  const [imageFailed, setImageFailed] = React.useState(false);\n  const showImage =\n    typeof image === \"string\" && image.length > 0 && !imageFailed;\n\n  if (showImage) {\n    return (\n      <img\n        src={image}\n        alt={`${username} profile`}\n        className=\"h-[72px] w-[72px] shrink-0 rounded-full object-cover\"\n        onError={() => setImageFailed(true)}\n      />\n    );\n  }\n\n  return (\n    <div\n      className=\"flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-full bg-[var(--profile-background)] p-0 text-center text-[40px] leading-none uppercase text-[var(--profile-text)]\"\n      aria-label={`${username} profile`}\n      role=\"img\"\n    >\n      <span>{initial}</span>\n    </div>\n  );\n}\n\nexport default function Profile({ providers }: { providers: ProfileData }) {\n  const { data: session } = authClient.useSession();\n  const sessionUser = session?.user as\n    | {\n        name?: string | null;\n        image?: string | null;\n      }\n    | undefined;\n  const initialUsername = providers.username || providers.name;\n  const [username, setUsername] = React.useState(initialUsername);\n  const [newEmail, setNewEmail] = React.useState(\"\");\n  const [resetState, setResetState] = React.useState<\n    \"idle\" | \"pending\" | \"success\" | \"error\"\n  >(\"idle\");\n  const [resetError, setResetError] = React.useState(\"\");\n  const [emailState, setEmailState] = React.useState<\n    \"idle\" | \"pending\" | \"success\" | \"error\"\n  >(\"idle\");\n  const [emailError, setEmailError] = React.useState(\"\");\n  const [pendingEmailChange, setPendingEmailChange] = React.useState(\"\");\n  const [usernameState, setUsernameState] = React.useState<\n    \"idle\" | \"pending\" | \"success\" | \"error\"\n  >(\"idle\");\n  const [usernameError, setUsernameError] = React.useState(\"\");\n  const [savedUsername, setSavedUsername] = React.useState(initialUsername);\n  const [isEditingUsername, setIsEditingUsername] = React.useState(false);\n  const [isEditingEmail, setIsEditingEmail] = React.useState(false);\n  const [isShowingPasswordReset, setIsShowingPasswordReset] =\n    React.useState(false);\n  const [hideStoryQuestions, setHideStoryQuestions] = React.useState(\n    providers.hide_story_questions,\n  );\n  const [storyQuestionsState, setStoryQuestionsState] = React.useState<\n    \"idle\" | \"pending\" | \"success\" | \"error\"\n  >(\"idle\");\n  const [storyQuestionsError, setStoryQuestionsError] = React.useState(\"\");\n  const [isShowingDeleteAccount, setIsShowingDeleteAccount] =\n    React.useState(false);\n  const [deleteConfirmation, setDeleteConfirmation] = React.useState(\"\");\n  const [deleteState, setDeleteState] = React.useState<\n    \"idle\" | \"pending\" | \"error\"\n  >(\"idle\");\n  const [deleteError, setDeleteError] = React.useState(\"\");\n  const avatarName =\n    sessionUser?.name?.trim() || savedUsername || providers.name || \"U\";\n  const avatarImage = sessionUser?.image ?? providers.image;\n  const deleteConfirmationTarget = savedUsername.trim() || providers.email;\n\n  React.useEffect(() => {\n    const storedPendingEmail = window.localStorage.getItem(\n      \"profile_pending_email_change\",\n    );\n    if (!storedPendingEmail) return;\n\n    if (storedPendingEmail.toLowerCase() === providers.email.toLowerCase()) {\n      window.localStorage.removeItem(\"profile_pending_email_change\");\n      setPendingEmailChange(\"\");\n      return;\n    }\n\n    setPendingEmailChange(storedPendingEmail);\n  }, [providers.email]);\n\n  function openUsernameEditor() {\n    setUsername(savedUsername);\n    setUsernameState(\"idle\");\n    setUsernameError(\"\");\n    setIsEditingUsername(true);\n  }\n\n  function closeUsernameEditor() {\n    setUsername(savedUsername);\n    setUsernameState(\"idle\");\n    setUsernameError(\"\");\n    setIsEditingUsername(false);\n  }\n\n  function openEmailEditor() {\n    setNewEmail(\"\");\n    setEmailState(\"idle\");\n    setEmailError(\"\");\n    setIsEditingEmail(true);\n  }\n\n  function closeEmailEditor() {\n    setNewEmail(\"\");\n    setEmailState(\"idle\");\n    setEmailError(\"\");\n    setIsEditingEmail(false);\n  }\n\n  function openDeleteAccount() {\n    setDeleteConfirmation(\"\");\n    setDeleteState(\"idle\");\n    setDeleteError(\"\");\n    setIsShowingDeleteAccount(true);\n  }\n\n  function closeDeleteAccount() {\n    setDeleteConfirmation(\"\");\n    setDeleteState(\"idle\");\n    setDeleteError(\"\");\n    setIsShowingDeleteAccount(false);\n  }\n\n  async function requestPasswordReset() {\n    setResetState(\"pending\");\n    setResetError(\"\");\n\n    try {\n      await authClient.requestPasswordReset({\n        email: providers.email,\n        redirectTo: `${window.location.origin}/auth/reset_pw`,\n      });\n      setResetState(\"success\");\n    } catch (e) {\n      setResetState(\"error\");\n      setResetError((e as Error)?.message || \"Could not send reset link.\");\n    }\n  }\n\n  async function requestEmailChange() {\n    const emailValidation = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n    if (!emailValidation.test(newEmail)) {\n      setEmailState(\"error\");\n      setEmailError(\"Please enter a valid email address.\");\n      return;\n    }\n\n    if (newEmail.toLowerCase() === providers.email.toLowerCase()) {\n      setEmailState(\"error\");\n      setEmailError(\"This is already your current email address.\");\n      return;\n    }\n\n    setEmailState(\"pending\");\n    setEmailError(\"\");\n    const { error } = await authClient.changeEmail({\n      newEmail,\n      callbackURL: `${window.location.origin}/profile`,\n    });\n    if (error) {\n      setEmailState(\"error\");\n      setEmailError(error.message || \"Could not start email change.\");\n      return;\n    }\n    window.localStorage.setItem(\"profile_pending_email_change\", newEmail);\n    setPendingEmailChange(newEmail);\n    setEmailState(\"success\");\n  }\n\n  async function saveUsername() {\n    const normalizedUsername = username.trim();\n    const usernameValidation = /^[a-zA-Z0-9_-]{3,20}$/;\n\n    if (!usernameValidation.test(normalizedUsername)) {\n      setUsernameState(\"error\");\n      setUsernameError(\n        \"Username must be 3-20 characters and use only letters, numbers, _ or -.\",\n      );\n      return;\n    }\n\n    if (normalizedUsername === savedUsername) {\n      setUsernameState(\"success\");\n      setUsernameError(\"\");\n      return;\n    }\n\n    setUsernameState(\"pending\");\n    setUsernameError(\"\");\n\n    const { error } = await authClient.updateUser({\n      username: normalizedUsername,\n      name: normalizedUsername,\n    });\n\n    if (error) {\n      setUsernameState(\"error\");\n      const errorCode =\n        typeof (error as { code?: unknown })?.code === \"string\"\n          ? (error as { code: string }).code\n          : \"\";\n      const errorMessage =\n        typeof error.message === \"string\" ? error.message : \"\";\n      const isUsernameTaken =\n        errorCode === \"USERNAME_IS_ALREADY_TAKEN\" ||\n        errorMessage.toLowerCase().includes(\"username is already taken\");\n      setUsernameError(\n        isUsernameTaken\n          ? \"That username is already taken. Please choose another one.\"\n          : errorMessage || \"Could not update username.\",\n      );\n      return;\n    }\n\n    setSavedUsername(normalizedUsername);\n    setUsernameState(\"success\");\n    setUsernameError(\"\");\n  }\n\n  async function toggleHideStoryQuestions() {\n    if (storyQuestionsState === \"pending\") return;\n\n    const nextHideStoryQuestions = !hideStoryQuestions;\n    setHideStoryQuestions(nextHideStoryQuestions);\n    setStoryQuestionsState(\"pending\");\n    setStoryQuestionsError(\"\");\n\n    try {\n      await setHideStoryQuestionsPreference(nextHideStoryQuestions);\n      setStoryQuestionsState(\"success\");\n    } catch (error) {\n      setHideStoryQuestions(!nextHideStoryQuestions);\n      setStoryQuestionsState(\"error\");\n      setStoryQuestionsError(\n        (error as Error)?.message ||\n          \"Could not update your story question preference.\",\n      );\n    }\n  }\n\n  async function removeAccount() {\n    if (deleteState === \"pending\") return;\n\n    if (deleteConfirmation.trim() !== deleteConfirmationTarget) {\n      setDeleteState(\"error\");\n      setDeleteError(`Type ${deleteConfirmationTarget} to confirm.`);\n      return;\n    }\n\n    const confirmed = window.confirm(\n      \"Delete your account now? This will remove your sign-in account and log you out.\",\n    );\n    if (!confirmed) {\n      return;\n    }\n\n    setDeleteState(\"pending\");\n    setDeleteError(\"\");\n\n    try {\n      await deleteCurrentUserAccount();\n    } catch (error) {\n      setDeleteState(\"error\");\n      setDeleteError(\n        (error as Error)?.message || \"Could not delete your account.\",\n      );\n      return;\n    }\n\n    window.localStorage.removeItem(\"profile_pending_email_change\");\n\n    try {\n      await authClient.signOut();\n    } catch {}\n\n    resetPostHogUser();\n    window.location.href = \"/\";\n  }\n\n  return (\n    <>\n      <Header>\n        <h1>Profile</h1>\n        <p>Manage your account details and sign-in methods.</p>\n      </Header>\n\n      <div className={pageShellClass}>\n        <section className={cardClass}>\n          <p className={eyebrowClass}>Account</p>\n          <div className=\"mt-2 flex flex-col gap-4 border-b border-[color:color-mix(in_srgb,var(--header-border)_30%,transparent)] pb-5 sm:flex-row sm:items-center sm:justify-between\">\n            <div className=\"flex items-center gap-4\">\n              <ProfileAvatar image={avatarImage} username={avatarName} />\n              <div>\n                <h2 className=\"text-[1.6rem] font-bold leading-[1.1]\">\n                  {savedUsername}\n                </h2>\n                <p className=\"mb-0 mt-1 break-all text-[0.95rem] text-[var(--title-color-dim)]\">\n                  {providers.email}\n                </p>\n              </div>\n            </div>\n            <div className=\"flex flex-wrap gap-2\">\n              {providers.role.length ? (\n                providers.role.map((role) => (\n                  <span\n                    key={role}\n                    className={`rounded-full border px-3 py-1.5 text-[0.78rem] font-bold uppercase tracking-[0.08em] ${roleBadgeTone(role)}`}\n                  >\n                    {role}\n                  </span>\n                ))\n              ) : (\n                <span className=\"rounded-full border border-[color:color-mix(in_srgb,var(--header-border)_55%,transparent)] px-3 py-1.5 text-[0.78rem] font-bold uppercase tracking-[0.08em] text-[var(--title-color-dim)]\">\n                  Standard user\n                </span>\n              )}\n            </div>\n          </div>\n\n          <div className=\"mt-5 space-y-4\">\n            <SettingRow\n              label=\"Username\"\n              value={savedUsername}\n              action={\n                <Button\n                  type=\"button\"\n                  className=\"mt-0 min-w-[120px]\"\n                  onClick={() => {\n                    if (isEditingUsername) {\n                      closeUsernameEditor();\n                    } else {\n                      openUsernameEditor();\n                    }\n                  }}\n                >\n                  {isEditingUsername ? \"Close\" : \"Edit\"}\n                </Button>\n              }\n            >\n              {isEditingUsername ? (\n                <div className=\"space-y-3\">\n                  <Input\n                    value={username}\n                    onChange={(e) => setUsername(e.target.value)}\n                  />\n                  <StatusText\n                    state={usernameState}\n                    error={usernameError}\n                    success=\"Username saved.\"\n                  />\n                  <div className=\"flex flex-wrap gap-3\">\n                    <Button\n                      type=\"button\"\n                      primary={true}\n                      onClick={saveUsername}\n                      disabled={\n                        usernameState === \"pending\" ||\n                        username.trim() === savedUsername\n                      }\n                    >\n                      {usernameState === \"pending\" ? \"Saving...\" : \"Save\"}\n                    </Button>\n                    <Button\n                      type=\"button\"\n                      className=\"mt-0 min-w-[120px]\"\n                      onClick={closeUsernameEditor}\n                    >\n                      Cancel\n                    </Button>\n                  </div>\n                </div>\n              ) : null}\n            </SettingRow>\n\n            <SettingRow\n              label=\"Email\"\n              value={providers.email}\n              helper={\n                pendingEmailChange\n                  ? `Pending change to ${pendingEmailChange}.`\n                  : undefined\n              }\n              action={\n                <Button\n                  type=\"button\"\n                  className=\"mt-0 min-w-[120px]\"\n                  onClick={() => {\n                    if (isEditingEmail) {\n                      closeEmailEditor();\n                    } else {\n                      openEmailEditor();\n                    }\n                  }}\n                >\n                  {isEditingEmail ? \"Close\" : \"Edit\"}\n                </Button>\n              }\n            >\n              {isEditingEmail ? (\n                <div className=\"space-y-3\">\n                  <Input\n                    type=\"email\"\n                    value={newEmail}\n                    onChange={(e) => setNewEmail(e.target.value)}\n                    placeholder=\"New email address\"\n                    data-cy=\"profile-new-email\"\n                  />\n                  <StatusText\n                    state={emailState}\n                    error={emailError}\n                    success=\"Check your current email, then your new email for confirmation links.\"\n                    dataCy=\"profile-email-message\"\n                  />\n                  {pendingEmailChange ? (\n                    <div\n                      className=\"text-[0.92rem] text-[var(--title-color-dim)]\"\n                      data-cy=\"profile-email-pending\"\n                    >\n                      Current email: {providers.email}. Pending change:{\" \"}\n                      {pendingEmailChange}.\n                    </div>\n                  ) : null}\n                  <div className=\"flex flex-wrap gap-3\">\n                    <Button\n                      type=\"button\"\n                      primary={true}\n                      data-cy=\"profile-change-email\"\n                      onClick={requestEmailChange}\n                      disabled={emailState === \"pending\"}\n                    >\n                      {emailState === \"pending\"\n                        ? \"Sending...\"\n                        : \"Request Email Change\"}\n                    </Button>\n                    <Button\n                      type=\"button\"\n                      className=\"mt-0 min-w-[120px]\"\n                      onClick={closeEmailEditor}\n                    >\n                      Cancel\n                    </Button>\n                  </div>\n                </div>\n              ) : null}\n            </SettingRow>\n\n            <SettingRow\n              label=\"Password\"\n              value=\"Password reset via email\"\n              action={\n                <Button\n                  type=\"button\"\n                  className=\"mt-0 min-w-[120px]\"\n                  onClick={() =>\n                    setIsShowingPasswordReset((current) => !current)\n                  }\n                >\n                  {isShowingPasswordReset ? \"Close\" : \"Reset\"}\n                </Button>\n              }\n            >\n              {isShowingPasswordReset ? (\n                <div className=\"space-y-3\">\n                  <StatusText\n                    state={resetState}\n                    error={resetError}\n                    success=\"Check your email for the password reset link.\"\n                    dataCy=\"profile-reset-message\"\n                  />\n                  <Button\n                    type=\"button\"\n                    primary={true}\n                    data-cy=\"profile-reset-password\"\n                    onClick={requestPasswordReset}\n                    disabled={resetState === \"pending\"}\n                  >\n                    {resetState === \"pending\"\n                      ? \"Sending...\"\n                      : \"Send Password Reset Link\"}\n                  </Button>\n                </div>\n              ) : null}\n            </SettingRow>\n          </div>\n        </section>\n\n        <section className={`${cardClass} mt-5`}>\n          <div className=\"flex flex-col gap-2 border-b border-[color:color-mix(in_srgb,var(--header-border)_30%,transparent)] pb-4 sm:flex-row sm:items-end sm:justify-between\">\n            <div>\n              <p className={eyebrowClass}>Stories</p>\n              <h2 className=\"mt-1 text-[1.45rem] font-bold leading-[1.1]\">\n                Playback preferences\n              </h2>\n            </div>\n            <p className=\"m-0 text-[0.9rem] text-[var(--title-color-dim)]\">\n              Control how standard story pages behave when you open them.\n            </p>\n          </div>\n\n          <div className=\"mt-5 space-y-4\">\n            <SettingRow\n              label=\"Skip Questions\"\n              value={\n                hideStoryQuestions\n                  ? \"Enabled, questions will be skipped\"\n                  : \"Disabled, questions are shown as normal\"\n              }\n              helper=\"Whether to show the questions in interactive story mode.\"\n              action={\n                <Switch\n                  checked={hideStoryQuestions}\n                  onClick={toggleHideStoryQuestions}\n                  disabled={storyQuestionsState === \"pending\"}\n                  ariaLabel=\"Disable interactive questions in stories\"\n                />\n              }\n            >\n              <StatusText\n                state={storyQuestionsState}\n                error={storyQuestionsError}\n                success=\"Story preference saved.\"\n              />\n            </SettingRow>\n          </div>\n        </section>\n\n        <section className={`${cardClass} mt-5`}>\n          <div className=\"flex flex-col gap-2 border-b border-[color:color-mix(in_srgb,var(--header-border)_30%,transparent)] pb-4 sm:flex-row sm:items-end sm:justify-between\">\n            <div>\n              <p className={eyebrowClass}>Connected accounts</p>\n              <h2 className=\"mt-1 text-[1.45rem] font-bold leading-[1.1]\">\n                Social sign-in\n              </h2>\n            </div>\n            <p className=\"m-0 text-[0.9rem] text-[var(--title-color-dim)]\">\n              Link a provider to sign in without your password.\n            </p>\n          </div>\n          <div className=\"mt-4 grid gap-3\">\n            {Object.entries(providers.provider_linked).map(\n              ([provider, linked]) => (\n                <LinkedAccountRow\n                  key={provider}\n                  provider={provider}\n                  linked={linked}\n                />\n              ),\n            )}\n          </div>\n        </section>\n\n        <section className={`${cardClass} mt-5`}>\n          <div className=\"flex flex-col gap-2 border-b border-[color:color-mix(in_srgb,#e89a9a_35%,transparent)] pb-4 sm:flex-row sm:items-end sm:justify-between\">\n            <div>\n              <p className={eyebrowClass}>Delete account</p>\n              <h2 className=\"mt-1 text-[1.45rem] font-bold leading-[1.1]\">\n                Permanently delete your account\n              </h2>\n            </div>\n            <Button\n              type=\"button\"\n              variant=\"destructive\"\n              className=\"mt-0 min-w-[180px]\"\n              disabled={deleteState === \"pending\"}\n              onClick={() => {\n                if (deleteState === \"pending\") {\n                  return;\n                }\n                if (isShowingDeleteAccount) {\n                  closeDeleteAccount();\n                } else {\n                  openDeleteAccount();\n                }\n              }}\n            >\n              {isShowingDeleteAccount ? \"Close\" : \"Delete account\"}\n            </Button>\n          </div>\n          <p className=\"mb-0 mt-3 text-[0.96rem] leading-[1.65] text-[var(--title-color-dim)]\">\n            Remove your Duostories account and sign out immediately. This action\n            cannot be undone.\n          </p>\n          {isShowingDeleteAccount ? (\n            <div className=\"mt-4 rounded-[20px] border border-[color:color-mix(in_srgb,#e89a9a_45%,transparent)] bg-[color:color-mix(in_srgb,#f7d7d7_28%,var(--body-background))] p-4\">\n              <p className={labelClass}>Confirm deletion</p>\n              <p className=\"mt-0 text-[0.92rem] text-[var(--title-color-dim)]\">\n                Type <strong>{deleteConfirmationTarget}</strong> to confirm that\n                you want to permanently delete this account.\n              </p>\n              <Input\n                value={deleteConfirmation}\n                onChange={(e) => setDeleteConfirmation(e.target.value)}\n                placeholder={deleteConfirmationTarget}\n                autoCapitalize=\"none\"\n                autoCorrect=\"off\"\n                spellCheck={false}\n                data-cy=\"profile-delete-confirmation\"\n              />\n              {deleteState === \"error\" ? (\n                <span\n                  className={errorMessageClass}\n                  data-cy=\"profile-delete-error\"\n                >\n                  {deleteError}\n                </span>\n              ) : null}\n              <div className=\"mt-4 flex flex-wrap gap-3\">\n                <Button\n                  type=\"button\"\n                  variant=\"destructive\"\n                  data-cy=\"profile-delete-account\"\n                  onClick={removeAccount}\n                  disabled={\n                    deleteState === \"pending\" ||\n                    deleteConfirmation.trim() !== deleteConfirmationTarget\n                  }\n                >\n                  {deleteState === \"pending\"\n                    ? \"Deleting...\"\n                    : \"Permanently delete my account\"}\n                </Button>\n                <Button\n                  type=\"button\"\n                  className=\"mt-0 min-w-[120px]\"\n                  onClick={closeDeleteAccount}\n                  disabled={deleteState === \"pending\"}\n                >\n                  Cancel\n                </Button>\n              </div>\n            </div>\n          ) : null}\n        </section>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/learn/page.tsx",
    "content": "import React from \"react\";\nimport { redirect } from \"next/navigation\";\nimport Welcome from \"./welcome\";\nimport { getUser } from \"@/lib/userInterface\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\n\nexport const metadata = {\n  title: \"Learn with Duostories\",\n  description:\n    \"Sign in to track your progress or continue anonymously and learn with Duostories.\",\n  alternates: {\n    canonical: \"https://duostories.org/learn\",\n  },\n};\n\nexport default async function Page() {\n  const user = await getUser();\n\n  if (user?.userId) {\n    const lastCourseShort = await fetchQuery(\n      api.storyDone.getLastDoneCourseShortForLegacyUser,\n      {\n        legacyUserId: user.userId,\n      },\n    );\n    if (lastCourseShort) redirect(\"/\" + lastCourseShort);\n  }\n\n  return <Welcome />;\n}\n"
  },
  {
    "path": "src/app/(stories)/learn/welcome.tsx",
    "content": "\"use client\";\nimport Link from \"next/link\";\nimport React from \"react\";\nimport {\n  buttonInnerClassName,\n  buttonRootClassName,\n} from \"@/components/ui/button\";\n\nexport default function Page() {\n  return (\n    <div className=\"relative flex min-h-screen items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_top,#ddf4ff_0%,#ffffff_45%,#f6fbef_100%)] px-4 py-10\">\n      <div className=\"pointer-events-none absolute left-1/2 top-0 h-[360px] w-[360px] -translate-x-1/2 rounded-full bg-[color:color-mix(in_srgb,var(--button-background)_18%,transparent)] blur-3xl\" />\n      <main className=\"relative w-full max-w-[440px] rounded-[32px] border border-[color:color-mix(in_srgb,var(--overview-hr)_80%,transparent)] bg-[color:color-mix(in_srgb,var(--body-background)_92%,white)] p-8 text-center shadow-[0_28px_80px_rgba(28,176,246,0.14)] backdrop-blur\">\n        <div className=\"mx-auto flex max-w-[320px] flex-col items-center\">\n          <img\n            src=\"/icon192.png\"\n            alt=\"Duostories logo\"\n            width={96}\n            height={96}\n            className=\"mb-5 h-24 w-24 rounded-[24px] border border-[color:color-mix(in_srgb,var(--overview-hr)_80%,transparent)] bg-white p-3 shadow-[0_12px_30px_rgba(28,176,246,0.16)]\"\n          />\n          <p className=\"m-0 text-[0.82rem] font-extrabold uppercase tracking-[0.18em] text-[var(--link-blue)]\">\n            Learn with stories\n          </p>\n          <h1 className=\"m-0 mt-3 text-[calc(40/16*1rem)] font-extrabold leading-[1.05] tracking-[-0.03em] text-[var(--text-color)]\">\n            Welcome to Duostories\n          </h1>\n          <p className=\"m-0 mt-4 text-[calc(18/16*1rem)] leading-[1.55] text-[color:color-mix(in_srgb,var(--text-color-dim)_88%,transparent)]\">\n            Sign in to keep your reading progress, or continue anonymously and\n            start learning right away.\n          </p>\n        </div>\n\n        <nav\n          aria-label=\"Authentication options\"\n          className=\"mt-8 flex flex-col gap-3\"\n        >\n          <Link\n            href=\"/auth/signin?callbackUrl=/\"\n            className={buttonRootClassName({\n              className: \"block w-full no-underline\",\n              variant: \"primary\",\n            })}\n          >\n            <span className={buttonInnerClassName({ variant: \"primary\" })}>\n              Sign in\n            </span>\n          </Link>\n          <Link\n            href=\"/auth/register\"\n            className={buttonRootClassName({\n              className: \"block w-full no-underline\",\n              variant: \"primary\",\n            })}\n          >\n            <span className={buttonInnerClassName({ variant: \"primary\" })}>\n              Register\n            </span>\n          </Link>\n        </nav>\n\n        <div className=\"my-6 flex items-center gap-3 text-[0.9rem] font-bold uppercase tracking-[0.14em] text-[color:color-mix(in_srgb,var(--text-color-dim)_70%,transparent)]\">\n          <span className=\"h-px flex-1 bg-[var(--overview-hr)]\" />\n          <span>or</span>\n          <span className=\"h-px flex-1 bg-[var(--overview-hr)]\" />\n        </div>\n\n        <Link\n          href=\"/\"\n          className={buttonRootClassName({\n            className: \"block w-full no-underline\",\n            variant: \"secondary\",\n          })}\n        >\n          <span className={buttonInnerClassName({ variant: \"secondary\" })}>\n            Continue anonymously\n          </span>\n        </Link>\n      </main>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/story/[story_id]/auto_play/page.tsx",
    "content": "import React from \"react\";\nimport StoryWrapper from \"./story_wrapper\";\nimport { notFound } from \"next/navigation\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: { story_id: string };\n}) {\n  const story_id = parseInt((await params).story_id);\n  const story = await fetchQuery(api.storyRead.getStoryMetaByLegacyId, {\n    storyId: story_id,\n  });\n\n  if (!story) notFound();\n\n  return {\n    title: `${story.from_language_name} - Duostories ${story.learning_language_long} from ${story.from_language_long}`,\n    alternates: {\n      canonical: `https://duostories.org/story/${story_id}/auto_play`,\n    },\n    keywords: [story.learning_language_long],\n    openGraph: {\n      images: [\n        `/api/og-story?title=${story.from_language_name}&image=${story.image}&name=${story.learning_language_long}`,\n      ],\n      url: `https://duostories.org/story/${story_id}/auto_play`,\n      type: \"website\",\n    },\n  };\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ story_id: string }>;\n}) {\n  const story_id = parseInt((await params).story_id);\n  if (!Number.isFinite(story_id)) notFound();\n\n  return <StoryWrapper storyId={story_id} />;\n}\n"
  },
  {
    "path": "src/app/(stories)/story/[story_id]/auto_play/story_wrapper.tsx",
    "content": "\"use client\";\nimport React from \"react\";\n\nimport StoryAutoPlay from \"@/components/StoryAutoPlay\";\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\n\nexport default function StoryWrapper({ storyId }: { storyId: number }) {\n  const story = useQuery(api.storyRead.getStoryByLegacyId, { storyId });\n  if (story === undefined) return null;\n  if (story === null) return <p>Story not found.</p>;\n\n  return <StoryAutoPlay story={story} />;\n}\n"
  },
  {
    "path": "src/app/(stories)/story/[story_id]/getStory.ts",
    "content": "import { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\n\nexport async function get_story(story_id: number) {\n  return await fetchQuery(api.storyRead.getStoryByLegacyId, {\n    storyId: story_id,\n  });\n}\n\nexport type StoryData = NonNullable<Awaited<ReturnType<typeof get_story>>>;\n"
  },
  {
    "path": "src/app/(stories)/story/[story_id]/loading.tsx",
    "content": "import React from \"react\";\n\nimport StoryHeaderProgress from \"@/components/StoryHeaderProgress\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\nexport default function Loading() {\n  return (\n    <>\n      <StoryHeaderProgress course=\"unknown\" progress={0} length={10} />\n      <div style={{ textAlign: \"center\", marginTop: \"200px\" }}>\n        <p>Loading Story...</p>\n        <Spinner />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/story/[story_id]/not-found.tsx",
    "content": "import Link from \"next/link\";\nimport Header from \"../../(main)/header\";\n\nexport default function NotFound() {\n  return (\n    <Header>\n      <h1>Story Not Found</h1>\n      <p>This story does not exist or is not published yet.</p>\n      <p>\n        Go back to the <Link href={\"/\"}>main page</Link>.\n      </p>\n    </Header>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/story/[story_id]/page.tsx",
    "content": "import React, { Suspense } from \"react\";\nimport { cookies } from \"next/headers\";\nimport { notFound } from \"next/navigation\";\nimport { fetchAuthQuery } from \"@/lib/auth-server\";\nimport getUserId from \"@/lib/getUserId\";\nimport {\n  HIDE_STORY_QUESTIONS_COOKIE,\n  isStoryQuestionsDisabled,\n} from \"@/lib/story-preferences\";\nimport StoryWrapper from \"./story_wrapper\";\nimport { get_story } from \"./getStory\";\nimport LocalisationProvider from \"@/components/LocalisationProvider\";\nimport { ConvexHttpClient } from \"convex/browser\";\nimport { api } from \"@convex/_generated/api\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { fetchAuthMutation } from \"@/lib/auth-server\";\n\nconst convexUrl =\n  process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL ?? \"\";\n\nif (!convexUrl) {\n  throw new Error(\"Missing NEXT_PUBLIC_CONVEX_URL/CONVEX_URL\");\n}\n\nconst convex = new ConvexHttpClient(convexUrl);\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ story_id: string }>;\n}) {\n  const story_id = parseInt((await params).story_id);\n  const story = await fetchQuery(api.storyRead.getStoryMetaByLegacyId, {\n    storyId: story_id,\n  });\n\n  if (!story) notFound();\n\n  return {\n    title: `${story.from_language_name} - Duostories ${story.learning_language_long} from ${story.from_language_long}`,\n    alternates: {\n      canonical: `https://duostories.org/story/${story_id}`,\n    },\n    keywords: [story.learning_language_long],\n    openGraph: {\n      images: [\n        `/api/og-story?title=${story.from_language_name}&image=${story.image}&name=${story.learning_language_long}`,\n      ],\n      url: `https://duostories.org/story/${story_id}`,\n      type: \"website\",\n    },\n  };\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ story_id: string }>;\n}) {\n  const cookieStore = await cookies();\n  const story_id = parseInt((await params).story_id);\n\n  const story = await get_story(story_id);\n  if (!story) notFound();\n  const course_id = story.course_id;\n\n  const user_id = await getUserId();\n  const cookieHideStoryQuestions = isStoryQuestionsDisabled(\n    cookieStore.get(HIDE_STORY_QUESTIONS_COOKIE)?.value,\n  );\n  const savedStoryPreferences = user_id\n    ? ((await fetchAuthQuery(\n        api.userPreferences.getCurrentStoryPreferences,\n        {},\n      )) as {\n        hasSavedPreference: boolean;\n        hideStoryQuestions: boolean;\n      })\n    : null;\n  const hideStoryQuestions =\n    savedStoryPreferences?.hasSavedPreference === true\n      ? savedStoryPreferences.hideStoryQuestions\n      : cookieHideStoryQuestions;\n  async function setStoryDoneAction() {\n    \"use server\";\n    if (!user_id) {\n      await convex.mutation(api.storyDone.recordStoryDone, {\n        legacyStoryId: story_id,\n        time: Date.now(),\n      });\n      return {\n        message: \"done\",\n        story_id: story_id,\n      };\n    }\n    await fetchAuthMutation(api.storyDone.recordStoryDone, {\n      legacyStoryId: story_id,\n      time: Date.now(),\n    });\n    return {\n      message: \"done\",\n      story_id: story_id,\n      course_id: course_id,\n    };\n  }\n\n  return (\n    <>\n      <LocalisationProvider lang={story.from_language_id}>\n        <Suspense fallback={null}>\n          <StoryWrapper\n            story={story}\n            hideStoryQuestions={hideStoryQuestions}\n            storyFinishedIndexUpdate={setStoryDoneAction}\n            //localization={localization}\n          />\n        </Suspense>\n      </LocalisationProvider>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/story/[story_id]/script/page.tsx",
    "content": "import React from \"react\";\nimport { notFound } from \"next/navigation\";\nimport StoryWrapper from \"./story_wrapper\";\nimport { get_story } from \"../getStory\";\nimport LocalisationProvider from \"@/components/LocalisationProvider\";\nimport { headers } from \"next/headers\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ story_id: string }>;\n}) {\n  const story_id = parseInt((await params).story_id);\n  const story = await fetchQuery(api.storyRead.getStoryMetaByLegacyId, {\n    storyId: story_id,\n  });\n\n  if (!story) notFound();\n\n  return {\n    title: `Duostories ${story.learning_language_long} from ${story.from_language_long}: ${story.from_language_name}`,\n    alternates: {\n      canonical: `https://duostories.org/story/${story_id}`,\n    },\n    keywords: [story.learning_language_long],\n  };\n}\n\nasync function getNavigationMode() {\n  const headersList = await headers();\n  // If there is a next-url header, soft navigation has been performed\n  // Otherwise, hard navigation has been performed\n  const nextUrl = headersList.get(\"next-url\");\n  if (nextUrl) {\n    return \"soft\";\n  }\n  return \"hard\";\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ story_id: string }>;\n}) {\n  const story_id = parseInt((await params).story_id);\n  const story = await get_story(story_id);\n  if (!story) notFound();\n\n  async function setStoryDoneAction() {\n    \"use server\";\n    return {\n      message: \"done\",\n    };\n  }\n\n  return (\n    <>\n      <LocalisationProvider lang={story.from_language_id}>\n        <StoryWrapper\n          story={story}\n          storyFinishedIndexUpdate={setStoryDoneAction}\n          //localization={localization}\n          show_title_page={(await getNavigationMode()) === \"hard\"}\n        />\n      </LocalisationProvider>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/story/[story_id]/script/story_wrapper.tsx",
    "content": "\"use client\";\nimport React from \"react\";\n\nimport StoryProgress from \"@/components/StoryProgress\";\nimport { StoryData } from \"@/app/(stories)/story/[story_id]/getStory\";\n\nexport default function StoryWrapper({\n  story,\n  storyFinishedIndexUpdate,\n  show_title_page,\n}: {\n  story: StoryData;\n  storyFinishedIndexUpdate: () => Promise<{ message: string }>;\n  show_title_page: boolean;\n}) {\n  const [highlight_name, setHighlightName] = React.useState<string[]>([]);\n  const [hideNonHighlighted, setHideNonHighlighted] = React.useState(false);\n  //console.log(\"highlight_nameX\", highlight_name);\n  return (\n    <>\n      <StoryProgress\n        story={story}\n        settings={{\n          hide_questions: true,\n          show_all: true,\n          show_names: true,\n          rtl: story.learning_language_rtl,\n          highlight_name: highlight_name,\n          hideNonHighlighted: hideNonHighlighted,\n          setHighlightName: setHighlightName,\n          setHideNonHighlighted: setHideNonHighlighted,\n          show_hints: true,\n          setShowHints: () => {},\n          show_audio: true,\n          setShowAudio: () => {},\n          id: story.id,\n          show_title_page: show_title_page,\n        }}\n        onEnd={storyFinishedIndexUpdate}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/story/[story_id]/story_wrapper.tsx",
    "content": "\"use client\";\nimport React from \"react\";\n\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useQuery } from \"convex/react\";\nimport StoryProgress from \"@/components/StoryProgress\";\nimport { useNavigationMode } from \"@/components/NavigationModeProvider\";\nimport { StoryData } from \"@/app/(stories)/story/[story_id]/getStory\";\nimport { api } from \"@convex/_generated/api\";\nimport posthog from \"posthog-js\";\nimport { authClient } from \"@/lib/auth-client\";\nimport {\n  getCurrentPostHogUser,\n  identifyPostHogUser,\n  type PostHogUser,\n} from \"@/lib/posthog-user\";\n\nexport default function StoryWrapper({\n  story,\n  hideStoryQuestions,\n  storyFinishedIndexUpdate,\n}: {\n  story: StoryData;\n  hideStoryQuestions: boolean;\n  storyFinishedIndexUpdate: () => Promise<\n    | {\n        message: string;\n        story_id: number;\n        course_id?: undefined;\n      }\n    | {\n        message: string;\n        story_id: number;\n        course_id: number;\n      }\n  >;\n}) {\n  const mode = useNavigationMode();\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const [highlight_name, setHighlightName] = React.useState<string[]>([]);\n  const [hideNonHighlighted, setHideNonHighlighted] = React.useState(false);\n  const trackedStoryStart = React.useRef(false);\n  const completionInFlight = React.useRef(false);\n  const { data: session } = authClient.useSession();\n  const sessionUser = (session?.user ?? null) as PostHogUser | null;\n  const role = typeof sessionUser?.role === \"string\" ? sessionUser.role : null;\n  const editHrefBase =\n    role === \"contributor\" || role === \"admin\"\n      ? `/editor/course/${story.course_short}/story/${story.id}`\n      : undefined;\n  const rawLine = searchParams.get(\"line\");\n  const parsedLine = typeof rawLine === \"string\" ? Number(rawLine) : undefined;\n  const initialFocusLine =\n    parsedLine !== undefined && Number.isFinite(parsedLine) && parsedLine > 0\n      ? parsedLine\n      : undefined;\n  const nextStep = useQuery(\n    api.storyDone.getNextStoryForCurrentUserInCourse,\n    sessionUser?.id\n      ? {\n          courseShort: story.course_short,\n          currentStoryId: story.id,\n        }\n      : \"skip\",\n  );\n  const showNextStoryAction = Boolean(nextStep?.nextStoryId);\n  const nextStoryPreview = useQuery(\n    api.storyRead.getStoryPreviewByLegacyId,\n    showNextStoryAction && nextStep?.nextStoryId\n      ? { storyId: nextStep.nextStoryId }\n      : \"skip\",\n  );\n\n  const captureStoryEvent = React.useCallback(\n    async (eventName: \"story_started\" | \"story_completed\") => {\n      if (!identifyPostHogUser(sessionUser)) {\n        identifyPostHogUser(await getCurrentPostHogUser());\n      }\n      posthog.capture(eventName, {\n        story_id: story.id,\n        story_name: story.from_language_name,\n        course_id: story.course_id,\n        course_short: story.course_short,\n        learning_language: story.learning_language_long,\n      });\n    },\n    [\n      sessionUser,\n      story.id,\n      story.from_language_name,\n      story.course_id,\n      story.course_short,\n      story.learning_language_long,\n    ],\n  );\n\n  // Track story started on component mount\n  React.useEffect(() => {\n    if (trackedStoryStart.current) return;\n    trackedStoryStart.current = true;\n    void captureStoryEvent(\"story_started\");\n  }, [captureStoryEvent]);\n\n  const finishedLabel = showNextStoryAction\n    ? \"Next story\"\n    : nextStep && !nextStep.nextStoryId\n      ? \"Review stories\"\n      : undefined;\n\n  async function completeStoryOnce() {\n    if (completionInFlight.current) return false;\n    completionInFlight.current = true;\n    let succeeded = false;\n\n    try {\n      await captureStoryEvent(\"story_completed\");\n      await storyFinishedIndexUpdate();\n      succeeded = true;\n      return true;\n    } finally {\n      if (!succeeded) {\n        completionInFlight.current = false;\n      }\n    }\n  }\n\n  async function goToOverview() {\n    const didComplete = await completeStoryOnce();\n    if (!didComplete) return;\n    navigateToOverview();\n  }\n\n  function navigateToOverview() {\n    const setHash = story.set_id > 0 ? `#${story.set_id}` : \"\";\n    router.push(`/${story.course_short}${setHash}`);\n  }\n\n  async function onEnd() {\n    const didComplete = await completeStoryOnce();\n    if (!didComplete) return;\n    if (showNextStoryAction && nextStep?.nextStoryId) {\n      posthog.capture(\"story_end_next_clicked\", {\n        language: story.learning_language_long,\n        story_id: nextStep.nextStoryId,\n        completed_count: nextStep.completedCount,\n        total_count: nextStep.totalCount,\n      });\n      router.push(`/story/${nextStep.nextStoryId}`);\n      return;\n    }\n    navigateToOverview();\n  }\n\n  const shouldShowDefaultFinishedButton =\n    !sessionUser?.id || nextStep === undefined || nextStep === null;\n  const showFinishedPrimaryAction =\n    shouldShowDefaultFinishedButton || Boolean(finishedLabel);\n\n  return (\n    <>\n      <StoryProgress\n        key={`${story.id}:${initialFocusLine ?? \"start\"}:${mode}`}\n        story={story}\n        editHrefBase={editHrefBase}\n        initialFocusLine={initialFocusLine}\n        settings={{\n          hide_questions: hideStoryQuestions,\n          show_all: false,\n          show_names: false,\n          rtl: story.learning_language_rtl,\n          highlight_name: highlight_name,\n          hideNonHighlighted: hideNonHighlighted,\n          setHighlightName: setHighlightName,\n          setHideNonHighlighted: setHideNonHighlighted,\n          show_hints: true,\n          setShowHints: () => {},\n          show_audio: true,\n          setShowAudio: () => {},\n          id: story.id,\n          show_title_page: mode === \"hard\",\n        }}\n        onEnd={onEnd}\n        onBackToOverview={goToOverview}\n        finishedLabel={finishedLabel}\n        nextStoryPreview={nextStoryPreview}\n        showFinishedPrimaryAction={showFinishedPrimaryAction}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/story/[story_id]/test/page.tsx",
    "content": "import React from \"react\";\nimport StoryWrapper from \"./story_wrapper\";\nimport { notFound } from \"next/navigation\";\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ story_id: string }>;\n}) {\n  const story_id = parseInt((await params).story_id);\n  if (!Number.isFinite(story_id)) notFound();\n\n  return <StoryWrapper storyId={story_id} />;\n}\n"
  },
  {
    "path": "src/app/(stories)/story/[story_id]/test/story_wrapper.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport StoryProgress from \"@/components/StoryProgress\";\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\n\nexport default function StoryWrapper({ storyId }: { storyId: number }) {\n  const hide_questions = useSearchParams().get(\"hide_questions\");\n  const story = useQuery(api.storyRead.getStoryByLegacyId, { storyId });\n  if (story === undefined) return null;\n  if (story === null) return <p>Story not found.</p>;\n\n  return (\n    <>\n      <StoryProgress\n        story={story}\n        onEnd={() => {}}\n        settings={{\n          hide_questions: !!hide_questions,\n          show_all: true,\n          show_names: false,\n          rtl: story.learning_language_rtl,\n          highlight_name: [],\n          hideNonHighlighted: false,\n          setHighlightName: (_name: string[]) => {},\n          setHideNonHighlighted: (_value: React.SetStateAction<boolean>) => {},\n          show_hints: true,\n          setShowHints: () => {},\n          show_audio: true,\n          setShowAudio: () => {},\n          id: story.id,\n          show_title_page: false,\n        }}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/(stories)/story/layout.tsx",
    "content": "import \"@/styles/global.css\";\n\nexport const metadata = {\n  title:\n    \"Duostories: improve your Duolingo learning with community translated Duolingo stories.\",\n  description:\n    \"Supplement your Duolingo course with community-translated Duolingo stories.\",\n  alternates: {\n    canonical: \"https://duostories.org\",\n  },\n  keywords: [\n    \"language\",\n    \"learning\",\n    \"stories\",\n    \"Duolingo\",\n    \"community\",\n    \"volunteers\",\n  ],\n};\n\nexport default function Layout({ children }: { children: React.ReactNode }) {\n  return children;\n}\n"
  },
  {
    "path": "src/app/admin/AdminDialogTrigger.tsx",
    "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport {\n  buttonInnerClassName,\n  buttonRootClassName,\n} from \"@/components/ui/button\";\nimport * as EditDialog from \"./edit_dialog\";\n\ninterface AdminDialogTriggerProps {\n  children: ReactNode;\n  isNew?: boolean;\n  onOpenChange: (open: boolean) => void;\n  open: boolean;\n}\n\nexport default function AdminDialogTrigger({\n  children,\n  isNew,\n  onOpenChange,\n  open,\n}: AdminDialogTriggerProps) {\n  return (\n    <EditDialog.Root open={open} onOpenChange={onOpenChange}>\n      <EditDialog.Trigger asChild>\n        <button\n          className={buttonRootClassName({ className: \"ml-auto\" })}\n          type=\"button\"\n        >\n          <span className={buttonInnerClassName({})}>\n            {isNew ? \"Add\" : \"Edit\"}\n          </span>\n        </button>\n      </EditDialog.Trigger>\n      {children}\n    </EditDialog.Root>\n  );\n}\n"
  },
  {
    "path": "src/app/admin/AdminHeader.tsx",
    "content": "import Link from \"next/link\";\nimport React from \"react\";\nimport { requireAdmin } from \"@/lib/userInterface\";\nimport EditorCommandPalette from \"@/app/editor/_components/editor_command_palette\";\nimport { LoggedInButtonWrappedClient } from \"@/components/login/LoggedInButtonWrappedClient\";\n\nconst adminButtonClassName =\n  \"flex min-w-[105px] cursor-pointer flex-row items-center px-3.5 text-[var(--text-color-dim)] no-underline hover:brightness-75 hover:contrast-[2.5] hover:text-[var(--text-color)] max-[1120px]:w-auto max-[1120px]:min-w-0 max-[1120px]:flex-col max-[1120px]:px-2 max-[760px]:min-w-fit max-[760px]:flex-row max-[760px]:px-2.5\";\nconst adminNavClassName =\n  \"sticky top-0 z-[200] box-border flex h-[60px] w-full flex-row items-center overflow-x-auto overflow-y-hidden border-b-2 border-[var(--header-border)] bg-[var(--body-background)] px-5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden [&>*]:flex-none max-w-[100vw] max-[1120px]:px-3 max-[760px]:h-auto max-[760px]:min-h-14 max-[760px]:gap-1 max-[760px]:py-2\";\n\nfunction AdminButton({\n  children,\n  href,\n  ...delegated\n}: {\n  children: React.ReactNode;\n  href: string;\n} & React.HTMLAttributes<HTMLAnchorElement>) {\n  return (\n    <Link className={adminButtonClassName} href={href} {...delegated}>\n      <div>\n        <img\n          alt=\"import button\"\n          src=\"/editor/icons/import.svg\"\n          className=\"mr-2.5 w-9 max-[1120px]:mr-0 max-[760px]:hidden\"\n        />\n      </div>\n      <span>{children}</span>\n    </Link>\n  );\n}\n\nexport default async function AdminHeader() {\n  await requireAdmin();\n\n  return (\n    <nav className={adminNavClassName}>\n      <b className=\"pr-2.5 max-[1120px]:hidden\">Admin Interface</b>\n      <AdminButton href=\"/admin/users\">Users</AdminButton>\n      <AdminButton href=\"/admin/languages\">Languages</AdminButton>\n      <AdminButton href=\"/admin/courses\">Courses</AdminButton>\n      <AdminButton href=\"/admin/story\">Story</AdminButton>\n\n      <div className=\"ml-auto flex items-center gap-2\">\n        <EditorCommandPalette canAdmin />\n        <LoggedInButtonWrappedClient page={\"admin\"} />\n      </div>\n    </nav>\n  );\n}\n"
  },
  {
    "path": "src/app/admin/FlagName.tsx",
    "content": "import React from \"react\";\nimport Flag from \"@/components/ui/flag\";\n\nexport default function FlagName({\n  lang,\n  languages,\n}: {\n  lang: number;\n  languages: Record<\n    number,\n    {\n      short: string;\n      flag: number | null;\n      flag_file: string | null;\n      name: string | null;\n    }\n  >;\n}) {\n  return (\n    <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n      <Flag\n        iso={languages[lang].short}\n        width={40}\n        flag={languages[lang].flag}\n        flag_file={languages[lang].flag_file}\n      />\n      {languages[lang].name}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/admin/adminDetailStyles.ts",
    "content": "export const adminDetailPageClass =\n  \"mx-auto my-6 mb-10 w-[min(860px,calc(100vw-32px))]\";\n\nexport const adminDetailCardClass =\n  \"rounded-2xl border border-[color:color-mix(in_srgb,var(--header-border)_70%,transparent)] bg-[var(--body-background)] p-5 shadow-[0_16px_38px_color-mix(in_srgb,#000_14%,transparent)]\";\n\nexport const adminDetailLabelClass =\n  \"text-left text-[var(--text-color-dim)] md:text-right\";\n"
  },
  {
    "path": "src/app/admin/adminTableStyles.ts",
    "content": "export const adminTableContainerClass =\n  \"relative isolate overflow-auto rounded-xl border border-[color:color-mix(in_srgb,var(--header-border)_60%,transparent)]\";\n\nexport const adminTableHeadCellClass =\n  \"sticky top-0 z-[1] bg-[color:color-mix(in_srgb,var(--button-background)_88%,#fff)] px-3 py-2 text-left text-sm uppercase tracking-wide text-[var(--button-color)]\";\n"
  },
  {
    "path": "src/app/admin/courses/courses.tsx",
    "content": "\"use client\";\nimport Link from \"next/link\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport Flag from \"@/components/ui/flag\";\nimport * as EditDialog from \"../edit_dialog\";\nimport React, { useState } from \"react\";\nimport Button from \"@/components/ui/button\";\nimport Tag from \"@/components/ui/badge\";\nimport Input from \"@/components/ui/input\";\nimport FlagName from \"../FlagName\";\nimport AdminDialogTrigger from \"../AdminDialogTrigger\";\nimport { usePathname, useRouter, useSearchParams } from \"next/navigation\";\nimport {\n  adminTableContainerClass,\n  adminTableHeadCellClass,\n} from \"../adminTableStyles\";\nimport { useMutation } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\n\ninterface CourseProps {\n  id: number;\n  learning_language: number;\n  from_language: number;\n  public: boolean;\n  official: boolean;\n  name: string | null;\n  about: string | null;\n  conlang: boolean;\n  short: string | null;\n  tags: string[];\n}\n\ninterface AdminLanguageProps {\n  id: number;\n  name: string;\n  short: string;\n  flag: number;\n  flag_file: string;\n  speaker: string;\n  default_text: string;\n  tts_replace: string;\n  public: boolean;\n  rtl: boolean;\n}\n\nconst statusYesClass =\n  \"inline-block min-w-10 rounded-full bg-[color:color-mix(in_srgb,#21c55d_22%,transparent)] px-2.5 py-0.5 text-center text-sm font-bold text-[#0a6b2d]\";\nconst statusNoClass =\n  \"inline-block min-w-10 rounded-full bg-[color:color-mix(in_srgb,#ef4444_20%,transparent)] px-2.5 py-0.5 text-center text-sm font-bold text-[#9b1c1c]\";\nfunction InputLanguage({\n  name,\n  label,\n  value,\n  setValue,\n  languages,\n}: {\n  name: string;\n  label: string;\n  value: number;\n  setValue: (value: number) => void;\n  languages: Record<number, AdminLanguageProps>;\n}) {\n  const [nameX, setName] = useState(languages[value]?.name || \"\");\n  const inputRef = React.useRef<HTMLInputElement>(null);\n\n  let valid = false;\n  for (const lang in languages) {\n    if (languages[lang].name.toLowerCase() === nameX.toLowerCase()) {\n      valid = true;\n      break;\n    }\n  }\n  const edited = function (e: React.ChangeEvent<HTMLInputElement> | string) {\n    let value = typeof e == \"string\" ? e : e.target?.value;\n    for (const lang in languages) {\n      if (\n        value?.toLowerCase &&\n        languages[lang].name.toLowerCase() === value.toLowerCase()\n      ) {\n        setValue(parseInt(lang));\n        //props.callback(props.name, lang);\n        break;\n      }\n    }\n    setName(value);\n  };\n\n  const language_id: number[] = [];\n  for (const key in languages) {\n    const lang = Number(key);\n    if (languages[lang].name.toLowerCase().indexOf(nameX.toLowerCase()) !== -1)\n      language_id.push(lang);\n  }\n  return (\n    <EditDialog.Fieldset>\n      <EditDialog.Label className=\"Label\" htmlFor={label}>\n        {name}\n      </EditDialog.Label>\n      <div className=\"group/lang-dropdown flex-1\">\n        <div className=\"relative flex items-baseline pl-[46px] [&>img]:absolute [&>img]:top-0 [&>img]:bottom-0 [&>img]:left-[-2px] [&>img]:my-auto\">\n          {valid ? (\n            <Flag\n              iso={languages[value].short}\n              width={40}\n              flag={languages[value].flag}\n              flag_file={languages[value].flag_file}\n            />\n          ) : (\n            <Flag width={40} flag={-2736} />\n          )}\n          <EditDialog.Input\n            ref={inputRef}\n            id={label}\n            value={nameX}\n            onChange={edited}\n          />\n          <div className=\"absolute top-[43px] left-0 z-[1] hidden max-h-[180px] w-full min-w-[160px] overflow-scroll bg-[#f1f1f1] shadow-[0_8px_16px_#0003] group-focus-within/lang-dropdown:block\">\n            {language_id.map((lang) => (\n              <button\n                key={languages[lang].id}\n                type=\"button\"\n                className=\"flex w-full items-center border-0 bg-[var(--body-background)] p-0 outline-offset-[-2px] [&_img]:my-1 [&_img]:mr-2\"\n                onClick={() => {\n                  edited(languages[lang].name);\n                }}\n              >\n                <Flag\n                  iso={languages[lang].short}\n                  width={40}\n                  flag={languages[lang].flag}\n                  flag_file={languages[lang].flag_file}\n                />\n                <div>{languages[lang].name}</div>\n              </button>\n            ))}\n          </div>\n        </div>\n      </div>\n    </EditDialog.Fieldset>\n  );\n}\n\nfunction EditCourse({\n  obj,\n  languages,\n  updateCourse,\n  is_new,\n  onShortcutClose,\n  shortcutOpen,\n}: {\n  obj: CourseProps;\n  languages: Record<number, AdminLanguageProps>;\n  updateCourse: (course: CourseProps) => void;\n  is_new: boolean;\n  onShortcutClose?: () => void;\n  shortcutOpen?: boolean;\n}) {\n  const [open, setOpen] = useState(false);\n  const [error, setError] = useState<string | undefined>(undefined);\n  const createCourseMutation = useMutation(api.adminWrite.createAdminCourse);\n  const updateCourseMutation = useMutation(api.adminWrite.updateAdminCourse);\n\n  const [short, setShort] = useState(obj.short || \"\");\n  const [fromLanguage, setFromLanguage] = useState(obj.from_language || 0);\n  const [learningLanguage, setLearningLanguage] = useState(\n    obj.learning_language || 0,\n  );\n\n  const [name, setName] = useState(obj.name || \"\");\n  const [published, setPublished] = useState(obj.public || false);\n  const [conlang, setConlang] = useState(obj.conlang || false);\n  const [tags, setTags] = useState<string[]>(obj.tags || []);\n  const [about, setAbout] = useState(obj.about || \"\");\n\n  React.useEffect(() => {\n    if (shortcutOpen) {\n      setOpen(true);\n    }\n  }, [shortcutOpen]);\n\n  function handleOpenChange(nextOpen: boolean) {\n    setOpen(nextOpen);\n    if (!nextOpen && shortcutOpen) {\n      onShortcutClose?.();\n    }\n  }\n\n  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n    const tagList = tags.map((t) => t.trim().toLowerCase()).filter(Boolean);\n\n    const data = {\n      id: obj.id,\n      from_language: fromLanguage,\n      learning_language: learningLanguage,\n      name: name,\n      public: published,\n      conlang: conlang,\n      tags: tagList,\n      about: about,\n    };\n    //console.log(\"send\", data);\n\n    try {\n      let new_data;\n      if (is_new) {\n        new_data = await createCourseMutation({\n          learning_language: data.learning_language,\n          from_language: data.from_language,\n          public: data.public,\n          name: data.name,\n          conlang: data.conlang,\n          tags: data.tags,\n          about: data.about,\n          operationKey: `course:create:${data.learning_language}:${data.from_language}:client`,\n        });\n      } else {\n        new_data = await updateCourseMutation({\n          id: data.id,\n          learning_language: data.learning_language,\n          from_language: data.from_language,\n          public: data.public,\n          name: data.name,\n          conlang: data.conlang,\n          tags: data.tags,\n          about: data.about,\n          operationKey: `course:${data.id}:admin_set:client`,\n        });\n      }\n      //console.log(\"new_data\", new_data);\n      setOpen(false);\n      if (shortcutOpen) {\n        onShortcutClose?.();\n      }\n      updateCourse(new_data);\n    } catch (e) {\n      //console.log(\"error\", e);\n      setError(\"An error occurred. Please report in Discord.\");\n    }\n  }\n\n  return (\n    <AdminDialogTrigger\n      open={open}\n      onOpenChange={handleOpenChange}\n      isNew={is_new}\n    >\n      <EditDialog.Content>\n        <EditDialog.DialogTitle>\n          {is_new ? \"Add\" : \"Edit\"} course\n        </EditDialog.DialogTitle>\n        <EditDialog.DialogDescription>\n          {is_new\n            ? \"Add a new course. Click save when you're done.\"\n            : \"Make changes to a course. Click save when you're done.\"}\n        </EditDialog.DialogDescription>\n        <form onSubmit={handleSubmit}>\n          <EditDialog.InputText\n            name={\"Name\"}\n            label={\"name\"}\n            value={name}\n            setValue={setName}\n          />\n          <EditDialog.InputText\n            name={\"Short\"}\n            label={\"short\"}\n            value={short}\n            setValue={setShort}\n          />\n          <InputLanguage\n            name={\"From language\"}\n            label={\"from_language\"}\n            value={fromLanguage}\n            setValue={setFromLanguage}\n            languages={languages}\n          />\n          <InputLanguage\n            name={\"Learning Language\"}\n            label={\"learning_language\"}\n            value={learningLanguage}\n            setValue={setLearningLanguage}\n            languages={languages}\n          />\n          <EditDialog.InputBool\n            name={\"Public\"}\n            label={\"public\"}\n            value={published}\n            setValue={setPublished}\n          />\n          <EditDialog.InputBool\n            name={\"Conlang\"}\n            label={\"conlang\"}\n            value={conlang}\n            setValue={setConlang}\n          />\n          <EditDialog.InputText\n            name={\"Tags\"}\n            label={\"tags\"}\n            value={tags.join(\",\")}\n            setValue={(t) => setTags(t.split(\",\").map((t) => t.trim()))}\n          />\n          <EditDialog.InputTextArea\n            name={\"About\"}\n            label={\"about\"}\n            value={about}\n            setValue={setAbout}\n          />\n          <div className=\"mt-6 flex flex-wrap justify-between gap-2\">\n            {error ? (\n              <div className=\"rounded-lg bg-[var(--error-red)] p-2.5 text-white\">\n                An error occurred.\n              </div>\n            ) : (\n              <div></div>\n            )}\n            <Button>Save changes</Button>\n          </div>\n        </form>\n      </EditDialog.Content>\n    </AdminDialogTrigger>\n  );\n}\n\nfunction TableRow({\n  course,\n  languages,\n  updateCourse,\n  isShortcutOpen,\n  onShortcutClose,\n}: {\n  course: CourseProps;\n  languages: Record<number, AdminLanguageProps>;\n  updateCourse: (course: CourseProps) => void;\n  isShortcutOpen?: boolean;\n  onShortcutClose?: () => void;\n}) {\n  const refRow = React.useRef<HTMLTableRowElement>(null);\n\n  function updateCourseWrapper(new_course: CourseProps) {\n    const frames = [\n      { opacity: 0, filter: \"blur(10px) saturate(0)\" },\n      { opacity: 1, filter: \"\" },\n    ];\n    const attributes: (keyof CourseProps)[] = [\n      \"id\",\n      \"short\",\n      \"learning_language\",\n      \"from_language\",\n      \"public\",\n      \"name\",\n      \"conlang\",\n      \"tags\",\n      \"about\",\n    ];\n\n    function check_equal(attribute: keyof CourseProps) {\n      if (attribute === \"tags\") {\n        return (\n          new_course[attribute].sort().join(\",\") ===\n          course[attribute].sort().join(\",\")\n        );\n      }\n      return new_course[attribute] === course[attribute];\n    }\n\n    for (let i = 0; i < attributes.length; i++) {\n      if (!check_equal(attributes[i])) {\n        /*console.log(\n          \"update\",\n          attributes[i],\n          new_course[attributes[i]],\n          course[attributes[i]],\n        );*/\n        if (refRow.current)\n          refRow.current.children[i].animate(frames, {\n            duration: 1000,\n            iterations: 1,\n          });\n      }\n    }\n    updateCourse(new_course);\n  }\n\n  return (\n    <tr\n      ref={refRow}\n      className=\"odd:bg-[var(--body-background)] even:bg-[color:color-mix(in_srgb,var(--body-background-faint)_74%,transparent)] hover:brightness-95\"\n    >\n      <td className=\"px-4 py-2.5\">{course.id}</td>\n      <td className=\"px-3 py-2.5\">\n        {<Link href={\"/\" + course.short}>{course.short}</Link>}\n      </td>\n      <td className=\"px-3 py-2.5\">\n        <FlagName lang={course.learning_language} languages={languages} />\n      </td>\n      <td className=\"px-3 py-2.5\">\n        <FlagName lang={course.from_language} languages={languages} />\n      </td>\n      <td className=\"px-3 py-2.5 text-center\">\n        <span className={course.public ? statusYesClass : statusNoClass}>\n          {course.public ? \"Yes\" : \"No\"}\n        </span>\n      </td>\n      <td className=\"px-3 py-2.5\">{course.name}</td>\n      <td className=\"px-3 py-2.5 text-center\">\n        <span className={course.conlang ? statusYesClass : statusNoClass}>\n          {course.conlang ? \"Yes\" : \"No\"}\n        </span>\n      </td>\n      <td className=\"px-3 py-2.5\">\n        <div className=\"flex flex-wrap gap-1.5\">\n          {course.tags.map((d) => (\n            <Tag key={d}>{d}</Tag>\n          ))}\n        </div>\n      </td>\n      <td className=\"px-3 py-2.5\">\n        <div className=\"max-w-[260px] overflow-hidden text-ellipsis whitespace-nowrap\">\n          {course.about}\n        </div>\n      </td>\n      <td className=\"sticky right-0 z-[2] min-w-36 bg-inherit px-4 py-2.5 text-right whitespace-nowrap\">\n        <EditCourse\n          obj={course}\n          languages={languages}\n          updateCourse={updateCourseWrapper}\n          is_new={false}\n          shortcutOpen={isShortcutOpen}\n          onShortcutClose={onShortcutClose}\n        />\n      </td>\n    </tr>\n  );\n}\n\nexport function CourseList({\n  all_courses,\n  languages,\n}: {\n  all_courses: CourseProps[];\n  languages: AdminLanguageProps[];\n}) {\n  const pathname = usePathname();\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const [search, setSearch] = React.useState(\"\");\n  const [my_courses, setMyCourses] = React.useState(all_courses);\n  const editCourseValue = searchParams.get(\"editCourse\");\n  const editCourseId = editCourseValue\n    ? Number.parseInt(editCourseValue, 10)\n    : Number.NaN;\n  const addCourseShortcut = searchParams.get(\"addCourse\") === \"1\";\n\n  function clearShortcut() {\n    const nextParams = new URLSearchParams(searchParams.toString());\n    nextParams.delete(\"editCourse\");\n    nextParams.delete(\"addCourse\");\n    const nextUrl =\n      nextParams.size > 0 ? `${pathname}?${nextParams}` : pathname;\n    router.replace(nextUrl, { scroll: false });\n  }\n\n  React.useEffect(() => {\n    setMyCourses(all_courses);\n  }, [all_courses]);\n\n  function updateCourse(course: CourseProps) {\n    setMyCourses(my_courses.map((c) => (c.id === course.id ? course : c)));\n  }\n\n  if (languages === undefined || my_courses === undefined) return <Spinner />;\n\n  const languages_id: Record<number, AdminLanguageProps> = {};\n  for (const l of languages) languages_id[l.id] = l;\n\n  let filtered_courses: CourseProps[] = [];\n  if (search === \"\") filtered_courses = my_courses;\n  else {\n    for (const course of my_courses) {\n      if (!languages_id[course.learning_language]) continue;\n      if (\n        languages_id[course.learning_language].name\n          .toLowerCase()\n          .indexOf(search.toLowerCase()) !== -1 ||\n        languages_id[course.from_language].name\n          .toLowerCase()\n          .indexOf(search.toLowerCase()) !== -1\n      ) {\n        filtered_courses.push(course);\n      }\n    }\n  }\n\n  return (\n    <div className=\"relative isolate mx-auto my-6 mb-9 box-border w-full max-w-[min(1240px,calc(100vw-48px))] rounded-2xl border border-[color:color-mix(in_srgb,var(--header-border)_70%,transparent)] bg-[var(--body-background)] p-5 shadow-[0_16px_38px_color-mix(in_srgb,#000_14%,transparent)]\">\n      <div className=\"flex flex-wrap items-end justify-between gap-4 px-0.5 pb-3\">\n        <Input\n          label={\"Search\"}\n          value={search}\n          onChange={(e) => setSearch(e.target.value)}\n        />\n        <EditCourse\n          obj={{\n            id: 0,\n            learning_language: -1,\n            from_language: 1,\n            official: false,\n            short: \"\",\n            name: \"\",\n            public: false,\n            about: \"\",\n            tags: [],\n            conlang: false,\n          }}\n          is_new={true}\n          languages={languages_id}\n          updateCourse={updateCourse}\n          shortcutOpen={addCourseShortcut}\n          onShortcutClose={clearShortcut}\n        />\n      </div>\n      <div className={adminTableContainerClass}>\n        <table\n          id=\"story_list\"\n          data-cy=\"story_list\"\n          className=\"js-sort-table js-sort-5 js-sort-desc w-full min-w-[980px] border-collapse\"\n          data-js-sort-table=\"true\"\n        >\n          <thead>\n            <tr>\n              <th className={adminTableHeadCellClass}></th>\n              <th className={adminTableHeadCellClass}></th>\n              <th className={adminTableHeadCellClass} data-js-sort-colnum=\"0\">\n                learning_language\n              </th>\n              <th className={adminTableHeadCellClass} data-js-sort-colnum=\"1\">\n                from_language\n              </th>\n              <th className={adminTableHeadCellClass} data-js-sort-colnum=\"1\">\n                public\n              </th>\n              <th className={adminTableHeadCellClass} data-js-sort-colnum=\"2\">\n                name\n              </th>\n              <th className={adminTableHeadCellClass} data-js-sort-colnum=\"2\">\n                conlang\n              </th>\n              <th className={adminTableHeadCellClass} data-js-sort-colnum=\"2\">\n                tags\n              </th>\n              <th className={adminTableHeadCellClass} data-js-sort-colnum=\"3\">\n                about\n              </th>\n              <th\n                className={`${adminTableHeadCellClass} right-0 z-[3] min-w-36`}\n                data-js-sort-colnum=\"4\"\n              ></th>\n            </tr>\n          </thead>\n          <tbody>\n            {filtered_courses.map((course) => (\n              <TableRow\n                course={course}\n                key={course.id}\n                languages={languages_id}\n                updateCourse={updateCourse}\n                isShortcutOpen={course.id === editCourseId}\n                onShortcutClose={clearShortcut}\n              />\n            ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/admin/courses/page.tsx",
    "content": "import CourseListClient from \"./page_client\";\n\nexport default function Page() {\n  return <CourseListClient />;\n}\n"
  },
  {
    "path": "src/app/admin/courses/page_client.tsx",
    "content": "\"use client\";\n\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport { CourseList } from \"./courses\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\nexport default function CourseListClient() {\n  const data = useQuery(api.adminData.getAdminCourses, {});\n\n  if (data === undefined) return <Spinner />;\n  return <CourseList all_courses={data.courses} languages={data.languages} />;\n}\n"
  },
  {
    "path": "src/app/admin/edit_dialog.tsx",
    "content": "import React from \"react\";\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription as UiDialogDescription,\n  DialogTitle as UiDialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport StandardButton from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport const Root = Dialog;\nexport const Trigger = DialogTrigger;\n\nexport function Content({ children }: { children: React.ReactNode }) {\n  return (\n    <DialogContent showCloseButton={false}>\n      <div className=\"relative overflow-x-hidden p-6 max-[700px]:p-4\">\n        {children}\n        <DialogClose asChild>\n          <button\n            aria-label=\"Close\"\n            className=\"absolute right-0 top-0 m-2 grid place-content-center rounded-full bg-transparent p-2\"\n          >\n            <span\n              aria-hidden=\"true\"\n              className=\"inline-block h-[18px] w-[18px] align-middle\"\n              style={{\n                backgroundImage:\n                  \"url(//d35aaqx5ub95lt.cloudfront.net/images/icon-sprite8.svg)\",\n                backgroundPosition: \"-373px -154px\",\n              }}\n            />\n          </button>\n        </DialogClose>\n      </div>\n    </DialogContent>\n  );\n}\n\nexport function DialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof UiDialogTitle>) {\n  return <UiDialogTitle className={cn(\"mb-4\", className)} {...props} />;\n}\n\nexport function DialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof UiDialogDescription>) {\n  return (\n    <UiDialogDescription className={cn(\"mt-2.5 mb-5\", className)} {...props} />\n  );\n}\n\nexport const Button = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<typeof StandardButton>\n>(function EditDialogButton(props, ref) {\n  return <StandardButton ref={ref} {...props} />;\n});\n\nexport function Fieldset({\n  className,\n  ...props\n}: React.FieldsetHTMLAttributes<HTMLFieldSetElement>) {\n  return (\n    <fieldset\n      className={cn(\n        \"mb-2.5 grid w-full grid-cols-[110px_minmax(0,1fr)] items-center gap-x-4 gap-y-2 border-0 p-0 max-[700px]:grid-cols-1 max-[700px]:gap-y-1.5\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function Label({\n  className,\n  ...props\n}: React.LabelHTMLAttributes<HTMLLabelElement>) {\n  return (\n    <label\n      className={cn(\n        \"overflow-hidden text-right text-base whitespace-nowrap text-ellipsis max-[700px]:text-left\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nconst dialogInputClassName =\n  \"w-full min-w-0 rounded-2xl border-2 border-[var(--input-border)] bg-[var(--input-background)] px-4 py-2.5 text-base text-[var(--text-color)] outline-none\";\n\nexport const Input = React.forwardRef<\n  HTMLInputElement,\n  React.InputHTMLAttributes<HTMLInputElement>\n>(function Input({ className, type, ...props }, ref) {\n  if (type === \"checkbox\") {\n    return (\n      <input\n        ref={ref}\n        type=\"checkbox\"\n        className={cn(\n          \"h-5 w-5 min-w-5 justify-self-start rounded border-2 border-[var(--input-border)] bg-[var(--input-background)] p-0\",\n          className,\n        )}\n        {...props}\n      />\n    );\n  }\n\n  return (\n    <input\n      ref={ref}\n      type={type}\n      className={cn(dialogInputClassName, className)}\n      {...props}\n    />\n  );\n});\n\nfunction InputArea({\n  className,\n  ...props\n}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {\n  return (\n    <textarea\n      className={cn(\n        \"w-full min-w-0 rounded-2xl border-2 border-[var(--input-border)] bg-[var(--input-background)] px-4 py-2.5 text-base text-[var(--text-color)] outline-none\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function InputText({\n  name,\n  label,\n  value,\n  setValue,\n}: {\n  name: string;\n  label: string;\n  value: string;\n  setValue: (x: string) => void;\n}) {\n  return (\n    <Fieldset>\n      <Label htmlFor={label}>{name}</Label>\n      <Input\n        id={label}\n        value={value}\n        onChange={(e) => setValue(e.target.value)}\n      />\n    </Fieldset>\n  );\n}\n\nexport function InputTextArea({\n  name,\n  label,\n  value,\n  setValue,\n}: {\n  name: string;\n  label: string;\n  value: string;\n  setValue: (x: string) => void;\n}) {\n  return (\n    <Fieldset>\n      <Label htmlFor={label}>{name}</Label>\n      <InputArea\n        id={label}\n        value={value}\n        onChange={(e) => setValue(e.target.value)}\n      />\n    </Fieldset>\n  );\n}\n\nexport function InputBool({\n  name,\n  label,\n  value,\n  setValue,\n}: {\n  name: string;\n  label: string;\n  value: boolean;\n  setValue: (x: boolean) => void;\n}) {\n  return (\n    <Fieldset>\n      <Label htmlFor={label}>{name}</Label>\n      <Input\n        type=\"checkbox\"\n        id={label}\n        checked={value}\n        onChange={(e) => setValue(e.target.checked)}\n      />\n    </Fieldset>\n  );\n}\n"
  },
  {
    "path": "src/app/admin/languages/language_list.tsx",
    "content": "\"use client\";\n\nimport { useInput } from \"@/lib/hooks\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport Flag from \"@/components/ui/flag\";\nimport Input from \"@/components/ui/input\";\nimport React, { useState } from \"react\";\nimport { useMutation } from \"convex/react\";\nimport { usePathname, useRouter, useSearchParams } from \"next/navigation\";\nimport { api } from \"@convex/_generated/api\";\nimport * as EditDialog from \"../edit_dialog\";\nimport Button from \"@/components/ui/button\";\nimport AdminDialogTrigger from \"../AdminDialogTrigger\";\nimport {\n  adminTableContainerClass,\n  adminTableHeadCellClass,\n} from \"../adminTableStyles\";\n\ninterface Language {\n  id?: number;\n  name: string;\n  short: string;\n  flag: number;\n  flag_file: string;\n  speaker: string;\n  rtl: boolean;\n}\n\ninterface EditLanguageProps {\n  obj: Language;\n  updateLanguage: (lang: Language) => void;\n  is_new?: boolean;\n  onShortcutClose?: () => void;\n  shortcutOpen?: boolean;\n}\n\nfunction EditLanguage({\n  obj,\n  updateLanguage,\n  is_new,\n  onShortcutClose,\n  shortcutOpen,\n}: EditLanguageProps) {\n  const [open, setOpen] = useState(false);\n  const [error, setError] = useState<string | undefined>(undefined);\n  const createLanguageMutation = useMutation(\n    api.adminWrite.createAdminLanguage,\n  );\n  const updateLanguageMutation = useMutation(\n    api.adminWrite.updateAdminLanguage,\n  );\n\n  const [name, setName] = useState(obj.name);\n  const [short, setShort] = useState(obj.short);\n  const [flag, setFlag] = useState(String(obj.flag));\n  const [flag_file, setFlagFile] = useState(obj.flag_file);\n  const [speaker, setSpeaker] = useState(obj.speaker);\n  const [rtl, setRTL] = useState(obj.rtl);\n\n  React.useEffect(() => {\n    if (shortcutOpen) {\n      setOpen(true);\n    }\n  }, [shortcutOpen]);\n\n  function handleOpenChange(nextOpen: boolean) {\n    setOpen(nextOpen);\n    if (!nextOpen && shortcutOpen) {\n      onShortcutClose?.();\n    }\n  }\n\n  async function send() {\n    const data: Language = {\n      id: obj.id,\n      name,\n      short,\n      flag: Number.parseInt(flag, 10) || 0,\n      flag_file,\n      speaker,\n      rtl,\n    };\n\n    try {\n      let newData;\n      if (data.id !== undefined) {\n        newData = await updateLanguageMutation({\n          id: data.id,\n          name: data.name,\n          short: data.short,\n          flag: data.flag,\n          flag_file: data.flag_file,\n          speaker: data.speaker,\n          rtl: data.rtl,\n          operationKey: `language:${data.id}:admin_set:client`,\n        });\n      } else {\n        newData = await createLanguageMutation({\n          name: data.name,\n          short: data.short,\n          flag: data.flag,\n          flag_file: data.flag_file,\n          speaker: data.speaker,\n          rtl: data.rtl,\n          operationKey: `language:create:${data.short}:client`,\n        });\n      }\n      setOpen(false);\n      if (shortcutOpen) {\n        onShortcutClose?.();\n      }\n      updateLanguage(newData);\n    } catch {\n      setError(\"An error occurred. Please report in Discord.\");\n    }\n  }\n\n  return (\n    <AdminDialogTrigger\n      open={open}\n      onOpenChange={handleOpenChange}\n      isNew={is_new}\n    >\n      <EditDialog.Content>\n        <EditDialog.DialogTitle>\n          {is_new ? \"Add\" : \"Edit\"} Language\n        </EditDialog.DialogTitle>\n        <EditDialog.DialogDescription>\n          {is_new\n            ? \"Add a new language. Click save when you're done.\"\n            : \"Make changes to a language. Click save when you're done.\"}\n        </EditDialog.DialogDescription>\n        <EditDialog.InputText\n          name=\"Name\"\n          label=\"name\"\n          value={name}\n          setValue={setName}\n        />\n        <EditDialog.InputText\n          name=\"Short\"\n          label=\"short\"\n          value={short}\n          setValue={setShort}\n        />\n        <EditDialog.InputText\n          name=\"Flag\"\n          label=\"flag\"\n          value={flag}\n          setValue={setFlag}\n        />\n        <EditDialog.InputText\n          name=\"Flag File\"\n          label=\"flag_file\"\n          value={flag_file}\n          setValue={setFlagFile}\n        />\n        <EditDialog.InputText\n          name=\"Default Voice\"\n          label=\"speaker\"\n          value={speaker}\n          setValue={setSpeaker}\n        />\n        <EditDialog.InputBool\n          name=\"RTL\"\n          label=\"rtl\"\n          value={rtl}\n          setValue={setRTL}\n        />\n        <div className=\"mt-6 flex flex-wrap justify-between gap-2\">\n          {error ? (\n            <div className=\"rounded-lg bg-[var(--error-red)] p-2.5 text-white\">\n              An error occurred.\n            </div>\n          ) : (\n            <div></div>\n          )}\n          <Button onClick={send}>Save changes</Button>\n        </div>\n      </EditDialog.Content>\n    </AdminDialogTrigger>\n  );\n}\n\ninterface TableRowProps {\n  lang: Language;\n  updateLanguage: (lang: Language) => void;\n  isShortcutOpen?: boolean;\n  onShortcutClose?: () => void;\n}\n\nfunction TableRow({\n  lang,\n  updateLanguage,\n  isShortcutOpen,\n  onShortcutClose,\n}: TableRowProps) {\n  const refRow = React.useRef<HTMLTableRowElement>(null);\n\n  function updateLanguageWrapper(newCourse: Language) {\n    const frames = [\n      { opacity: 0, filter: \"blur(10px) saturate(0)\" },\n      { opacity: 1, filter: \"\" },\n    ];\n    const attributes = [\n      \"id\",\n      \"\",\n      \"name\",\n      \"short\",\n      \"flag\",\n      \"flag_file\",\n      \"speaker\",\n      \"rtl\",\n    ];\n\n    function checkEqual(attribute: string) {\n      const newVal = (newCourse as unknown as Record<string, unknown>)[\n        attribute\n      ];\n      const oldVal = (lang as unknown as Record<string, unknown>)[attribute];\n      return newVal === oldVal;\n    }\n\n    for (let i = 0; i < attributes.length; i++) {\n      if (!checkEqual(attributes[i]) && refRow.current?.children[i]) {\n        (refRow.current.children[i] as HTMLElement).animate(frames, {\n          duration: 1000,\n          iterations: 1,\n        });\n      }\n    }\n    updateLanguage(newCourse);\n  }\n\n  return (\n    <tr\n      ref={refRow}\n      className=\"odd:bg-[var(--body-background)] even:bg-[color:color-mix(in_srgb,var(--body-background-faint)_74%,transparent)] hover:brightness-95\"\n    >\n      <td className=\"px-4 py-2.5\">{lang.id}</td>\n      <td className=\"px-3 py-2.5\">\n        <Flag\n          iso={lang.short}\n          width={40}\n          flag={lang.flag}\n          flag_file={lang.flag_file}\n        />\n      </td>\n      <td className=\"px-3 py-2.5\">{lang.name}</td>\n      <td className=\"px-3 py-2.5\">{lang.short}</td>\n      <td className=\"px-3 py-2.5\">{lang.flag}</td>\n      <td className=\"px-3 py-2.5\">{lang.flag_file}</td>\n      <td className=\"px-3 py-2.5\">{lang.speaker}</td>\n      <td className=\"px-3 py-2.5\">{String(lang.rtl)}</td>\n      <td className=\"px-4 py-2.5 text-right\">\n        <EditLanguage\n          obj={lang}\n          updateLanguage={updateLanguageWrapper}\n          shortcutOpen={isShortcutOpen}\n          onShortcutClose={onShortcutClose}\n        />\n      </td>\n    </tr>\n  );\n}\n\ninterface LanguageListProps {\n  all_languages: Language[];\n}\n\nexport default function LanguageList({ all_languages }: LanguageListProps) {\n  const pathname = usePathname();\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const [search, setSearch] = useInput(\"\");\n  const [myLangs, setMyLangs] = useState<Language[]>(all_languages);\n  const editLanguageValue = searchParams.get(\"editLanguage\");\n  const editLanguageId = editLanguageValue\n    ? Number.parseInt(editLanguageValue, 10)\n    : Number.NaN;\n  const addLanguageShortcut = searchParams.get(\"addLanguage\") === \"1\";\n\n  function clearShortcut() {\n    const nextParams = new URLSearchParams(searchParams.toString());\n    nextParams.delete(\"editLanguage\");\n    nextParams.delete(\"addLanguage\");\n    const nextUrl =\n      nextParams.size > 0 ? `${pathname}?${nextParams}` : pathname;\n    router.replace(nextUrl, { scroll: false });\n  }\n\n  React.useEffect(() => {\n    setMyLangs(all_languages);\n  }, [all_languages]);\n\n  function updateLanguage(course: Language) {\n    setMyLangs(myLangs.map((c) => (c.id === course.id ? course : c)));\n  }\n\n  if (all_languages === undefined) return <Spinner />;\n\n  const filteredLanguages =\n    search === \"\"\n      ? myLangs\n      : myLangs.filter((language) =>\n          language.name.toLowerCase().includes(search.toLowerCase()),\n        );\n\n  return (\n    <div className=\"relative isolate mx-auto my-6 mb-9 box-border w-full max-w-[min(1240px,calc(100vw-48px))] rounded-2xl border border-[color:color-mix(in_srgb,var(--header-border)_70%,transparent)] bg-[var(--body-background)] p-5 shadow-[0_16px_38px_color-mix(in_srgb,#000_14%,transparent)]\">\n      <div className=\"flex flex-wrap items-end justify-between gap-4 px-0.5 pb-3\">\n        <Input label=\"Search\" value={search} onChange={setSearch} />\n        <EditLanguage\n          obj={{\n            name: \"\",\n            short: \"\",\n            flag: 0,\n            flag_file: \"\",\n            speaker: \"\",\n            rtl: false,\n          }}\n          is_new={true}\n          updateLanguage={updateLanguage}\n          shortcutOpen={addLanguageShortcut}\n          onShortcutClose={clearShortcut}\n        />\n      </div>\n      <div className={adminTableContainerClass}>\n        <table\n          id=\"story_list\"\n          data-cy=\"story_list\"\n          className=\"w-full min-w-[940px] border-collapse\"\n          data-js-sort-table=\"true\"\n        >\n          <thead>\n            <tr>\n              {[\n                \"ID\",\n                \"\",\n                \"Name\",\n                \"ISO\",\n                \"Duo Flag\",\n                \"Flag File\",\n                \"Default Voice\",\n                \"RTL\",\n                \"\",\n              ].map((header, idx) => (\n                <th\n                  key={`${header}-${idx}`}\n                  className={adminTableHeadCellClass}\n                >\n                  {header}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            {filteredLanguages.map((lang) => (\n              <TableRow\n                key={lang.id}\n                lang={lang}\n                updateLanguage={updateLanguage}\n                isShortcutOpen={lang.id === editLanguageId}\n                onShortcutClose={clearShortcut}\n              />\n            ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/admin/languages/page.tsx",
    "content": "import LanguageListClient from \"./page_client\";\n\nexport default function Page() {\n  return <LanguageListClient />;\n}\n"
  },
  {
    "path": "src/app/admin/languages/page_client.tsx",
    "content": "\"use client\";\n\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport LanguageList from \"./language_list\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\nexport default function LanguageListClient() {\n  const languages = useQuery(api.adminData.getAdminLanguages, {});\n\n  if (languages === undefined) return <Spinner />;\n  return <LanguageList all_languages={languages} />;\n}\n"
  },
  {
    "path": "src/app/admin/layout.tsx",
    "content": "import React from \"react\";\nimport AdminHeader from \"./AdminHeader\";\n\nexport default async function Layout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <div>\n      <AdminHeader />\n      <div className=\"overflow-x-hidden\">{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/admin/page.tsx",
    "content": "export default function Page({}) {\n  return (\n    <div className=\"mx-auto my-6 w-[min(860px,calc(100vw-32px))] rounded-2xl border border-[color:color-mix(in_srgb,var(--header-border)_70%,transparent)] bg-[var(--body-background)] p-6 shadow-[0_16px_38px_color-mix(in_srgb,#000_14%,transparent)]\">\n      <div className=\"text-xl font-semibold\">Admin</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/admin/story/[story_id]/actions.ts",
    "content": "\"use server\";\n\nimport { getUser, isAdmin } from \"@/lib/userInterface\";\nimport { fetchAuthMutation } from \"@/lib/auth-server\";\nimport { api } from \"@convex/_generated/api\";\n\nasync function requireAdmin() {\n  const token = await getUser();\n  if (!isAdmin(token)) {\n    throw new Error(\"You need to be a registered admin.\");\n  }\n}\n\nexport async function togglePublished(\n  id: number,\n  _currentPublic: boolean,\n): Promise<void> {\n  await requireAdmin();\n\n  await fetchAuthMutation(api.adminStoryWrite.togglePublished, {\n    legacyStoryId: id,\n    operationKey: `story:${id}:admin_toggle_published:action`,\n  });\n}\n\nexport async function removeApproval(\n  storyId: number,\n  approval_id: number,\n): Promise<void> {\n  await requireAdmin();\n\n  await fetchAuthMutation(api.adminStoryWrite.removeApproval, {\n    legacyStoryId: storyId,\n    legacyApprovalId: approval_id,\n    operationKey: `story_approval:${approval_id}:admin_delete:action`,\n  });\n}\n"
  },
  {
    "path": "src/app/admin/story/[story_id]/page.tsx",
    "content": "import StoryDisplay from \"./story_display\";\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ story_id: string }>;\n}) {\n  const storyId = Number.parseInt((await params).story_id, 10);\n  if (!Number.isFinite(storyId)) {\n    return <div>Invalid story id.</div>;\n  }\n\n  return <StoryDisplay storyId={storyId} />;\n}\n"
  },
  {
    "path": "src/app/admin/story/[story_id]/story_display.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport Switch from \"@/components/ui/switch\";\nimport Button from \"@/components/ui/button\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport {\n  adminDetailCardClass,\n  adminDetailLabelClass,\n  adminDetailPageClass,\n} from \"../../adminDetailStyles\";\nimport {\n  togglePublished,\n  removeApproval as removeApprovalAction,\n} from \"./actions\";\n\nexport default function StoryDisplay({ storyId }: { storyId: number }) {\n  const story = useQuery(api.adminData.getAdminStoryByLegacyId, {\n    legacyStoryId: storyId,\n  });\n\n  if (story === undefined) {\n    return (\n      <div className={adminDetailPageClass}>\n        <div className={adminDetailCardClass}>\n          <Spinner />\n        </div>\n      </div>\n    );\n  }\n\n  if (!story) {\n    return (\n      <div className={adminDetailPageClass}>\n        <div className={adminDetailCardClass}>Story not found.</div>\n      </div>\n    );\n  }\n\n  const storyData = story;\n\n  async function changePublished() {\n    await togglePublished(storyData.id, storyData.public);\n  }\n\n  async function deleteApproval(approvalId: number) {\n    await removeApprovalAction(storyData.id, approvalId);\n  }\n\n  function formatApprovalDate(value: unknown) {\n    const asNumber =\n      typeof value === \"number\" ? value : Number(String(value ?? \"\").trim());\n    const date = Number.isFinite(asNumber)\n      ? new Date(asNumber)\n      : new Date(String(value ?? \"\"));\n    if (Number.isNaN(date.getTime())) return String(value ?? \"\");\n    return new Intl.DateTimeFormat(undefined, {\n      dateStyle: \"medium\",\n      timeStyle: \"short\",\n    }).format(date);\n  }\n\n  return (\n    <div className={adminDetailPageClass}>\n      <div className={adminDetailCardClass}>\n        <div className=\"mb-3.5 flex flex-wrap items-center justify-between gap-3\">\n          <Link\n            className=\"whitespace-nowrap underline underline-offset-2\"\n            href=\"/admin/story\"\n          >\n            Back to Story Search\n          </Link>\n          <Link\n            href={`/editor/course/${storyData.short}/story/${storyData.id}`}\n          >\n            <Button>Edit Story</Button>\n          </Link>\n        </div>\n\n        <div className=\"mb-3 flex flex-col items-start gap-4 md:flex-row md:items-center\">\n          <img\n            className=\"rounded-xl border border-[color:color-mix(in_srgb,var(--header-border)_70%,transparent)] bg-[color:color-mix(in_srgb,var(--body-background-faint)_85%,transparent)]\"\n            alt=\"story image\"\n            src={`https://stories-cdn.duolingo.com/image/${storyData.image}.svg`}\n            width={132}\n            height={132}\n          />\n          <div>\n            <h1 className=\"m-0 text-3xl leading-tight\">{storyData.name}</h1>\n            <div className=\"mt-3 mb-5 grid grid-cols-1 gap-x-3 gap-y-2 md:grid-cols-[160px_minmax(0,1fr)]\">\n              <div className={adminDetailLabelClass}>Story ID</div>\n              <div className=\"min-w-0 break-words\">{storyData.id}</div>\n              <div className={adminDetailLabelClass}>Legacy ID</div>\n              <div className=\"min-w-0 break-words\">{storyId}</div>\n              <div className={adminDetailLabelClass}>Published</div>\n              <div className=\"min-w-0 break-words\">\n                <span className=\"inline-flex items-center gap-2\">\n                  <Switch\n                    checked={storyData.public}\n                    onClick={changePublished}\n                  />\n                  {storyData.public ? \"Yes\" : \"No\"}\n                </span>\n              </div>\n              <div className={adminDetailLabelClass}>Course</div>\n              <div className=\"min-w-0 break-words\">\n                <Link\n                  className=\"underline underline-offset-2\"\n                  href={`/editor/course/${storyData.short}`}\n                >\n                  {storyData.short}\n                </Link>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <h2 className=\"my-5 text-xl\">Approvals</h2>\n        {storyData.approvals.length === 0 ? (\n          <div>No approvals.</div>\n        ) : (\n          <ul className=\"m-0 list-none overflow-hidden rounded-xl border border-[color:color-mix(in_srgb,var(--header-border)_70%,transparent)] p-0\">\n            {storyData.approvals.map((approval, index) => (\n              <li\n                key={approval.id}\n                className={`grid grid-cols-[minmax(0,1fr)_auto] gap-2 px-3 py-2.5 ${\n                  index % 2 === 0\n                    ? \"bg-[var(--body-background)]\"\n                    : \"bg-[color:color-mix(in_srgb,var(--body-background-faint)_72%,transparent)]\"\n                }`}\n              >\n                <span>\n                  {formatApprovalDate(approval.date)} - {approval.name}\n                </span>\n                <Button\n                  className=\"mt-0\"\n                  variant=\"destructive\"\n                  size=\"sm\"\n                  type=\"button\"\n                  onClick={() => deleteApproval(approval.id)}\n                >\n                  Remove\n                </Button>\n              </li>\n            ))}\n          </ul>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/admin/story/page.tsx",
    "content": "\"use client\";\n\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport Input from \"@/components/ui/input\";\nimport Button from \"@/components/ui/button\";\n\nexport default function Page() {\n  const [id, setId] = useState(\"\");\n  const router = useRouter();\n\n  function go() {\n    const nextId = id.trim();\n    if (!nextId) return;\n    router.push(`/admin/story/${nextId}`);\n  }\n\n  return (\n    <div className=\"mx-auto my-6 mb-10 w-[min(860px,calc(100vw-32px))]\">\n      <div className=\"relative isolate mx-auto my-6 mb-9 box-border w-full rounded-2xl border border-[color:color-mix(in_srgb,var(--header-border)_70%,transparent)] bg-[var(--body-background)] p-5 shadow-[0_16px_38px_color-mix(in_srgb,#000_14%,transparent)]\">\n        <div className=\"flex flex-wrap items-end justify-between gap-4 px-0.5 pb-3\">\n          <div className=\"flex flex-wrap items-center gap-3\">\n            <label className=\"whitespace-nowrap font-bold\" htmlFor=\"story-id\">\n              Story ID\n            </label>\n            <div className=\"w-full max-w-[320px] flex-[0_1_320px]\">\n              <Input\n                id=\"story-id\"\n                value={id}\n                inputMode=\"numeric\"\n                placeholder=\"e.g. 12345\"\n                onChange={(e) => setId(e.target.value)}\n                onKeyDown={(e) => {\n                  if (e.key === \"Enter\") go();\n                }}\n              />\n            </div>\n          </div>\n          <Button onClick={go} disabled={!id.trim()}>\n            Open Story\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/admin/users/[user_id]/actions.ts",
    "content": "\"use server\";\n\nimport { z } from \"zod\";\nimport { fetchAuthMutation } from \"@/lib/auth-server\";\nimport { api } from \"@convex/_generated/api\";\n\nconst OkSchema = z.literal(\"ok\");\n\nconst SetActivatedInput = z.object({\n  id: z.number(),\n  activated: z\n    .union([z.boolean(), z.number()])\n    .transform((v) => (typeof v === \"number\" ? v !== 0 : v)),\n});\n\nconst SetWriteInput = z.object({\n  id: z.number(),\n  write: z\n    .union([z.boolean(), z.number()])\n    .transform((v) => (typeof v === \"number\" ? v !== 0 : v)),\n});\n\nconst DeleteUserInput = z.object({ id: z.number() });\n\nexport async function setUserActivatedAction(input: unknown) {\n  const parsed = SetActivatedInput.parse(input);\n  await fetchAuthMutation(api.adminData.setAdminUserActivated, {\n    id: parsed.id,\n    activated: parsed.activated,\n  });\n  return OkSchema.parse(\"ok\");\n}\n\nexport async function setUserWriteAction(input: unknown) {\n  const parsed = SetWriteInput.parse(input);\n  await fetchAuthMutation(api.adminData.setAdminUserWrite, {\n    id: parsed.id,\n    write: parsed.write,\n  });\n  return OkSchema.parse(\"ok\");\n}\n\nexport async function setUserDeleteAction(input: unknown) {\n  const parsed = DeleteUserInput.parse(input);\n  await fetchAuthMutation(api.adminData.setAdminUserDelete, {\n    id: parsed.id,\n  });\n  return OkSchema.parse(\"ok\");\n}\n"
  },
  {
    "path": "src/app/admin/users/[user_id]/page.tsx",
    "content": "import { notFound } from \"next/navigation\";\nimport UserDisplay from \"./user_display\";\nimport { UserSchema } from \"./schema\";\nimport { fetchAuthQuery } from \"@/lib/auth-server\";\nimport { api } from \"@convex/_generated/api\";\n\nasync function user_properties(id: string) {\n  const parsedId = Number.parseInt(id, 10);\n  if (!Number.isFinite(parsedId)) return undefined;\n  const match = await fetchAuthQuery(api.adminData.getAdminUserByLegacyId, {\n    id: parsedId,\n  });\n  if (!match) return undefined;\n\n  return UserSchema.parse({\n    id: match.id,\n    name: match.name,\n    email: match.email,\n    regdate: match.regdate ? new Date(match.regdate) : undefined,\n    activated: match.activated,\n    role: match.role,\n    admin: match.admin,\n    discordLinked: match.discordLinked,\n    discordAccountId: match.discordAccountId,\n    discordStoriesRole: match.discordStoriesRole,\n    discordStoriesSyncStatus: match.discordStoriesSyncStatus,\n    discordStoriesLastSyncedAt:\n      typeof match.discordStoriesLastSyncedAt === \"number\"\n        ? new Date(match.discordStoriesLastSyncedAt)\n        : undefined,\n  });\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ user_id: string }>;\n}) {\n  const user = await user_properties((await params).user_id);\n  //console.log(user);\n\n  if (user === undefined) notFound();\n\n  return <UserDisplay user={user} />;\n}\n"
  },
  {
    "path": "src/app/admin/users/[user_id]/schema.ts",
    "content": "import { z } from \"zod\";\n\nexport const UserSchema = z.object({\n  id: z.number(),\n  name: z.string(),\n  email: z.string().email().or(z.string().min(1)),\n  image: z.string().nullable().optional(),\n  regdate: z.coerce.date().optional(),\n  activated: z.coerce.boolean().optional(),\n  role: z.coerce.boolean().optional(),\n  admin: z.coerce.boolean().optional(),\n  discordLinked: z.coerce.boolean().optional(),\n  discordAccountId: z.string().nullable().optional(),\n  discordStoriesRole: z.string().nullable().optional(),\n  discordStoriesSyncStatus: z\n    .enum([\n      \"assigned\",\n      \"up_to_date\",\n      \"no_milestone\",\n      \"not_linked\",\n      \"member_not_found\",\n      \"error\",\n    ])\n    .nullable()\n    .optional(),\n  discordStoriesLastSyncedAt: z.coerce.date().nullable().optional(),\n});\n\nexport type AdminUser = z.infer<typeof UserSchema>;\n"
  },
  {
    "path": "src/app/admin/users/[user_id]/user_display.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport Link from \"next/link\";\nimport Switch from \"@/components/ui/switch\";\nimport Button from \"@/components/ui/button\";\nimport {\n  adminDetailCardClass,\n  adminDetailLabelClass,\n  adminDetailPageClass,\n} from \"@/app/admin/adminDetailStyles\";\nimport type { AdminUser } from \"./schema\";\nimport {\n  setUserActivatedAction,\n  setUserWriteAction,\n  setUserDeleteAction,\n} from \"./actions\";\n\nasync function setUserActivated(data: {\n  id: number;\n  activated: 0 | 1 | boolean;\n}) {\n  return await setUserActivatedAction(data);\n}\n\nasync function setUserWrite(data: { id: number; write: 0 | 1 | boolean }) {\n  return await setUserWriteAction(data);\n}\n\nasync function setUserDelete(data: { id: number }) {\n  return await setUserDeleteAction(data);\n}\n\nfunction Activate({ user }: { user: AdminUser }) {\n  const [checked, setChecked] = useState(Boolean(user.activated));\n\n  return (\n    <span className=\"inline-flex items-center gap-2\">\n      <Switch\n        checked={checked}\n        onClick={async () => {\n          const value = await setUserActivated({\n            id: user.id,\n            activated: checked ? 0 : 1,\n          });\n          if (value !== undefined) setChecked(!checked);\n        }}\n      />\n      {checked ? \"Yes\" : \"No\"}\n    </span>\n  );\n}\n\nfunction Write({ user }: { user: AdminUser }) {\n  const [checked, setChecked] = useState(Boolean(user.role));\n\n  return (\n    <span className=\"inline-flex items-center gap-2\">\n      <Switch\n        checked={checked}\n        onClick={async () => {\n          const value = await setUserWrite({\n            id: user.id,\n            write: checked ? 0 : 1,\n          });\n          if (value !== undefined) setChecked(!checked);\n        }}\n      />\n      {checked ? \"Yes\" : \"No\"}\n    </span>\n  );\n}\n\nexport default function UserDisplay({ user }: { user: AdminUser }) {\n  const [userData] = useState<AdminUser>(user);\n\n  async function removeUser() {\n    if (window.confirm(\"Are you sure you want to delete this user?\")) {\n      await setUserDelete({ id: userData.id });\n    }\n  }\n\n  return (\n    <div className={adminDetailPageClass}>\n      <div className={adminDetailCardClass}>\n        <div className=\"mb-3.5 flex flex-wrap items-center justify-between gap-3\">\n          <Link\n            className=\"whitespace-nowrap underline underline-offset-2\"\n            href=\"/admin/users\"\n          >\n            Back to Users\n          </Link>\n          <Button variant=\"destructive\" onClick={removeUser}>\n            Delete User\n          </Button>\n        </div>\n\n        <div className=\"mb-4 flex items-center justify-between gap-3\">\n          <h1 className=\"m-0 text-3xl leading-tight\">{userData.name}</h1>\n        </div>\n\n        <div className=\"mt-3 mb-5 grid grid-cols-1 gap-x-3 gap-y-2 md:grid-cols-[160px_minmax(0,1fr)]\">\n          <div className={adminDetailLabelClass}>User ID</div>\n          <div className=\"min-w-0 break-words\">{userData.id}</div>\n\n          <div className={adminDetailLabelClass}>Email</div>\n          <div className=\"min-w-0 break-words\">{userData.email}</div>\n\n          <div className={adminDetailLabelClass}>Registered</div>\n          <div className=\"min-w-0 break-words\">{`${userData.regdate}`}</div>\n\n          <div className={adminDetailLabelClass}>Activated</div>\n          <div className=\"min-w-0 break-words\">\n            <Activate user={user} />\n          </div>\n\n          <div className={adminDetailLabelClass}>Contributor</div>\n          <div className=\"min-w-0 break-words\">\n            <Write user={user} />\n          </div>\n\n          <div className={adminDetailLabelClass}>Admin</div>\n          <div className=\"min-w-0 break-words\">\n            {userData.admin ? \"Yes\" : \"No\"}\n          </div>\n\n          <div className={adminDetailLabelClass}>Discord</div>\n          <div className=\"min-w-0 break-words\">\n            {userData.discordLinked\n              ? `Linked${userData.discordAccountId ? ` (${userData.discordAccountId})` : \"\"}`\n              : \"Not linked\"}\n          </div>\n\n          <div className={adminDetailLabelClass}>Stories role</div>\n          <div className=\"min-w-0 break-words\">\n            {userData.discordStoriesRole ?? \"None\"}\n            {userData.discordStoriesSyncStatus\n              ? ` (${userData.discordStoriesSyncStatus})`\n              : \"\"}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/admin/users/page.tsx",
    "content": "import UserList, { type AdminUserList } from \"./user_list\";\nimport { fetchAuthQuery } from \"@/lib/auth-server\";\nimport { api } from \"@convex/_generated/api\";\n\nconst LOAD_STEP = 50;\n\nfunction normalizeQuery(value: string | string[] | undefined) {\n  if (!value) return \"\";\n  if (Array.isArray(value)) return value[0] ?? \"\";\n  return value;\n}\n\ntype FilterValue = \"all\" | \"yes\" | \"no\";\ntype RoleFilterValue = \"all\" | \"user\" | \"contributor\" | \"admin\";\n\nfunction normalizeFilter(value: string | string[] | undefined): FilterValue {\n  const raw = normalizeQuery(value).toLowerCase();\n  if (raw === \"yes\" || raw === \"no\") return raw;\n  return \"all\";\n}\n\nfunction normalizeRoleFilter(\n  value: string | string[] | undefined,\n): RoleFilterValue {\n  const raw = normalizeQuery(value).toLowerCase();\n  if (raw === \"contributior\") return \"contributor\";\n  if (raw === \"user\" || raw === \"contributor\" || raw === \"admin\") return raw;\n  return \"all\";\n}\n\nexport default async function Page({\n  searchParams,\n}: {\n  searchParams?: Promise<Record<string, string | string[] | undefined>>;\n}) {\n  const resolvedSearchParams = await searchParams;\n  const rawQuery = normalizeQuery(resolvedSearchParams?.q);\n  const query = rawQuery.trim();\n  const limitRaw = normalizeQuery(resolvedSearchParams?.limit);\n  const limit = Math.max(\n    1,\n    Number.parseInt(limitRaw || String(LOAD_STEP), 10) || LOAD_STEP,\n  );\n  const activatedFilter = normalizeFilter(resolvedSearchParams?.activated);\n  const roleFilter = normalizeRoleFilter(resolvedSearchParams?.role);\n\n  const response = await fetchAuthQuery(api.adminData.getAdminUsersPage, {\n    query,\n    limit,\n    activatedFilter,\n    roleFilter,\n  });\n\n  const users: AdminUserList[] = response.users.map((user) => ({\n    ...user,\n    regdate:\n      typeof user.regdate === \"number\" ? new Date(user.regdate) : undefined,\n    discordStoriesLastSyncedAt:\n      typeof user.discordStoriesLastSyncedAt === \"number\"\n        ? new Date(user.discordStoriesLastSyncedAt)\n        : undefined,\n  }));\n\n  return (\n    <UserList\n      users={users}\n      query={query}\n      limit={limit}\n      hasMore={response.hasMore}\n      loadStep={LOAD_STEP}\n      activatedFilter={activatedFilter}\n      roleFilter={roleFilter}\n    />\n  );\n}\n"
  },
  {
    "path": "src/app/admin/users/user_list.tsx",
    "content": "\"use client\";\n\nimport {\n  useEffect,\n  useRef,\n  useState,\n  useTransition,\n  type KeyboardEvent,\n} from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport Link from \"next/link\";\nimport Button, {\n  buttonInnerClassName,\n  buttonRootClassName,\n} from \"@/components/ui/button\";\nimport Input from \"@/components/ui/input\";\nimport { SpinnerBlue } from \"@/components/ui/spinner\";\nimport {\n  adminTableContainerClass,\n  adminTableHeadCellClass,\n} from \"../adminTableStyles\";\nimport type { AdminUser } from \"./[user_id]/schema\";\n\nexport type AdminUserList = AdminUser & { admin?: boolean; rowKey?: string };\n\ntype FilterValue = \"all\" | \"yes\" | \"no\";\ntype RoleFilterValue = \"all\" | \"user\" | \"contributor\" | \"admin\";\n\ninterface UserListProps {\n  users: AdminUserList[];\n  query: string;\n  limit: number;\n  hasMore: boolean;\n  loadStep: number;\n  activatedFilter: FilterValue;\n  roleFilter: RoleFilterValue;\n}\n\ntype AdminFilters = {\n  activated: FilterValue;\n  role: RoleFilterValue;\n};\n\ntype PendingAction = \"search\" | \"filters\" | \"loadMore\" | null;\n\nfunction formatRegistered(value: Date | string | undefined) {\n  if (!value) return \"-\";\n  const date = value instanceof Date ? value : new Date(value);\n  if (Number.isNaN(date.getTime())) return String(value);\n  return new Intl.DateTimeFormat(undefined, {\n    month: \"short\",\n    day: \"2-digit\",\n    year: \"numeric\",\n    hour: \"2-digit\",\n    minute: \"2-digit\",\n  }).format(date);\n}\n\nfunction buildQueryString(query: string, limit: number, filters: AdminFilters) {\n  const params = new URLSearchParams();\n  if (query.trim().length > 0) params.set(\"q\", query.trim());\n  if (limit > 0) params.set(\"limit\", String(limit));\n  if (filters.activated !== \"all\") params.set(\"activated\", filters.activated);\n  if (filters.role !== \"all\") params.set(\"role\", filters.role);\n  const qs = params.toString();\n  return qs.length ? `?${qs}` : \"\";\n}\n\nconst statusYesClass =\n  \"inline-block min-w-10 rounded-full bg-[color:color-mix(in_srgb,#21c55d_22%,transparent)] px-2.5 py-0.5 text-center text-sm font-bold text-[#0a6b2d]\";\nconst statusNoClass =\n  \"inline-block min-w-10 rounded-full bg-[color:color-mix(in_srgb,#ef4444_20%,transparent)] px-2.5 py-0.5 text-center text-sm font-bold text-[#9b1c1c]\";\nconst statusInfoClass =\n  \"inline-block min-w-10 rounded-full bg-[color:color-mix(in_srgb,#3b82f6_18%,transparent)] px-2.5 py-0.5 text-center text-sm font-bold text-[#124f9c]\";\n\nfunction getRoleLabel(user: AdminUserList) {\n  if (user.admin) return \"Admin\";\n  if (user.role) return \"Contributor\";\n  return \"User\";\n}\n\nfunction getStoriesRoleTitle(user: AdminUserList) {\n  const parts: string[] = [];\n  if (user.discordStoriesSyncStatus) {\n    parts.push(`Sync status: ${user.discordStoriesSyncStatus}`);\n  }\n  if (user.discordStoriesLastSyncedAt) {\n    parts.push(\n      `Last synced: ${formatRegistered(user.discordStoriesLastSyncedAt)}`,\n    );\n  }\n  return parts.join(\"\\n\");\n}\n\nconst tableHeaders: Array<{ label: string; title?: string; key: string }> = [\n  { key: \"id\", label: \"ID\" },\n  { key: \"name\", label: \"Name\" },\n  { key: \"email\", label: \"Email\" },\n  { key: \"activated\", label: \"\", title: \"Activated\" },\n  { key: \"role\", label: \"Role\" },\n  { key: \"discord\", label: \"\", title: \"Discord\" },\n  { key: \"stories\", label: \"Stories\" },\n  { key: \"actions\", label: \"\", title: \"Actions\" },\n];\n\nfunction ActivatedStatus({ activated }: { activated: boolean | undefined }) {\n  if (!activated) {\n    return (\n      <span\n        className=\"inline-flex h-9 w-9 items-center justify-center rounded-full bg-[color:color-mix(in_srgb,#ef4444_16%,transparent)] text-base font-bold text-[#9b1c1c]\"\n        title=\"Not activated\"\n      >\n        -\n      </span>\n    );\n  }\n\n  return (\n    <span\n      className=\"inline-flex h-9 w-9 items-center justify-center rounded-full bg-[color:color-mix(in_srgb,#21c55d_18%,transparent)] text-base font-bold text-[#0a6b2d]\"\n      title=\"Activated\"\n    >\n      ✓\n    </span>\n  );\n}\n\nfunction DiscordAvatar({ user }: { user: AdminUserList }) {\n  const [imageFailed, setImageFailed] = useState(false);\n  const showImage =\n    user.discordLinked &&\n    typeof user.image === \"string\" &&\n    user.image.length > 0 &&\n    !imageFailed;\n  const initial = user.name.trim().charAt(0).toUpperCase() || \"?\";\n\n  if (!user.discordLinked) {\n    return (\n      <span className={statusNoClass} title=\"No linked Discord account\">\n        No\n      </span>\n    );\n  }\n\n  return (\n    <div\n      className=\"inline-flex h-9 w-9 items-center justify-center overflow-hidden rounded-full bg-[var(--profile-background)] font-bold text-[var(--profile-text)]\"\n      title={\n        user.discordAccountId\n          ? `Discord account ID: ${user.discordAccountId}`\n          : \"Linked Discord account\"\n      }\n    >\n      {showImage ? (\n        <img\n          alt=\"\"\n          src={user.image ?? undefined}\n          className=\"h-full w-full object-cover\"\n          onError={() => setImageFailed(true)}\n        />\n      ) : (\n        initial\n      )}\n    </div>\n  );\n}\n\nexport default function UserList({\n  users,\n  query,\n  limit,\n  hasMore,\n  loadStep,\n  activatedFilter,\n  roleFilter,\n}: UserListProps) {\n  const [search, setSearch] = useState(query);\n  const [filters, setFilters] = useState<AdminFilters>({\n    activated: activatedFilter,\n    role: roleFilter,\n  });\n  const [pendingAction, setPendingAction] = useState<PendingAction>(null);\n  const filtersRef = useRef(filters);\n  const router = useRouter();\n  const [isPending, startTransition] = useTransition();\n\n  function submitSearch(\n    nextLimit = loadStep,\n    options?: { action?: PendingAction; scroll?: boolean },\n  ) {\n    const action = options?.action ?? \"search\";\n    const scroll = options?.scroll ?? true;\n    setPendingAction(action);\n    startTransition(() => {\n      router.push(\n        `/admin/users${buildQueryString(search, nextLimit, {\n          activated: filters.activated,\n          role: filters.role,\n        })}`,\n        { scroll },\n      );\n    });\n  }\n\n  function submitFilters(nextFilters: AdminFilters) {\n    setPendingAction(\"filters\");\n    startTransition(() => {\n      router.push(\n        `/admin/users${buildQueryString(search, loadStep, {\n          activated: nextFilters.activated,\n          role: nextFilters.role,\n        })}`,\n      );\n    });\n  }\n\n  function updateFilter(\n    key: keyof AdminFilters,\n    value: FilterValue | RoleFilterValue,\n  ) {\n    const nextFilters = { ...filtersRef.current, [key]: value };\n    filtersRef.current = nextFilters;\n    setFilters(nextFilters);\n    submitFilters(nextFilters);\n  }\n\n  function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n    if (event.key === \"Enter\") {\n      submitSearch(loadStep);\n    }\n  }\n\n  return (\n    <div className=\"relative isolate mx-auto my-6 mb-9 box-border w-full max-w-[min(1240px,calc(100vw-48px))] rounded-2xl border border-[color:color-mix(in_srgb,var(--header-border)_70%,transparent)] bg-[var(--body-background)] p-5 shadow-[0_16px_38px_color-mix(in_srgb,#000_14%,transparent)]\">\n      <div className=\"flex flex-wrap items-end justify-between gap-4 px-0.5 pb-3\">\n        <div className=\"flex flex-wrap items-center gap-3.5\">\n          <Input\n            label=\"Search\"\n            placeholder=\"Username, email, or ID\"\n            value={search}\n            onChange={(event) => setSearch(event.target.value)}\n            onKeyDown={handleKeyDown}\n          />\n          <div className=\"flex flex-wrap items-center gap-2.5\">\n            <label className=\"inline-flex items-center gap-2 text-base text-[var(--text-color-dim)]\">\n              Activated\n              <select\n                className=\"min-w-[90px] rounded-xl border-2 border-[var(--input-border)] bg-[var(--input-background)] px-2.5 py-1.5 text-[var(--text-color)]\"\n                value={filters.activated}\n                onChange={(event) =>\n                  updateFilter(\"activated\", event.target.value as FilterValue)\n                }\n              >\n                <option value=\"all\">All</option>\n                <option value=\"yes\">Yes</option>\n                <option value=\"no\">No</option>\n              </select>\n            </label>\n            <label className=\"inline-flex items-center gap-2 text-base text-[var(--text-color-dim)]\">\n              Role\n              <select\n                className=\"min-w-[120px] rounded-xl border-2 border-[var(--input-border)] bg-[var(--input-background)] px-2.5 py-1.5 text-[var(--text-color)]\"\n                value={filters.role}\n                onChange={(event) =>\n                  updateFilter(\"role\", event.target.value as RoleFilterValue)\n                }\n              >\n                <option value=\"all\">All</option>\n                <option value=\"user\">User</option>\n                <option value=\"contributor\">Contributor</option>\n                <option value=\"admin\">Admin</option>\n              </select>\n            </label>\n          </div>\n        </div>\n        <Button onClick={() => submitSearch(loadStep)}>Search</Button>\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-between gap-3 px-0.5 py-3.5\">\n        <div className=\"inline-flex items-center gap-2 text-[var(--text-color-dim)]\">\n          {users.length === 0 ? \"No users found.\" : `Showing ${users.length}`}\n          <span\n            className={`inline-flex h-5 w-5 items-center ${isPending ? \"visible\" : \"invisible\"}`}\n            aria-live=\"polite\"\n          >\n            <SpinnerBlue />\n          </span>\n        </div>\n      </div>\n\n      <div className={adminTableContainerClass}>\n        <table className=\"w-max min-w-full border-collapse\">\n          <thead>\n            <tr>\n              {tableHeaders.map((header) => (\n                <th\n                  key={header.key}\n                  className={adminTableHeadCellClass}\n                  title={header.title}\n                >\n                  {header.label}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            {users.length === 0 ? (\n              <tr className=\"bg-[var(--body-background)]\">\n                <td colSpan={8} className=\"px-4 py-2.5\">\n                  No users found.\n                </td>\n              </tr>\n            ) : (\n              users.map((user, index) => (\n                <tr\n                  key={user.rowKey ?? `${user.id}-${index}`}\n                  className={`${index % 2 === 0 ? \"bg-[var(--body-background)]\" : \"bg-[color:color-mix(in_srgb,var(--body-background-faint)_74%,transparent)]\"} hover:brightness-95`}\n                >\n                  <td className=\"px-4 py-2.5\">{user.id}</td>\n                  <td className=\"max-w-[220px] px-3 py-2.5\">\n                    <span className=\"block overflow-hidden text-ellipsis whitespace-nowrap\">\n                      {user.name}\n                    </span>\n                  </td>\n                  <td className=\"max-w-[280px] px-3 py-2.5\">\n                    <span className=\"block overflow-hidden text-ellipsis whitespace-nowrap\">\n                      {user.email}\n                    </span>\n                  </td>\n                  <td className=\"px-3 py-2.5\">\n                    <ActivatedStatus activated={user.activated} />\n                  </td>\n                  <td className=\"px-3 py-2.5\">{getRoleLabel(user)}</td>\n                  <td className=\"px-3 py-2.5\">\n                    <DiscordAvatar user={user} />\n                  </td>\n                  <td className=\"px-3 py-2.5\">\n                    <span\n                      className={\n                        user.discordStoriesRole\n                          ? statusInfoClass\n                          : statusNoClass\n                      }\n                      title={getStoriesRoleTitle(user)}\n                    >\n                      {user.discordStoriesRole ?? \"None\"}\n                    </span>\n                  </td>\n                  <td className=\"px-4 py-2.5 text-right whitespace-nowrap\">\n                    <Link\n                      className={buttonRootClassName({\n                        className: \"mt-0 inline-block min-w-20 no-underline\",\n                      })}\n                      href={`/admin/users/${user.id}`}\n                    >\n                      <span className={buttonInnerClassName({ size: \"sm\" })}>\n                        Open\n                      </span>\n                    </Link>\n                  </td>\n                </tr>\n              ))\n            )}\n          </tbody>\n        </table>\n      </div>\n\n      {hasMore ? (\n        <div className=\"flex justify-center pt-4\">\n          <Button\n            disabled={isPending && pendingAction === \"loadMore\"}\n            onClick={() =>\n              submitSearch(limit + loadStep, {\n                action: \"loadMore\",\n                scroll: false,\n              })\n            }\n          >\n            <span className=\"inline-flex items-center gap-2\">\n              {isPending && pendingAction === \"loadMore\" ? (\n                <span className=\"inline-flex h-4 w-4 items-center\">\n                  <SpinnerBlue />\n                </span>\n              ) : null}\n              Load more\n            </span>\n          </Button>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/api/auth/[...all]/route.ts",
    "content": "import { handler } from \"@/lib/auth-server\";\n\nexport const { GET, POST } = handler;\n"
  },
  {
    "path": "src/app/api/og/route.tsx",
    "content": "import React from \"react\";\nimport { ImageResponse } from \"next/og\";\nimport type { NextRequest } from \"next/server\";\n\nexport async function GET(_request: NextRequest) {\n  try {\n    const fontData = await fetch(\n      new URL(\"../../../../assets/Nunito-Regular.ttf\", import.meta.url),\n    ).then((res) => res.arrayBuffer());\n\n    //let counts = get_counts();\n    let counts = { count_stories: 0, count_courses: 0 };\n\n    let text = `A community project to bring the original Duolingo Stories to new languages.`;\n    let text2 = `${counts.count_stories} stories in ${counts.count_courses} courses and counting!`;\n\n    return new ImageResponse(\n      <div\n        style={{\n          backgroundColor: \"white\",\n          backgroundSize: \"150px 150px\",\n          height: \"100%\",\n          width: \"100%\",\n          display: \"flex\",\n          textAlign: \"center\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          flexDirection: \"row\",\n          flexWrap: \"nowrap\",\n          gap: \"50px\",\n        }}\n      >\n        <div\n          style={{\n            display: \"flex\",\n            alignItems: \"flex-start\",\n            justifyContent: \"flex-start\",\n            justifyItems: \"left\",\n            flexDirection: \"column\",\n            backgroundColor: \"white\",\n            fontSize: 40,\n            width: \"60%\",\n          }}\n        >\n          <div style={{ fontWeight: \"bold\", fontSize: 80 }}>Duostories</div>\n          <div style={{ textAlign: \"left\" }}>{text}</div>\n          <div style={{ textAlign: \"left\" }}>{text2}</div>\n        </div>\n        <img\n          src={\"https://duostories.org/icon192.png\"}\n          height={\"300px\"}\n          width=\"300px\"\n          alt=\"\"\n        />\n      </div>,\n      {\n        width: 1200,\n        height: 630,\n        fonts: [\n          {\n            name: \"Nunito\",\n            data: fontData,\n            style: \"normal\",\n          },\n        ],\n      },\n    );\n  } catch (e) {\n    //console.log(`${e instanceof Error ? e.message : String(e)}`);\n    return new Response(`Failed to generate the image`, {\n      status: 500,\n    });\n  }\n}\n"
  },
  {
    "path": "src/app/api/og-course/route.tsx",
    "content": "import React from \"react\";\nimport { ImageResponse } from \"next/og\";\nimport type { NextRequest } from \"next/server\";\nexport const runtime = \"edge\";\n\nfunction get_flag_id(iso: string | null): number {\n  const order = [\n    \"en\", //0\n    \"es\", //1\n    \"fr\", //2\n    \"de\", //3\n    \"ja\", //4\n    \"it\", //5\n    \"ko\", //6\n    \"zh\", //7\n    \"ru\", //8\n    \"pt\", //9\n    \"tr\", //10\n    \"nl\", //11\n    \"sv\", //12\n    \"ga\", //13\n    \"el\", //14\n    \"he\", //15\n    \"pl\", //16\n    \"no\", //17\n    \"vi\", //18\n    \"da\", //19\n    \"hv\", //20\n    \"ro\", //21\n    \"sw\", //22\n    \"eo\", //23\n    \"hu\", //24\n    \"cy\", //25\n    \"uk\", //26\n    \"tlh\", //27\n    \"cs\", //28\n    \"hi\", //29\n    \"id\", //30\n    \"hw\", //31\n    \"nv\", //32\n    \"ar\", //33\n    \"ca\", //34\n    \"th\", //35\n    \"gn\", //36\n    \"world\", //37\n    \"duo\", //38\n    \"tools\", //39\n    \"reader\", //40\n    \"la\", //41\n    \"gd\", //42\n    \"fi\", //43\n    \"yi\", //44\n    \"ht\", //45\n    \"tl\", //46\n    \"zu\", //47\n  ];\n  let flag = 0;\n  for (let i = 0; i < order.length; i++) {\n    if (order[i] === (iso || \"world\")) flag = i;\n  }\n  return flag;\n}\n\nexport async function GET(request: NextRequest) {\n  try {\n    const { searchParams } = new URL(request.url);\n\n    const fontData = await fetch(\n      new URL(\"../../../../assets/Nunito-Regular.ttf\", import.meta.url),\n    ).then((res) => res.arrayBuffer());\n\n    const flag_offset = get_flag_id(searchParams.get(\"lang\"));\n    const flag_scale = 3;\n\n    const imageUrl = new URL(\"./og_background.png\", import.meta.url).toString();\n\n    return new ImageResponse(\n      <div\n        style={{\n          backgroundColor: \"white\",\n          backgroundSize: \"150px 150px\",\n          height: \"100%\",\n          width: \"100%\",\n          display: \"flex\",\n          textAlign: \"left\",\n          alignItems: \"flex-start\",\n          justifyContent: \"flex-start\",\n          flexDirection: \"column\",\n          flexWrap: \"nowrap\",\n        }}\n      >\n        <img\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: 0,\n          }}\n          height=\"100%\"\n          width=\"100%\"\n          src={imageUrl}\n          alt=\"\"\n        />\n        <div\n          style={{\n            position: \"absolute\",\n            left: 32,\n            top: 32,\n            width: 82 * flag_scale,\n            height: 66 * flag_scale,\n            backgroundPosition: `0px -${66 * flag_offset * flag_scale}px`,\n            backgroundColor: \"#f5f5f5\",\n            backgroundSize: `${82 * flag_scale}px ${3168 * flag_scale}px`,\n            backgroundImage:\n              \"url(https://d35aaqx5ub95lt.cloudfront.net/vendor/87938207afff1598611ba626a8c4827c.svg)\",\n          }}\n        ></div>\n        <div\n          style={{\n            position: \"absolute\",\n            left: 32 + 32 + 82 * flag_scale,\n            top: 64 - 16,\n            fontSize: 82,\n          }}\n        >\n          {searchParams.get(\"name\") ?? \"Language\"}\n        </div>\n        <div\n          style={{\n            position: \"absolute\",\n            left: 32 + 32 + 82 * flag_scale,\n            top: 64 + 82,\n            fontSize: \"40px\",\n            fontWeight: 300,\n          }}\n        >\n          on Duostories.org\n        </div>\n        <div\n          style={{\n            position: \"absolute\",\n            left: 64,\n            top: 40 + 30 + 66 * flag_scale,\n            fontSize: \"40px\",\n          }}\n        >\n          {`${searchParams.get(\"count\") ?? \"4\"} community translated stories`}\n        </div>\n\n        <div\n          style={{\n            fontSize: 60,\n            fontStyle: \"normal\",\n            letterSpacing: \"-0.025em\",\n            color: \"black\",\n            marginTop: 30,\n            padding: \"0 120px\",\n            lineHeight: 1.4,\n            whiteSpace: \"pre-wrap\",\n          }}\n        ></div>\n      </div>,\n      {\n        width: 1200,\n        height: 630,\n        fonts: [\n          {\n            name: \"Nunito\",\n            data: fontData,\n            style: \"normal\",\n          },\n        ],\n      },\n    );\n  } catch (e) {\n    //console.log(`${e instanceof Error ? e.message : String(e)}`);\n    return new Response(`Failed to generate the image`, {\n      status: 500,\n    });\n  }\n}\n"
  },
  {
    "path": "src/app/api/og-story/route.tsx",
    "content": "import React from \"react\";\nimport { ImageResponse } from \"next/og\";\nimport type { NextRequest } from \"next/server\";\n\nexport async function GET(request: NextRequest) {\n  try {\n    const { searchParams } = new URL(request.url);\n\n    const fontData = await fetch(\n      new URL(\"../../../../assets/Nunito-Regular.ttf\", import.meta.url),\n    ).then((res) => res.arrayBuffer());\n\n    const imageUrl = new URL(\"./og_background.png\", import.meta.url).toString();\n\n    return new ImageResponse(\n      <div\n        style={{\n          height: \"70%\",\n          width: \"100%\",\n          display: \"flex\",\n          textAlign: \"center\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          flexDirection: \"row\",\n          flexWrap: \"nowrap\",\n          gap: \"30px\",\n        }}\n      >\n        <img\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: 0,\n          }}\n          height=\"630px\"\n          width=\"1200px\"\n          src={imageUrl}\n          alt=\"\"\n        />\n        <div\n          style={{\n            display: \"flex\",\n            alignItems: \"flex-start\",\n            justifyContent: \"flex-start\",\n            justifyItems: \"left\",\n            flexDirection: \"column\",\n            fontSize: 40,\n            width: \"60%\",\n          }}\n        >\n          <div style={{ fontWeight: \"bold\", fontSize: 80 }}>\n            {searchParams.get(\"title\") ?? \"Good morning\"}\n          </div>\n          <div style={{ textAlign: \"left\" }}>\n            {`${searchParams.get(\"name\") ?? \"Language\"} story on duostories.org`}\n          </div>\n        </div>\n        <img\n          src={`https://stories-cdn.duolingo.com/image/${searchParams.get(\"image\") ?? \"783305780a6dad8e0e4eb34109d948e6a5fc2c35\"}.svg`}\n          height={290}\n          width={300}\n          alt=\"\"\n        />\n      </div>,\n      {\n        width: 1200,\n        height: 630,\n        fonts: [\n          {\n            name: \"Nunito\",\n            data: fontData,\n            style: \"normal\",\n          },\n        ],\n      },\n    );\n  } catch (e) {\n    //console.log(`${e instanceof Error ? e.message : String(e)}`);\n    return new Response(`Failed to generate the image`, {\n      status: 500,\n    });\n  }\n}\n"
  },
  {
    "path": "src/app/audio/_lib/audio/azure_tts.ts",
    "content": "import * as sdk from \"microsoft-cognitiveservices-speech-sdk\";\nimport * as fs from \"fs\";\nimport { put } from \"@vercel/blob\";\nimport type { AudioMark, SynthesisResult, Voice, TTSEngine } from \"./types\";\n\nfunction get_raw(text: string): string {\n  text = text.replace(/ +/g, \" \");\n  let text2 = \"\";\n  for (let m of text.matchAll(/(<[^>]+>)|(\\w+)|([^\\w<>]*)/g)) {\n    if (m[1]) {\n    } else if (m[2]) {\n      text2 += m[2];\n    } else if (m[3]) {\n      text2 += m[3];\n    }\n  }\n  return text2;\n}\n\nasync function synthesizeSpeechAzure(\n  filename: string | undefined,\n  voice_id: string,\n  text: string,\n  file?: string,\n): Promise<SynthesisResult> {\n  return new Promise((resolve, reject) => {\n    if (file) text = fs.readFileSync(file, \"utf8\");\n    const speechConfig = sdk.SpeechConfig.fromSubscription(\n      process.env.AZURE_APIKEY!,\n      \"westeurope\",\n    );\n    const audioConfig = sdk.AudioConfig.fromAudioFileOutput(\n      \"/dev/null\", //filename === undefined ? \"/dev/null\" : filename,\n    );\n    speechConfig.speechSynthesisOutputFormat = 5;\n    // create the speech synthesizer.\n    let synthesizer: sdk.SpeechSynthesizer | undefined =\n      new sdk.SpeechSynthesizer(speechConfig, audioConfig);\n\n    let last_pos = 0;\n    const marks: AudioMark[] = [];\n\n    synthesizer.wordBoundary = (\n      _w: unknown,\n      v: sdk.SpeechSynthesisWordBoundaryEventArgs,\n    ) => {\n      last_pos = text2.substring(last_pos).search(v.text) + last_pos;\n      const data: AudioMark = {\n        time: Math.round(v.audioOffset / 10000),\n        type: \"word\",\n        start: v.textOffset,\n        end: v.textOffset + v.wordLength,\n        value: v.text,\n      };\n      marks.push(data);\n    };\n\n    //text = text.replace(/^<speak>/, \"\");\n    //text = text.replace(/<\\/speak>$/, \"\");\n    let lang = voice_id.split(\"-\")[0] + \"-\" + voice_id.split(\"-\")[1];\n    if (!text.startsWith(\"<speak\"))\n      text = `<speak version='1.0' xml:lang='${lang}'><voice name=\"${voice_id}\">${text}</voice></speak>`;\n\n    let text2 = get_raw(text);\n    synthesizer.speakSsmlAsync(\n      text,\n      async function (result: sdk.SpeechSynthesisResult) {\n        if (result.reason === sdk.ResultReason.SynthesizingAudioCompleted) {\n          const content = Buffer.from(result.audioData).toString(\"base64\");\n          const output: SynthesisResult = {\n            output_file: filename,\n            marks: marks,\n            content: content,\n          };\n          if (filename !== undefined) {\n            await put(filename, Buffer.from(result.audioData), {\n              access: \"public\",\n              addRandomSuffix: false,\n            });\n          }\n          resolve(output);\n        } else {\n          console.error(\n            \"Speech synthesis canceled, \" +\n              result.errorDetails +\n              \"\\nDid you update the subscription info?\",\n          );\n          reject(result.errorDetails);\n        }\n        synthesizer?.close();\n        synthesizer = undefined;\n      },\n      function (err: string) {\n        console.trace(\"err - \" + err);\n        synthesizer?.close();\n        synthesizer = undefined;\n        reject(err);\n      },\n    );\n  });\n}\n\nasync function getVoices(): Promise<Voice[]> {\n  const speechConfig = sdk.SpeechConfig.fromSubscription(\n    process.env.AZURE_APIKEY!,\n    \"westeurope\",\n  );\n\n  // create the speech synthesizer.\n  const synthesizer = new sdk.SpeechSynthesizer(speechConfig);\n\n  const voices = await synthesizer.getVoicesAsync();\n  const result_voices: Voice[] = [];\n  for (const voice of voices.voices) {\n    result_voices.push({\n      language: voice.locale.split(\"-\")[0],\n      locale: voice.locale,\n      name: voice.shortName,\n      gender: voice.gender === 1 ? \"FEMALE\" : \"MALE\",\n      type: voice.voiceType === 1 ? \"NEURAL\" : \"NORMAL\",\n      service: \"Microsoft Azure\",\n    });\n  }\n  return result_voices;\n}\n\nfunction isValidVoice(voice: string): boolean {\n  return voice.indexOf(\"-\") !== -1;\n}\n\nconst azureEngine: TTSEngine = {\n  name: \"azure\",\n  synthesizeSpeech: synthesizeSpeechAzure,\n  getVoices: getVoices,\n  isValidVoice: isValidVoice,\n};\n\nexport default azureEngine;\n"
  },
  {
    "path": "src/app/audio/_lib/audio/elevenlabs.ts",
    "content": "import { put } from \"@vercel/blob\";\nimport { decode } from \"base64-arraybuffer\";\nimport WebSocket from \"ws\";\nimport type {\n  AudioMark,\n  SynthesisResult,\n  Voice,\n  ElevenLabsEngine,\n  ElevenLabsSubscription,\n} from \"./types\";\n\nconst model = \"eleven_multilingual_v2\";\n\ninterface GenerateResult {\n  audioBuffers: Buffer[];\n  alignment: [string, number][];\n}\n\nasync function generate(\n  voiceId: string,\n  text: string,\n): Promise<GenerateResult> {\n  const wsUrl = `wss://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream-input?model_id=${model}&enable_ssml_parsing=false`;\n  return new Promise((resolve, reject) => {\n    const socket = new WebSocket(wsUrl);\n\n    const audioBuffers: Buffer[] = [];\n    const alignment: [string, number][] = [];\n\n    // 2. Initialize the connection by sending the BOS message\n    socket.onopen = function (_event: Event) {\n      const bosMessage = {\n        text: \" \",\n        voice_settings: {\n          stability: 0.5,\n          similarity_boost: 0.8,\n        },\n        xi_api_key: process.env.ELEVENLABS_APIKEY, // replace with your API key\n      };\n\n      socket.send(JSON.stringify(bosMessage));\n\n      // 3. Send the input text message (\"Hello World\")\n      const textMessage = {\n        text: text,\n        try_trigger_generation: true,\n      };\n\n      socket.send(JSON.stringify(textMessage));\n\n      // 4. Send the EOS message with an empty string\n      const eosMessage = {\n        text: \"\",\n      };\n\n      socket.send(JSON.stringify(eosMessage));\n    };\n\n    // 5. Handle server responses\n    socket.onmessage = function (event: { data: string }) {\n      const response = JSON.parse(event.data) as {\n        alignment?: { chars: string[]; charStartTimesMs: number[] };\n        audio?: string;\n        isFinal?: boolean;\n        normalizedAlignment?: unknown;\n      };\n\n      if (response.alignment) {\n        for (let i = 0; i < response.alignment.chars.length; i++) {\n          alignment.push([\n            response.alignment.chars[i],\n            response.alignment.charStartTimesMs[i],\n          ]);\n        }\n      }\n\n      if (response.audio) {\n        // decode and handle the audio data (e.g., play it)\n        const audioChunk = decode(response.audio);\n        audioBuffers.push(Buffer.from(audioChunk));\n      }\n\n      if (response.isFinal) {\n        // the generation is complete\n        resolve({ audioBuffers: audioBuffers, alignment: alignment });\n      }\n    };\n\n    // Handle errors\n    socket.onerror = function (error: Error) {\n      console.error(`WebSocket Error: ${error}`);\n      reject(error);\n    };\n\n    // Handle socket closing\n    socket.onclose = function (event: {\n      wasClean: boolean;\n      code: number;\n      reason: string;\n    }) {\n      if (event.wasClean) {\n        console.info(\n          `Connection closed cleanly, code=${event.code}, reason=${event.reason}`,\n        );\n      } else {\n        console.warn(\"Connection died\");\n      }\n    };\n  });\n}\nasync function synthesizeSpeechElevenLabs(\n  filename: string | undefined,\n  voice_id: string,\n  text: string,\n): Promise<SynthesisResult> {\n  //console.log(\"synthesizeSpeechElevenLabs\", filename, voice_id, text);\n  const response = await generate(voice_id, text);\n  const completeAudio = Buffer.concat(response.audioBuffers);\n\n  let content: string | undefined;\n  if (filename) {\n    await put(filename, completeAudio, {\n      access: \"public\",\n      addRandomSuffix: false,\n    });\n  } else {\n    content = completeAudio.toString(\"base64\");\n  }\n  //console.log(response.alignment);\n  const marks: AudioMark[] = [];\n  let word = \"\";\n  let word_start = 0;\n  let word_start_time = 0;\n  for (let i = 0; i < response.alignment.length; i++) {\n    const [c, t] = response.alignment[i];\n    const match = c.match(/[ .,!?:;]/);\n    if (match) {\n      if (word.length > 0) {\n        marks.push({\n          time: word_start_time,\n          type: \"word\",\n          start: word_start,\n          end: word_start + word.length,\n          value: word,\n        });\n      }\n      word = \"\";\n      word_start = i;\n      word_start_time = t;\n    } else {\n      word += c;\n    }\n  }\n  if (word.length > 0) {\n    marks.push({\n      time: word_start_time,\n      type: \"word\",\n      start: word_start,\n      end: word_start + word.length,\n      value: word,\n    });\n  }\n  //console.log(marks);\n  return {\n    output_file: filename,\n    content: content,\n    marks: marks,\n  };\n}\n\nasync function getUserInfo(): Promise<ElevenLabsSubscription> {\n  const options = {\n    method: \"GET\",\n    headers: { \"xi-api-key\": process.env.ELEVENLABS_APIKEY! },\n  };\n\n  const response = await fetch(\n    \"https://api.elevenlabs.io/v1/user/subscription\",\n    options,\n  );\n  return (await response.json()) as ElevenLabsSubscription;\n}\n\nasync function isValidVoice(voiceId: string): Promise<boolean> {\n  const options = {\n    method: \"GET\",\n    headers: { \"xi-api-key\": process.env.ELEVENLABS_APIKEY! },\n  };\n  try {\n    const response = await fetch(\n      `https://api.elevenlabs.io/v1/voices/${voiceId}`,\n      options,\n    );\n    const voice = (await response.json()) as { voice_id?: string };\n    //console.log(voice);\n    return voice.voice_id === voiceId;\n  } catch (e) {\n    return false;\n  }\n}\n\nasync function getVoices(): Promise<Voice[]> {\n  const options = {\n    method: \"GET\",\n    headers: { \"xi-api-key\": process.env.ELEVENLABS_APIKEY! },\n  };\n\n  try {\n    const response = await fetch(\n      \"https://api.elevenlabs.io/v1/voices\",\n      options,\n    );\n    const data = (await response.json()) as {\n      voices: Array<{\n        voice_id: string;\n        name: string;\n        labels?: { language?: string };\n      }>;\n    };\n    // ElevenLabs doesn't return voices in standard format, return empty for now\n    return data.voices.map((voice) => ({\n      language: voice.labels?.language ?? \"en\",\n      locale: voice.labels?.language ?? \"en-US\",\n      name: voice.voice_id,\n      gender: \"MALE\" as const,\n      type: \"NEURAL\" as const,\n      service: \"ElevenLabs\",\n    }));\n  } catch (e) {\n    //console.log(e);\n    return [];\n  }\n}\n\nconst elevenlabsEngine: ElevenLabsEngine = {\n  name: \"elevenlabs\",\n  synthesizeSpeech: synthesizeSpeechElevenLabs,\n  getVoices: getVoices,\n  isValidVoice: isValidVoice,\n  getUserInfo: getUserInfo,\n};\n\nexport default elevenlabsEngine;\n"
  },
  {
    "path": "src/app/audio/_lib/audio/google.ts",
    "content": "import { put } from \"@vercel/blob\";\nimport type { SynthesisResult, Voice, TTSEngine } from \"./types\";\n\nconst apiKey = process.env.GITHUB_APIKEY;\n\nasync function synthesizeSpeechGoogle(\n  filename: string | undefined,\n  voice_id: string,\n  text: string,\n): Promise<SynthesisResult> {\n  //async function getAudio(apiKey, voiceLang, voiceName, ssml) {\n  let [lang, region, voiceName] = voice_id.split(\"-\", 2);\n\n  const headers = new Headers();\n  headers.append(\"Content-Type\", \"application/json; charset=utf-8\");\n\n  //text = text.replace(/^<speak>/, \"\");\n  //text = text.replace(/<\\/speak>$/, \"\");\n  //let [text_with_marks, marks] = add_marks(text);\n  //let ssml = \"<speak>\" + text_with_marks + \"</speak>\";\n  let ssml = text;\n\n  const request = {\n    input: {\n      ssml,\n    },\n    voice: {\n      languageCode: lang + \"-\" + region,\n      name: voice_id,\n    },\n    audioConfig: {\n      audioEncoding: \"MP3\",\n    },\n    enableTimePointing: [\"SSML_MARK\"],\n  };\n\n  const response = await fetch(\n    `https://texttospeech.googleapis.com/v1beta1/text:synthesize?key=${apiKey}`,\n    {\n      method: \"POST\",\n      headers,\n      body: JSON.stringify(request),\n    },\n  );\n  if (response.ok) {\n    const { audioContent, timepoints } = await response.json();\n\n    return new Promise(async (resolve, reject) => {\n      if (filename === undefined) {\n        /*\n        for (let mark_index in timepoints) {\n          mark_index = mark_index * 1;\n          marks[0][\"time\"] = 0;\n          for (let mark of marks) {\n            if (mark.end === parseInt(timepoints[mark_index].markName)) {\n              mark[\"time\"] = timepoints[mark_index][\"timeSeconds\"] * 1000;\n            }\n          }\n        }*/\n        resolve({ timepoints: timepoints, content: audioContent });\n      } else {\n        await put(filename, Buffer.from(audioContent, \"base64\"), {\n          access: \"public\",\n          addRandomSuffix: false,\n        });\n        resolve({ output_file: filename, timepoints: timepoints });\n      }\n      /*\n      fs.writeFile(filename, Buffer.from(audioContent, \"base64\"), () => {\n        resolve({ output_file: filename, timepoints: timepoints });\n      });\n    */\n    });\n    // do something with audioContent and timepoints\n  } else {\n    console.error(`Error: ${response.status} - ${response.statusText}`);\n    return new Promise((resolve, reject) => {\n      reject(`Error: ${response.status} - ${response.statusText}`);\n    });\n  }\n}\n\ninterface MarkData {\n  type: \"word\";\n  start: number;\n  end: number;\n  value: string;\n}\n\nfunction add_marks(text: string): [string, MarkData[]] {\n  const regexSplitToken = /(<[^>]+>)|([^\\s<>]+)|(\\s*)/g;\n  const regexCombineWhitespace = / +/g;\n  text = text.replace(regexCombineWhitespace, \" \").trim();\n  let text2 = \"\";\n  let i = 0;\n  const splitTextTokens = text.matchAll(regexSplitToken);\n  const marks: MarkData[] = [];\n  for (const [, tag, word, space] of splitTextTokens) {\n    if (tag) {\n      text2 += tag;\n    } else if (word) {\n      marks.push({ type: \"word\", start: i, end: i + word.length, value: word });\n      i += word.length;\n      text2 += word + `<mark name=\"${i}\"/>`;\n    } else if (space) {\n      i += space.length;\n      text2 += space;\n    }\n  }\n  return [text2, marks];\n}\n\ninterface GoogleVoice {\n  languageCodes: string[];\n  name: string;\n  ssmlGender: \"MALE\" | \"FEMALE\";\n}\n\nasync function getVoices(): Promise<Voice[]> {\n  const headers = new Headers();\n  headers.append(\"Content-Type\", \"application/json; charset=utf-8\");\n\n  const response = await fetch(\n    `https://texttospeech.googleapis.com/v1/voices?key=${apiKey}`,\n    {\n      method: \"GET\",\n      headers,\n    },\n  );\n\n  if (response.ok) {\n    const { voices } = (await response.json()) as { voices: GoogleVoice[] };\n    const voices_result: Voice[] = [];\n    for (const voice of voices) {\n      voices_result.push({\n        language: voice.languageCodes[0].split(\"-\")[0],\n        locale: voice.languageCodes[0],\n        name: voice.name,\n        gender: voice.ssmlGender,\n        type: voice.name.indexOf(\"Neural\") !== -1 ? \"NEURAL\" : \"NORMAL\",\n        service: \"Google TTS\",\n      });\n    }\n    return voices_result;\n  } else {\n    console.error(`Error: ${response.status} - ${response.statusText}`);\n    return [];\n  }\n}\n\nfunction isValidVoice(voice: string): boolean {\n  return (\n    voice.indexOf(\"Wavenet\") !== -1 ||\n    voice.indexOf(\"Standard\") !== -1 ||\n    voice.indexOf(\"Neural2\") !== -1\n  );\n}\n\nconst googleEngine: TTSEngine = {\n  name: \"google\",\n  synthesizeSpeech: synthesizeSpeechGoogle,\n  getVoices: getVoices,\n  isValidVoice: isValidVoice,\n};\n\nexport default googleEngine;\n"
  },
  {
    "path": "src/app/audio/_lib/audio/index.ts",
    "content": "import engine_azure from \"./azure_tts\";\nimport engine_google from \"./google\";\nimport engine_polly from \"./polly\";\nimport engine_elevenlabs from \"./elevenlabs\";\nimport type { TTSEngine, ElevenLabsEngine } from \"./types\";\n\nconst audio_engines: (TTSEngine | ElevenLabsEngine)[] = [\n  engine_elevenlabs,\n  engine_google,\n  engine_azure,\n  engine_polly,\n];\n\nexport { audio_engines };\n"
  },
  {
    "path": "src/app/audio/_lib/audio/polly.ts",
    "content": "// Load the AWS SDK for Node.js\nimport {\n  Polly,\n  SynthesizeSpeechInput,\n  SynthesizeSpeechOutput,\n  DescribeVoicesOutput,\n} from \"@aws-sdk/client-polly\";\nimport { put } from \"@vercel/blob\";\nimport { ConvexHttpClient } from \"convex/browser\";\nimport { api } from \"@convex/_generated/api\";\nimport type { SynthesisResult, Voice, TTSEngine, SpeakerData } from \"./types\";\nimport type { Readable } from \"stream\";\n\n// Set the region and credentials for the AWS SDK\nconst config = {\n  region: \"eu-central-1\",\n};\n\nconst convexUrl =\n  process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL ?? \"\";\n\nconst convex = convexUrl ? new ConvexHttpClient(convexUrl) : null;\n\nasync function synthesizeSpeechCall(\n  polly: Polly,\n  params: SynthesizeSpeechInput,\n): Promise<SynthesizeSpeechOutput> {\n  return new Promise((resolve, reject) => {\n    polly.synthesizeSpeech(\n      params,\n      function (err: Error | null, data?: SynthesizeSpeechOutput) {\n        if (err) {\n          console.error(\"[Polly] synthesizeSpeech error:\", err.message);\n          return reject(err);\n        }\n        resolve(data!);\n      },\n    );\n  });\n}\n\nfunction streamToString(stream: Readable): Promise<string> {\n  const chunks: Buffer[] = [];\n  return new Promise((resolve, reject) => {\n    stream.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n    stream.on(\"error\", reject);\n    stream.on(\"end\", () => resolve(Buffer.concat(chunks).toString(\"utf8\")));\n  });\n}\n\nfunction streamToBuffer(stream: Readable): Promise<Buffer> {\n  const chunks: Buffer[] = [];\n  return new Promise((resolve, reject) => {\n    stream.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n    stream.on(\"error\", reject);\n    stream.on(\"end\", () => resolve(Buffer.concat(chunks)));\n  });\n}\n\nasync function streamToBase64(stream: Readable): Promise<string> {\n  return new Promise((resolve, reject) => {\n    const chunks: Buffer[] = [];\n    stream.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n    stream.on(\"error\", reject);\n    stream.on(\"end\", () => {\n      // Concatenate all the chunks into a single buffer\n      const buffer = Buffer.concat(chunks);\n\n      // Encode the buffer to base64\n      const base64 = buffer.toString(\"base64\");\n      resolve(base64);\n    });\n  });\n}\n\nasync function synthesizeSpeechPolly(\n  filename: string | undefined,\n  voice_id: string,\n  text: string,\n): Promise<SynthesisResult> {\n  console.log(\"[Polly] Starting synthesis for voice:\", voice_id);\n\n  // Create an instance of the Polly service object\n  const polly = new Polly(config);\n\n  text = text.replace(/^<speak>/, \"\");\n  text = text.replace(/<\\/speak>$/, \"\");\n  text = text.replace(/pitch=\"medium\"/, \"\");\n\n  const voice_data = await getVoiceData(voice_id);\n  console.log(\"[Polly] Voice data from DB:\", voice_data);\n\n  // Set the parameters for the synthesis request\n  const params: SynthesizeSpeechInput = {\n    OutputFormat: \"mp3\",\n    Text: `<speak>${text}</speak>`,\n    VoiceId: voice_id as SynthesizeSpeechInput[\"VoiceId\"],\n    TextType: \"ssml\",\n    Engine: voice_data?.type === \"NEURAL\" ? \"neural\" : \"standard\",\n  };\n  console.log(\"[Polly] Synthesis params:\", {\n    ...params,\n    Text: params.Text?.substring(0, 100) + \"...\",\n  });\n\n  // Call the synthesizeSpeech method to generate the audio\n  let data: SynthesizeSpeechOutput;\n  try {\n    data = await synthesizeSpeechCall(polly, params);\n    console.log(\"[Polly] Synthesis successful\");\n  } catch (e) {\n    console.error(\n      \"[Polly] Initial synthesis failed:\",\n      e instanceof Error ? e.message : e,\n    );\n    if (e instanceof Error && e.message.indexOf(\"feature\") !== -1) {\n      console.log(\"[Polly] Retrying without pitch attribute\");\n      params.Text = params.Text!.replace(/pitch=\"[^\"]*\"/, \"\");\n      data = await synthesizeSpeechCall(polly, params);\n    } else {\n      console.log(\"[Polly] Retrying with standard engine\");\n      params.Engine = \"standard\";\n      data = await synthesizeSpeechCall(polly, params);\n    }\n  }\n\n  const params2: SynthesizeSpeechInput = {\n    ...params,\n    SpeechMarkTypes: [\"word\"],\n    OutputFormat: \"json\",\n  };\n  const data2 = await synthesizeSpeechCall(polly, params2);\n\n  let content: string | undefined;\n  if (filename) {\n    const data_file = await streamToBuffer(data.AudioStream as Readable);\n    await put(filename, data_file, {\n      access: \"public\",\n      addRandomSuffix: false,\n    });\n  } else {\n    content = await streamToBase64(data.AudioStream as Readable);\n  }\n\n  // Handle the audio data\n  const data_read2 = await streamToString(data2.AudioStream as Readable);\n\n  const marks = [];\n  for (const mark of data_read2.trim().split(\"\\n\")) {\n    marks.push(JSON.parse(mark));\n  }\n  return {\n    output_file: filename,\n    marks: marks,\n    content: content,\n  };\n}\n\nasync function getVoices(): Promise<Voice[]> {\n  return new Promise((resolve, reject) => {\n    const polly = new Polly(config);\n    polly.describeVoices(\n      {},\n      (err: Error | null, data?: DescribeVoicesOutput) => {\n        if (err) {\n          reject(err);\n        } else {\n          const voices_result: Voice[] = [];\n          for (const voice of data?.Voices ?? []) {\n            voices_result.push({\n              language: voice.LanguageCode?.split(\"-\")[0] ?? \"\",\n              locale: voice.LanguageCode ?? \"\",\n              name: voice.Id ?? \"\",\n              gender: (voice.Gender?.toUpperCase() ?? \"MALE\") as\n                | \"MALE\"\n                | \"FEMALE\",\n              type:\n                voice.SupportedEngines?.[0] === \"neural\" ? \"NEURAL\" : \"NORMAL\",\n              service: \"Amazon Polly\",\n            });\n          }\n          resolve(voices_result);\n        }\n      },\n    );\n  });\n}\n\nfunction isValidVoice(voice: string): boolean {\n  return voice.indexOf(\"-\") === -1;\n}\n\nasync function getVoiceData(voice: string): Promise<SpeakerData | undefined> {\n  if (!convex) return undefined;\n  const speaker = await convex.query(api.audioRead.getSpeakerByName, {\n    speaker: voice,\n  });\n  if (!speaker) return undefined;\n\n  return {\n    id: speaker.id,\n    speaker: speaker.speaker,\n    type:\n      speaker.type === \"NEURAL\" || speaker.type === \"NORMAL\"\n        ? speaker.type\n        : \"NORMAL\",\n    gender: speaker.gender,\n    service: speaker.service,\n  };\n}\n\nconst pollyEngine: TTSEngine = {\n  name: \"polly\",\n  synthesizeSpeech: synthesizeSpeechPolly,\n  getVoices: getVoices,\n  isValidVoice: isValidVoice,\n};\n\nexport default pollyEngine;\n"
  },
  {
    "path": "src/app/audio/_lib/audio/types.ts",
    "content": "/**\n * Word timing marker for audio synchronization\n */\nexport interface AudioMark {\n  time: number;\n  type: \"word\";\n  start: number;\n  end: number;\n  value: string;\n}\n\n/**\n * Result from speech synthesis\n */\nexport interface SynthesisResult {\n  /** Output filename if saved to storage */\n  output_file?: string;\n  /** Base64 encoded audio content if not saved to file */\n  content?: string;\n  /** Word timing markers for audio synchronization */\n  marks?: AudioMark[];\n  /** Timepoints (used by Google TTS) */\n  timepoints?: Array<{ markName: string; timeSeconds: number }>;\n  /** Engine name that produced this result */\n  engine?: string;\n}\n\n/**\n * Voice information returned by getVoices\n */\nexport interface Voice {\n  /** ISO language code (e.g., \"en\", \"es\") */\n  language: string;\n  /** Full locale code (e.g., \"en-US\", \"es-ES\") */\n  locale: string;\n  /** Voice identifier/name */\n  name: string;\n  /** Voice gender */\n  gender: \"MALE\" | \"FEMALE\";\n  /** Voice type */\n  type: \"NEURAL\" | \"NORMAL\";\n  /** TTS service provider name */\n  service: string;\n}\n\n/**\n * TTS Engine interface - all engines must implement this\n */\nexport interface TTSEngine {\n  /** Engine identifier */\n  name: string;\n  /** Synthesize speech from text */\n  synthesizeSpeech: (\n    filename: string | undefined,\n    voice_id: string,\n    text: string,\n  ) => Promise<SynthesisResult>;\n  /** Get available voices */\n  getVoices: () => Promise<Voice[]>;\n  /** Check if a voice ID is valid for this engine */\n  isValidVoice: (voice: string) => Promise<boolean> | boolean;\n}\n\n/**\n * ElevenLabs specific - extends TTSEngine with user info\n */\nexport interface ElevenLabsEngine extends TTSEngine {\n  getUserInfo: () => Promise<ElevenLabsSubscription>;\n}\n\n/**\n * ElevenLabs subscription info\n */\nexport interface ElevenLabsSubscription {\n  tier: string;\n  character_count: number;\n  character_limit: number;\n  can_extend_character_limit: boolean;\n  allowed_to_extend_character_limit: boolean;\n  next_character_count_reset_unix: number;\n  voice_limit: number;\n  max_voice_add_edits: number;\n  voice_add_edit_counter: number;\n  professional_voice_limit: number;\n  can_use_instant_voice_cloning: boolean;\n  can_use_professional_voice_cloning: boolean;\n  status: string;\n}\n\n/**\n * Speaker/voice data from database\n */\nexport interface SpeakerData {\n  id: number;\n  speaker: string;\n  type: \"NEURAL\" | \"NORMAL\";\n  language?: string;\n  locale?: string;\n  gender?: string;\n  service?: string;\n}\n"
  },
  {
    "path": "src/app/audio/create/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\nimport { v4 as uuid } from \"uuid\";\nimport fs from \"fs\";\nimport { audio_engines } from \"../_lib/audio\";\nimport { getUser, isContributor } from \"@/lib/userInterface\";\nimport type { SynthesisResult } from \"../_lib/audio/types\";\nimport { getPostHogClient } from \"@/lib/posthog-server\";\n\nasync function mkdir(folderName: string): Promise<void> {\n  return new Promise((resolve, reject) => {\n    fs.mkdir(folderName, (err: NodeJS.ErrnoException | null) => {\n      if (err) {\n        reject(err);\n      } else {\n        resolve();\n        //console.log(`Folder '${folderName}' created successfully`);\n      }\n    });\n  });\n}\n\nasync function exists(filename: string): Promise<boolean> {\n  return new Promise((resolve) => {\n    fs.access(filename, (err: NodeJS.ErrnoException | null) => {\n      if (err) {\n        resolve(false);\n      } else {\n        resolve(true);\n      }\n    });\n  });\n}\n\nexport async function POST(req: NextRequest) {\n  const token = await getUser();\n\n  if (!token || !isContributor(token))\n    return new Response(\"You need to be a registered contributor.\", {\n      status: 401,\n    });\n\n  let data = await req.json();\n  let id = parseInt(data.id);\n  let speaker = data.speaker;\n  let text = data.text;\n  let filename = undefined;\n  let file;\n  if (id !== 0) {\n    filename = `audio/${id}`;\n    try {\n      await mkdir(filename);\n    } catch (e) {}\n    while (true) {\n      file = uuid().split(\"-\")[0] + \".mp3\";\n      filename += \"/\" + file;\n      if (!(await exists(filename))) break;\n    }\n  }\n\n  let answer: SynthesisResult | undefined;\n  for (const engine of audio_engines) {\n    if (await engine.isValidVoice(speaker)) {\n      try {\n        console.log(\n          `[Audio] Using engine: ${engine.name} for speaker: ${speaker}`,\n        );\n        answer = await engine.synthesizeSpeech(filename, speaker, text);\n        answer.engine = engine.name;\n        break;\n      } catch (e) {\n        console.error(`[Audio] Engine ${engine.name} failed:`, e);\n        // Continue to next engine instead of returning\n      }\n      break;\n    }\n  }\n\n  if (answer === undefined)\n    return new Response(\"Error not found.\", { status: 404 });\n\n  if (id !== 0) {\n    answer.output_file = `${id}/` + file;\n  }\n\n  // Track audio creation event server-side\n  const posthog = getPostHogClient();\n  posthog.capture({\n    distinctId: token.name || `user_${token.userId}`,\n    event: \"audio_created\",\n    properties: {\n      story_id: id,\n      speaker: speaker,\n      tts_engine: answer.engine,\n      text_length: text?.length || 0,\n    },\n  });\n  await posthog.shutdown();\n\n  return NextResponse.json(answer);\n}\n"
  },
  {
    "path": "src/app/audio/elevenlabs_quota/page.tsx",
    "content": "import engine_elevenlabs from \"../_lib/audio/elevenlabs\";\n\nexport default async function Page() {\n  const data = await engine_elevenlabs.getUserInfo();\n  return (\n    <>\n      Used character count: {data.character_count}/{data.character_limit} (\n      {(100 * data.character_count) / data.character_limit}%)\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/audio/upload/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\nimport { v4 as uuid } from \"uuid\";\nimport fs from \"fs\";\nimport { put } from \"@vercel/blob\";\nimport { getUser, isContributor } from \"@/lib/userInterface\";\n\nasync function mkdir(folderName: string): Promise<void> {\n  return new Promise((resolve, reject) => {\n    fs.mkdir(folderName, (err: NodeJS.ErrnoException | null) => {\n      if (err) {\n        reject(err);\n      } else {\n        resolve();\n        //console.log(`Folder '${folderName}' created successfully`);\n      }\n    });\n  });\n}\n\nasync function exists(filename: string): Promise<boolean> {\n  return new Promise((resolve) => {\n    fs.access(filename, (err: NodeJS.ErrnoException | null) => {\n      if (err) {\n        resolve(false);\n      } else {\n        resolve(true);\n      }\n    });\n  });\n}\n\nexport async function POST(req: NextRequest) {\n  const token = await getUser();\n\n  if (!token)\n    return new Response(\"You need to be logged in.\", {\n      status: 401,\n    });\n\n  if (!isContributor(token))\n    return new Response(\"You need to be a registered contributor.\", {\n      status: 401,\n    });\n\n  const data = await req.formData();\n  const file = data.get(\"file\");\n\n  if (!file || typeof file === \"string\") {\n    return NextResponse.json({ success: false });\n  }\n\n  const bytes = await file.arrayBuffer();\n  const buffer = Buffer.from(bytes);\n  const storyIdValue = data.get(\"story_id\");\n  let id = parseInt(typeof storyIdValue === \"string\" ? storyIdValue : \"0\");\n\n  let filename = undefined;\n  if (id !== 0) {\n    filename = `audio/${id}`;\n    try {\n      await mkdir(filename);\n    } catch (e) {}\n    while (true) {\n      const extMatch = file.name.match(/.*(\\.[^.]*)/);\n      let filebase = uuid().split(\"-\")[0] + (extMatch ? extMatch[1] : \"\");\n      filename += \"/_\" + filebase;\n      if (!(await exists(filename))) break;\n    }\n    await put(filename, Buffer.from(buffer), {\n      access: \"public\",\n      addRandomSuffix: false,\n    });\n\n    return NextResponse.json({ success: true, filename: filename });\n  }\n  return NextResponse.json({ success: false });\n}\n"
  },
  {
    "path": "src/app/audio/voices/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\nimport { audio_engines } from \"../_lib/audio\";\nimport { getUser, isAdmin } from \"@/lib/userInterface\";\nimport type { Voice } from \"../_lib/audio/types\";\nimport { fetchAuthMutation } from \"@/lib/auth-server\";\nimport { api } from \"@convex/_generated/api\";\n\nexport async function GET(_req: NextRequest) {\n  const token = await getUser();\n\n  if (!isAdmin(token))\n    return new Response(\"You need to be a registered admin.\", { status: 401 });\n\n  let voices: Voice[] = [];\n  for (const engine of audio_engines) {\n    try {\n      voices = voices.concat(await engine.getVoices());\n    } catch (e) {\n      //console.log(\"error\", engine.name);\n    }\n  }\n\n  for (let v of voices) {\n    try {\n      await fetchAuthMutation(api.languageWrite.upsertSpeakerFromVoice, {\n        localeShort: v.locale,\n        languageShort: v.language,\n        speaker: v.name,\n        gender: v.gender,\n        type: v.type,\n        service: v.service,\n        operationKey: `speaker:${v.name}:sync:route`,\n      });\n    } catch (e) {\n      //console.log(\"unknown language\", v?.language, v);\n    }\n  }\n\n  return NextResponse.json(voices);\n}\n"
  },
  {
    "path": "src/app/auth/admin/layout.tsx",
    "content": "import React from \"react\";\nimport { redirect } from \"next/navigation\";\nimport { getUser, isAdmin } from \"@/lib/userInterface\";\n\nexport default async function Layout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const user = await getUser();\n\n  if (isAdmin(user)) redirect(\"/admin\");\n\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "src/app/auth/admin/page.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport Button from \"@/components/ui/button\";\nimport { useRouter } from \"next/navigation\";\n\nexport default function Page({}) {\n  const router = useRouter();\n\n  return (\n    <>\n      <h1 className=\"m-0 text-[calc(24/16*1rem)]\">Not Allowed</h1>\n      <p className=\"m-0\">\n        You need to be logged in with an account that has an admin role.\n      </p>\n\n      <Button variant=\"primary\" onClick={() => router.push(\"/auth/signin\")}>\n        Log In\n      </Button>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/auth/editor/layout.tsx",
    "content": "import React from \"react\";\nimport { redirect } from \"next/navigation\";\nimport { getUser, isAdmin } from \"@/lib/userInterface\";\n\ninterface LayoutProps {\n  children: React.ReactNode;\n}\n\nexport default async function Layout({ children }: LayoutProps) {\n  const user = await getUser();\n\n  if (isAdmin(user)) redirect(\"/editor\");\n\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "src/app/auth/editor/page.tsx",
    "content": "import React from \"react\";\nimport Link from \"next/link\";\nimport {\n  buttonInnerClassName,\n  buttonRootClassName,\n} from \"@/components/ui/button\";\n\nexport default function Page() {\n  return (\n    <>\n      <h1 className=\"m-0 text-[calc(24/16*1rem)]\">Not Allowed</h1>\n      <p className=\"m-0\">\n        You need to be logged in with an account that has a contributor role.\n      </p>\n      <p className=\"m-0\">\n        If you want to contribute ask us on{\" \"}\n        <Link href=\"https://discord.gg/4NGVScARR3\">Discord</Link>.\n      </p>\n      <p className=\"m-0\">\n        You might need to login and out again after you got access to the\n        editor.\n      </p>\n      <Link\n        href=\"/auth/signin\"\n        className={buttonRootClassName({\n          className: \"inline-block no-underline\",\n          variant: \"primary\",\n        })}\n      >\n        <span className={buttonInnerClassName({ variant: \"primary\" })}>\n          Log In\n        </span>\n      </Link>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/auth/layout.tsx",
    "content": "import React from \"react\";\nimport Link from \"next/link\";\n\nexport const metadata = {\n  title:\n    \"Duostories: improve your Duolingo learning with community translated Duolingo stories.\",\n  description:\n    \"Supplement your Duolingo course with community-translated Duolingo stories.\",\n  alternates: {\n    canonical: \"https://duostories.org\",\n  },\n  keywords: [\n    \"language\",\n    \"learning\",\n    \"stories\",\n    \"Duolingo\",\n    \"community\",\n    \"volunteers\",\n  ],\n};\n\nexport default function Layout({ children }: { children: React.ReactNode }) {\n  return (\n    <div className=\"relative flex min-h-full w-full items-center justify-center\">\n      <Link\n        href=\"/\"\n        aria-label=\"Close\"\n        className=\"absolute right-5 top-5 inline-block h-[18px] w-[18px] cursor-pointer align-middle\"\n        style={{\n          backgroundImage:\n            \"url(https://d35aaqx5ub95lt.cloudfront.net/images/icon-sprite8.svg)\",\n          backgroundPosition: \"-373px -154px\",\n        }}\n      />\n      <div className=\"m-5 flex w-[375px] flex-col gap-2 text-center\">\n        {children}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/auth/register/page.tsx",
    "content": "import React from \"react\";\nimport { redirect } from \"next/navigation\";\nimport Register from \"./register\";\nimport { isAuthenticated } from \"@/lib/auth-server\";\n\nexport default async function Page({}) {\n  const session = await isAuthenticated();\n\n  // If the user is already logged in, redirect.\n  // Note: Make sure not to redirect to the same page\n  // To avoid an infinite loop!\n  if (session) {\n    redirect(\"/\");\n  }\n\n  return <Register />;\n}\n"
  },
  {
    "path": "src/app/auth/register/register.tsx",
    "content": "\"use client\";\nimport Head from \"next/head\";\nimport React from \"react\";\nimport Link from \"next/link\";\nimport { useInput } from \"@/lib/hooks\";\nimport Button from \"@/components/ui/button\";\nimport Input from \"@/components/ui/input\";\nimport posthog from \"posthog-js\";\nimport { authClient } from \"@/lib/auth-client\";\nimport {\n  authAlertErrorClass,\n  authAlertInfoClass,\n  authHeadingClass,\n  authInlineLinkClass,\n  authParagraphClass,\n} from \"@/components/auth/styles\";\n\nexport default function Register() {\n  const [state, setState] = React.useState(0);\n  const [error, setError] = React.useState(\"\");\n  const [message, setMessage] = React.useState(\"\");\n\n  const [usernameInput, usernameInputSetValue] = useInput(\"\");\n  const [passwordInput, passwordInputSetValue] = useInput(\"\");\n  const [emailInput, emailInputSetValue] = useInput(\"\");\n\n  function validateInputs() {\n    const emailValidation = /^\\w+([.-]?\\w+)*@\\w+([.-]?\\w+)*(\\.\\w+)+$/;\n    const usernameValidation = /^[a-zA-Z0-9_-]{3,20}$/; // Alphanumeric, 3-20 characters\n\n    if (!usernameValidation.test(usernameInput)) {\n      setError(\n        \"Username must be 3-20 characters long and can only contain letters, numbers, underscores, and dashes.\",\n      );\n      return false;\n    }\n\n    if (!emailValidation.test(emailInput)) {\n      setError(\"Not a valid email, please try again.\");\n      return false;\n    }\n\n    if (passwordInput.length < 6) {\n      setError(\"Password must be at least 6 characters long.\");\n      return false;\n    }\n\n    return true;\n  }\n\n  async function register_button(event: React.FormEvent<HTMLFormElement>) {\n    event.preventDefault(); // Prevent form submission from refreshing the page\n\n    if (!validateInputs()) {\n      setState(-1);\n      return;\n    }\n\n    setState(1);\n    const { error: signUpError } = await authClient.signUp.email({\n      name: usernameInput,\n      email: emailInput,\n      password: passwordInput,\n      username: usernameInput,\n      displayUsername: usernameInput,\n    });\n\n    if (signUpError) {\n      setError(signUpError?.message || \"Something went wrong.\");\n      setState(-1);\n    } else {\n      setState(2);\n      setMessage(\n        \"Your account has been registered. An e-mail with a verification link has been sent to you. Please click on the link in the e-mail to proceed. You may need to look into your spam folder.\",\n      );\n      posthog.capture(\"user_signed_up\", {\n        username: usernameInput,\n        email: emailInput,\n      });\n    }\n  }\n\n  return (\n    <>\n      <Head>\n        <title>Duostories Login</title>\n        <link rel=\"canonical\" href={`https://duostories.org/login`} />\n      </Head>\n\n      <h1 className={authHeadingClass}>Sign up</h1>\n      <p className={authParagraphClass}>\n        If you register you can keep track of the stories you have already\n        finished.\n      </p>\n      <p className={authParagraphClass}>\n        Registration is optional, stories can be accessed even without login.\n      </p>\n      {state === -1 && <span className={authAlertErrorClass}>{error}</span>}\n      {state === 2 && (\n        <span className={authAlertInfoClass} data-cy=\"message-confirm\">\n          {message}\n        </span>\n      )}\n      {state !== 2 && (\n        <form onSubmit={register_button} className=\"flex flex-col gap-2\">\n          <Input\n            data-cy=\"username\"\n            value={usernameInput}\n            onChange={usernameInputSetValue}\n            type=\"text\"\n            placeholder=\"Username\"\n            required\n            pattern=\"^[A-Za-z0-9_-]{3,20}$\"\n            title=\"Username must be 3-20 characters long and can only contain letters, numbers, underscores, and dashes.\"\n          />\n          <Input\n            data-cy=\"email\"\n            value={emailInput}\n            onChange={emailInputSetValue}\n            type=\"email\"\n            placeholder=\"Email\"\n            required\n          />\n          <Input\n            data-cy=\"password\"\n            value={passwordInput}\n            onChange={passwordInputSetValue}\n            type=\"password\"\n            placeholder=\"Password\"\n            required\n            minLength={6}\n            title=\"Password must be at least 6 characters long.\"\n          />\n          <Button data-cy=\"submit\" variant=\"primary\">\n            {state !== 1 ? \"Sign up\" : \"...\"}\n          </Button>\n        </form>\n      )}\n      <p className={authParagraphClass}>\n        Already have an account?{\" \"}\n        <Link className={authInlineLinkClass} href=\"/auth/signin\">\n          Log in\n        </Link>\n      </p>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/auth/reset_pw/page.tsx",
    "content": "import React from \"react\";\nimport { redirect } from \"next/navigation\";\nimport ResetPassword from \"./reset_pw\";\nimport { isAuthenticated } from \"@/lib/auth-server\";\n\nexport default async function Page({\n  searchParams,\n}: {\n  searchParams?: Promise<{ token?: string | string[] }>;\n}) {\n  const resolvedSearchParams = searchParams ? await searchParams : undefined;\n  const token = Array.isArray(resolvedSearchParams?.token)\n    ? resolvedSearchParams?.token[0]\n    : resolvedSearchParams?.token;\n  const session = await isAuthenticated();\n\n  // If the user is already logged in, redirect to home unless\n  // this is a token-based password reset flow.\n  if (session && !token) {\n    redirect(\"/\");\n  }\n\n  return <ResetPassword />;\n}\n"
  },
  {
    "path": "src/app/auth/reset_pw/reset_pw.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport Link from \"next/link\";\nimport { useInput } from \"@/lib/hooks\";\nimport Button from \"@/components/ui/button\";\nimport Input from \"@/components/ui/input\";\nimport { useSearchParams } from \"next/navigation\";\nimport { authClient } from \"@/lib/auth-client\";\nimport {\n  authAlertErrorClass,\n  authAlertInfoClass,\n  authHeadingClass,\n  authInlineLinkClass,\n  authParagraphClass,\n} from \"@/components/auth/styles\";\n\nexport default function ResetPassword() {\n  const searchParams = useSearchParams();\n  const token = searchParams.get(\"token\");\n  const errorParam = searchParams.get(\"error\");\n  const [state, setState] = React.useState(0);\n  const [error, setError] = React.useState(\"\");\n  const [message, setMessage] = React.useState(\"\");\n\n  const [emailInput, emailInputSetValue] = useInput(\"\");\n  const [passwordInput, passwordInputSetValue] = useInput(\"\");\n\n  async function requestReset() {\n    const emailValidation = /^\\w+([.-]?\\w+)*@\\w+([.-]?\\w+)*(\\.\\w{2,3})+$/;\n    if (!emailValidation.test(emailInput)) {\n      let msg = \"Not a valid email, please try again.\";\n      setError(msg);\n      setState(-1);\n      return;\n    }\n\n    setState(1);\n    try {\n      await authClient.requestPasswordReset({\n        email: emailInput,\n        redirectTo: `${window.location.origin}/auth/reset_pw`,\n      });\n    } catch (e) {\n      setState(-1);\n      setError(\"An Error occurred.\" + e);\n      return;\n    }\n    setMessage(\n      \"If the account exists an email was sent out with a link to reset the password.\",\n    );\n    setState(2);\n  }\n\n  async function resetPassword() {\n    if (!token) return;\n    if (passwordInput.length < 6) {\n      setError(\"Password must be at least 6 characters long.\");\n      setState(-1);\n      return;\n    }\n    setState(1);\n    const { error: resetError } = await authClient.resetPassword({\n      token,\n      newPassword: passwordInput,\n    });\n    if (resetError) {\n      setError(resetError.message || \"An Error occurred.\");\n      setState(-1);\n      return;\n    }\n    setState(2);\n    setMessage(\"Your password has been changed. You can now log in.\");\n  }\n  const handleKeypressSignup = (e: React.KeyboardEvent) => {\n    // listens for enter key\n    if (e.keyCode === 13) {\n      if (token) {\n        resetPassword();\n      } else {\n        requestReset();\n      }\n    }\n  };\n\n  return (\n    <>\n      <h1 className={authHeadingClass}>Reset Password</h1>\n      <p className={authParagraphClass}>\n        {token\n          ? \"Enter your new password.\"\n          : \"You forgot your password? We can send you a link to reset it.\"}\n      </p>\n      {errorParam && (\n        <span className={authAlertErrorClass}>\n          {errorParam === \"INVALID_TOKEN\"\n            ? \"This reset link is invalid or expired.\"\n            : errorParam}\n        </span>\n      )}\n      {state === -1 && <span className={authAlertErrorClass}>{error}</span>}\n      {state === 2 ? (\n        <span className={authAlertInfoClass} data-cy=\"message-confirm\">\n          {message}\n        </span>\n      ) : (\n        <form\n          action={token ? resetPassword : requestReset}\n          className=\"flex flex-col gap-2\"\n        >\n          {token ? (\n            <Input\n              data-cy=\"password\"\n              value={passwordInput}\n              onChange={passwordInputSetValue}\n              onKeyDown={handleKeypressSignup}\n              type=\"password\"\n              placeholder=\"Password\"\n              minLength={6}\n            />\n          ) : (\n            <Input\n              data-cy=\"email\"\n              value={emailInput}\n              onChange={emailInputSetValue}\n              onKeyDown={handleKeypressSignup}\n              type=\"email\"\n              name=\"email\"\n              placeholder=\"Email\"\n            />\n          )}\n          <Button data-cy=\"submit\" type=\"submit\" variant=\"primary\">\n            {state !== 1 ? (token ? \"Set Password\" : \"Send Link\") : \"...\"}\n          </Button>\n        </form>\n      )}\n      <p className={authParagraphClass}>\n        Already have an account?{\" \"}\n        <Link className={authInlineLinkClass} href=\"/auth/signin\">\n          Log in\n        </Link>\n      </p>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/auth/signin/login_options.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport Link from \"next/link\";\nimport { useInput } from \"@/lib/hooks\";\nimport posthog from \"posthog-js\";\n\nimport { GetIcon } from \"@/components/icons\";\nimport Button from \"@/components/ui/button\";\nimport Input from \"@/components/ui/input\";\nimport { SpinnerBlue } from \"@/components/ui/spinner\";\nimport { ProviderProps } from \"@/app/auth/signin/page\";\nimport { authClient } from \"@/lib/auth-client\";\nimport {\n  authAlertErrorClass,\n  authHeadingClass,\n  authInlineLinkClass,\n  authParagraphClass,\n} from \"@/components/auth/styles\";\n\nconst PENDING_SIGNIN_STORAGE_KEY = \"posthog_pending_signin\";\n\nexport function LoginOptions(props: {\n  providers: ProviderProps[];\n  callbackUrl: string;\n}) {\n  const providersClass = \"grid grid-cols-2 gap-x-4\";\n\n  const { providers, callbackUrl } = props;\n\n  const [state, setState] = React.useState<{ error: string | null }>({\n    error: null,\n  });\n  const [isPending, setIsPending] = React.useState(false);\n\n  const [usernameInput, usernameInputSetValue] = useInput(\"\");\n  const [passwordInput, passwordInputSetValue] = useInput(\"\");\n\n  const handleOAuthProviderClick = async (provider: ProviderProps) => {\n    if (typeof window !== \"undefined\") {\n      window.sessionStorage.setItem(\n        PENDING_SIGNIN_STORAGE_KEY,\n        JSON.stringify({\n          method: \"oauth\",\n          provider: provider.id,\n        }),\n      );\n    }\n\n    posthog.capture(\"oauth_provider_clicked\", {\n      provider: provider.id,\n      provider_name: provider.name,\n    });\n    const { data, error } = await authClient.signIn.social({\n      provider: provider.id,\n      callbackURL: callbackUrl,\n    });\n    if (error) {\n      if (typeof window !== \"undefined\") {\n        window.sessionStorage.removeItem(PENDING_SIGNIN_STORAGE_KEY);\n      }\n      setState({ error: error.message ?? \"Sign in error.\" });\n      return;\n    }\n    if (data?.url) {\n      window.location.href = data.url;\n    }\n  };\n\n  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    setIsPending(true);\n    setState({ error: null });\n\n    if (typeof window !== \"undefined\") {\n      window.sessionStorage.setItem(\n        PENDING_SIGNIN_STORAGE_KEY,\n        JSON.stringify({\n          method: \"credentials\",\n        }),\n      );\n    }\n\n    const { error } = await authClient.signIn.username({\n      username: usernameInput,\n      password: passwordInput,\n      callbackURL: callbackUrl,\n    });\n\n    if (error) {\n      if (typeof window !== \"undefined\") {\n        window.sessionStorage.removeItem(PENDING_SIGNIN_STORAGE_KEY);\n      }\n      setState({ error: error.message ?? \"Sign in error.\" });\n      setIsPending(false);\n      return;\n    }\n\n    window.location.href = callbackUrl;\n  };\n\n  return (\n    <>\n      <h1 className={authHeadingClass}>Log in</h1>\n      <p className={authParagraphClass}>\n        Attention, you cannot login with your Duolingo account.\n      </p>\n      <p className={authParagraphClass}>\n        You have to register for the unofficial stories separately, as they are\n        an independent project.\n      </p>\n      {state.error && (\n        <span className={authAlertErrorClass}>{state.error}</span>\n      )}\n      <form className=\"flex flex-col gap-2\" onSubmit={handleSubmit}>\n        <Input\n          data-cy=\"username\"\n          value={usernameInput}\n          onChange={usernameInputSetValue}\n          type=\"text\"\n          name=\"username\"\n          placeholder=\"Username\"\n        />\n        <Input\n          data-cy=\"password\"\n          value={passwordInput}\n          onChange={passwordInputSetValue}\n          type=\"password\"\n          name=\"password\"\n          placeholder=\"Password\"\n        />\n        <Button data-cy=\"submit\" variant=\"primary\">\n          {isPending ? <SpinnerBlue /> : \"Log in\"}\n        </Button>\n      </form>\n      <p className={authParagraphClass}>\n        {\"Don't have an account? \"}\n        <Link\n          href=\"/auth/register\"\n          data-cy=\"register-button\"\n          className={authInlineLinkClass}\n        >\n          Sign Up\n        </Link>\n        <br />\n        Forgot Password?{\" \"}\n        <Link\n          href=\"/auth/reset_pw\"\n          data-cy=\"reset-button\"\n          className={authInlineLinkClass}\n        >\n          Reset\n        </Link>\n      </p>\n      <hr className=\"relative my-3 h-0 w-full overflow-visible border-0 border-t-2 border-[var(--input-border)] before:relative before:top-[calc(-1em+2px)] before:bg-[var(--body-background)] before:px-[0.4em] before:text-[var(--input-border)] before:content-['or']\" />\n      <div className={providersClass}>\n        {providers.map((provider) => (\n          <Button\n            key={provider.id}\n            variant=\"outline\"\n            className=\"mb-2 w-full min-w-0 [&>span]:px-5\"\n            onClick={() => handleOAuthProviderClick(provider)}\n          >\n            <span className=\"flex w-full min-w-0 items-center justify-center gap-3 whitespace-nowrap leading-none\">\n              <span className=\"inline-flex shrink-0 items-center justify-center\">\n                <GetIcon name={provider.id} />\n              </span>\n              <span className=\"min-w-0 truncate max-sm:hidden\">\n                {provider.name}\n              </span>\n            </span>\n          </Button>\n        ))}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/auth/signin/page.tsx",
    "content": "import React from \"react\";\nimport { redirect } from \"next/navigation\";\nimport { LoginOptions } from \"./login_options\";\nimport { isAuthenticated } from \"@/lib/auth-server\";\n\nexport interface ProviderProps {\n  id: string;\n  name: string;\n}\n\nconst getEnv = (...keys: string[]) =>\n  keys.map((key) => process.env[key]).find((value) => value);\n\nconst hasProvider = (idKeys: string[], secretKeys: string[]) =>\n  Boolean(getEnv(...idKeys) && getEnv(...secretKeys));\n\nexport default async function Page({\n  searchParams,\n}: {\n  searchParams?: Promise<{ callbackUrl?: string | string[] }>;\n}) {\n  const resolvedSearchParams = searchParams ? await searchParams : undefined;\n  const session = await isAuthenticated();\n\n  // If the user is already logged in, redirect.\n  // Note: Make sure not to redirect to the same page\n  // To avoid an infinite loop!\n  if (session) {\n    redirect(\"/\");\n  }\n\n  const providers: ProviderProps[] = [];\n\n  if (\n    hasProvider(\n      [\"FACEBOOK_CLIENT_ID\", \"AUTH_FACEBOOK_ID\"],\n      [\"FACEBOOK_CLIENT_SECRET\", \"AUTH_FACEBOOK_SECRET\"],\n    )\n  ) {\n    providers.push({ id: \"facebook\", name: \"Facebook\" });\n  }\n\n  if (\n    hasProvider(\n      [\"GITHUB_CLIENT_ID\", \"GITHUB_ID\", \"AUTH_GITHUB_ID\"],\n      [\"GITHUB_CLIENT_SECRET\", \"GITHUB_SECRET\", \"AUTH_GITHUB_SECRET\"],\n    )\n  ) {\n    providers.push({ id: \"github\", name: \"GitHub\" });\n  }\n\n  if (\n    hasProvider(\n      [\"DISCORD_CLIENT_ID\", \"AUTH_DISCORD_CLIENT_ID\"],\n      [\"DISCORD_CLIENT_SECRET\", \"AUTH_DISCORD_CLIENT_SECRET\"],\n    )\n  ) {\n    providers.push({ id: \"discord\", name: \"Discord\" });\n  }\n\n  if (\n    hasProvider(\n      [\"GOOGLE_CLIENT_ID\", \"AUTH_GOOGLE_ID\"],\n      [\"GOOGLE_CLIENT_SECRET\", \"AUTH_GOOGLE_SECRET\"],\n    )\n  ) {\n    providers.push({ id: \"google\", name: \"Google\" });\n  }\n\n  const callbackUrl = Array.isArray(resolvedSearchParams?.callbackUrl)\n    ? resolvedSearchParams?.callbackUrl[0]\n    : resolvedSearchParams?.callbackUrl || \"/\";\n\n  return <LoginOptions providers={providers} callbackUrl={callbackUrl} />;\n}\n"
  },
  {
    "path": "src/app/dev/story-footer-button-test/page.module.css",
    "content": ".page {\n  background:\n    radial-gradient(\n      circle at top,\n      color-mix(in srgb, var(--link-blue) 10%, white) 0%,\n      transparent 42%\n    ),\n    linear-gradient(180deg, #f7fbff 0%, #eef4f8 100%);\n  min-height: 100vh;\n  padding: 48px 20px 72px;\n}\n\n.shell {\n  margin: 0 auto;\n  max-width: 1240px;\n}\n\n.header {\n  margin: 0 auto 28px;\n  max-width: 720px;\n  text-align: center;\n}\n\n.eyebrow {\n  color: #1cb0f6;\n  font-size: calc(13 / 16 * 1rem);\n  font-weight: 800;\n  letter-spacing: 0.16em;\n  margin: 0 0 10px;\n  text-transform: uppercase;\n}\n\n.header h1 {\n  font-size: clamp(2rem, 4vw, 3.4rem);\n  line-height: 1.05;\n  margin: 0;\n}\n\n.copy {\n  color: #5f6c76;\n  margin: 14px auto 0;\n  max-width: 620px;\n}\n\n.themeGrid {\n  display: grid;\n  gap: 24px;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n}\n\n.themePanel {\n  border: 1px solid color-mix(in srgb, var(--overview-hr) 85%, transparent);\n  border-radius: 32px;\n  box-shadow: 0 24px 60px rgba(15, 35, 47, 0.08);\n  color: var(--text-color);\n  overflow: hidden;\n  padding: 22px;\n  position: relative;\n}\n\n.themePanel::before {\n  background:\n    linear-gradient(\n      135deg,\n      color-mix(in srgb, var(--link-blue) 12%, transparent),\n      transparent 55%\n    ),\n    linear-gradient(\n      180deg,\n      color-mix(in srgb, var(--body-background) 92%, white),\n      var(--body-background)\n    );\n  content: \"\";\n  inset: 0;\n  pointer-events: none;\n  position: absolute;\n}\n\n.themePanel > * {\n  position: relative;\n}\n\n.themeLight,\n.themeDark {\n  --title-color-dim: #777;\n  --header-border: lightgrey;\n  --overview-hr: #e5e5e5;\n  --text-color: black;\n  --text-color-dim: #4c4c4c;\n  --body-background: white;\n  --button-background: #58cc02;\n  --button-color: white;\n  --button-border: #58a700;\n  --button-inactive-background: #e5e5e5;\n  --button-inactive-color: #afafaf;\n  --link-blue: #1cb0f6;\n  --body-background-faint: #f2f2f2;\n  background: var(--body-background);\n}\n\n.themeDark {\n  --header-border: #37464f;\n  --overview-hr: #3c474b;\n  --text-color: #f1f7fb;\n  --text-color-dim: #f1f7fb;\n  --body-background: #131f22;\n  --button-color: #131f22;\n  --button-blue-background: #131f22;\n  --button-blue-border: #37464f;\n  --button-blue-color: #49c0f8;\n  --language-selector-hover-background: #626b70;\n  --body-background-faint: #202f36;\n}\n\n.themeHeader {\n  align-items: flex-start;\n  display: flex;\n  justify-content: space-between;\n  gap: 16px;\n  margin-bottom: 18px;\n}\n\n.themeEyebrow {\n  color: var(--title-color-dim);\n  font-size: calc(12 / 16 * 1rem);\n  font-weight: 800;\n  letter-spacing: 0.12em;\n  margin: 0 0 6px;\n  text-transform: uppercase;\n}\n\n.themeHeader h2 {\n  margin: 0;\n}\n\n.themeBadge {\n  background: color-mix(in srgb, var(--link-blue) 12%, var(--body-background));\n  border: 1px solid color-mix(in srgb, var(--link-blue) 24%, var(--overview-hr));\n  border-radius: 999px;\n  color: var(--text-color);\n  font-size: calc(12 / 16 * 1rem);\n  font-weight: 800;\n  letter-spacing: 0.08em;\n  padding: 8px 12px;\n  text-transform: uppercase;\n}\n\n.stateGrid {\n  display: grid;\n  gap: 16px;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n}\n\n.variantGrid {\n  display: grid;\n  gap: 16px;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n  margin-bottom: 18px;\n}\n\n.footerGrid {\n  display: grid;\n  gap: 16px;\n  grid-template-columns: 1fr;\n  margin-top: 4px;\n}\n\n.stateCard,\n.footerCard,\n.liveCard {\n  background: color-mix(in srgb, var(--body-background) 90%, transparent);\n  border: 1px solid color-mix(in srgb, var(--overview-hr) 86%, transparent);\n  border-radius: 24px;\n  padding: 18px;\n}\n\n.liveCard {\n  margin-top: 16px;\n}\n\n.subsectionHeader {\n  margin-bottom: 12px;\n}\n\n.subsectionHeader h3 {\n  font-size: calc(18 / 16 * 1rem);\n  margin: 0 0 4px;\n}\n\n.subsectionHeader span {\n  color: var(--text-color-dim);\n  font-size: calc(14 / 16 * 1rem);\n}\n\n.stateLabelRow h3 {\n  font-size: calc(18 / 16 * 1rem);\n  margin: 0 0 4px;\n}\n\n.stateLabelRow span {\n  color: var(--text-color-dim);\n  display: block;\n  font-size: calc(14 / 16 * 1rem);\n  line-height: 1.45;\n}\n\n.buttonStage {\n  align-items: center;\n  background: linear-gradient(\n    180deg,\n    color-mix(in srgb, var(--body-background) 96%, white),\n    var(--body-background-faint)\n  );\n  border: 1px dashed color-mix(in srgb, var(--overview-hr) 90%, transparent);\n  border-radius: 20px;\n  display: flex;\n  justify-content: center;\n  margin-top: 16px;\n  min-height: 120px;\n  padding: 20px;\n}\n\n.footerStage {\n  margin-top: 16px;\n}\n\n.footerFrame {\n  align-items: center;\n  background: var(--body-background);\n  border-top: 2px solid var(--header-border);\n  display: flex;\n  justify-content: flex-end;\n  min-height: 140px;\n  padding: 30px;\n}\n\n.previewHover > span {\n  background: color-mix(in srgb, var(--link-blue) 8%, var(--body-background));\n  border-color: color-mix(in srgb, var(--link-blue) 28%, var(--overview-hr));\n  color: var(--text-color);\n  transform: translateY(-5px);\n}\n\n.previewActive > span {\n  transform: translateY(-0.5px);\n}\n\n.previewFocus {\n  outline: 3px solid color-mix(in srgb, var(--link-blue) 35%, transparent);\n  outline-offset: 3px;\n}\n\n@media (max-width: 960px) {\n  .themeGrid {\n    grid-template-columns: 1fr;\n  }\n}\n\n@media (max-width: 640px) {\n  .page {\n    padding: 28px 14px 42px;\n  }\n\n  .themePanel {\n    border-radius: 24px;\n    padding: 16px;\n  }\n\n  .stateGrid {\n    grid-template-columns: 1fr;\n  }\n\n  .variantGrid {\n    grid-template-columns: 1fr;\n  }\n\n  .footerFrame {\n    min-height: auto;\n    padding: 18px 16px;\n  }\n\n  .themeHeader {\n    align-items: flex-start;\n    flex-direction: column;\n  }\n}\n"
  },
  {
    "path": "src/app/dev/story-footer-button-test/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport Button from \"@/components/ui/button\";\nimport styles from \"./page.module.css\";\n\nexport const metadata: Metadata = {\n  title: \"Story Footer Button Test\",\n  robots: {\n    index: false,\n    follow: false,\n  },\n};\n\nconst buttonStates = [\n  {\n    className: \"\",\n    description: \"Base state in the finished footer.\",\n    label: \"Default\",\n  },\n  {\n    className: styles.previewHover,\n    description: \"Pointer hover styling.\",\n    label: \"Hover\",\n  },\n  {\n    className: styles.previewActive,\n    description: \"Pressed state with reduced depth.\",\n    label: \"Active\",\n  },\n  {\n    className: styles.previewFocus,\n    description: \"Keyboard focus-visible ring.\",\n    label: \"Focus\",\n  },\n] as const;\n\nconst variants = [\n  {\n    description: \"Primary green CTA used for default story progression.\",\n    label: \"default\",\n    variant: \"default\",\n  },\n  {\n    description: \"Blue CTA variant used in auth and key story entry points.\",\n    label: \"primary\",\n    variant: \"primary\",\n  },\n  {\n    description:\n      \"Neutral raised secondary action, now used for Back to overview.\",\n    label: \"secondary\",\n    variant: \"secondary\",\n  },\n  {\n    description: \"Neutral outline action for cases like OAuth providers.\",\n    label: \"outline\",\n    variant: \"outline\",\n  },\n  {\n    description: \"Low-emphasis background treatment for lightweight actions.\",\n    label: \"ghost\",\n    variant: \"ghost\",\n  },\n  {\n    description: \"Inline text-style action for low-friction navigation.\",\n    label: \"link\",\n    variant: \"link\",\n  },\n] as const;\n\nconst themes = [\n  {\n    className: styles.themeLight,\n    label: \"Light mode\",\n  },\n  {\n    className: styles.themeDark,\n    label: \"Dark mode\",\n  },\n] as const;\n\nconst footerWaitVariants = [\n  {\n    description: \"The disabled wait-state footer action.\",\n    disabled: true,\n    label: \"wait\",\n    variant: \"default\",\n  },\n  {\n    description: \"The normal enabled continue action.\",\n    disabled: false,\n    label: \"continue\",\n    variant: \"default\",\n  },\n  {\n    description: \"The blue-toned primary variant in footer framing.\",\n    disabled: false,\n    label: \"primary\",\n    variant: \"primary\",\n  },\n] as const;\n\nexport default function Page() {\n  return (\n    <main className={styles.page}>\n      <div className={styles.shell}>\n        <header className={styles.header}>\n          <p className={styles.eyebrow}>UI test page</p>\n          <h1>Shared button variants and footer states</h1>\n          <p className={styles.copy}>\n            This page compares the shared action variants and isolates the\n            finished-story secondary state so contrast and interaction behavior\n            can be checked quickly in both themes.\n          </p>\n        </header>\n\n        <div className={styles.themeGrid}>\n          {themes.map((theme) => (\n            <section\n              key={theme.label}\n              className={`${styles.themePanel} ${theme.className}`}\n            >\n              <div className={styles.themeHeader}>\n                <div>\n                  <p className={styles.themeEyebrow}>Preview</p>\n                  <h2>{theme.label}</h2>\n                </div>\n                <span className={styles.themeBadge}>Back to overview</span>\n              </div>\n\n              <div className={styles.variantGrid}>\n                {variants.map((variant) => (\n                  <article key={variant.label} className={styles.stateCard}>\n                    <div className={styles.stateLabelRow}>\n                      <h3>{variant.label}</h3>\n                      <span>{variant.description}</span>\n                    </div>\n                    <div className={styles.buttonStage}>\n                      <Button variant={variant.variant}>Example action</Button>\n                    </div>\n                  </article>\n                ))}\n              </div>\n\n              <div className={styles.subsectionHeader}>\n                <h3>Disabled variants</h3>\n                <span>Disabled snapshots for every shared button variant.</span>\n              </div>\n\n              <div className={styles.variantGrid}>\n                {variants.map((variant) => (\n                  <article\n                    key={`${theme.label}-${variant.label}-disabled`}\n                    className={styles.stateCard}\n                  >\n                    <div className={styles.stateLabelRow}>\n                      <h3>{variant.label}</h3>\n                      <span>{variant.description}</span>\n                    </div>\n                    <div className={styles.buttonStage}>\n                      <Button disabled variant={variant.variant}>\n                        Example action\n                      </Button>\n                    </div>\n                  </article>\n                ))}\n              </div>\n\n              <div className={styles.subsectionHeader}>\n                <h3>Secondary state breakdown</h3>\n                <span>\n                  Static snapshots of the footer action state treatment.\n                </span>\n              </div>\n\n              <div className={styles.stateGrid}>\n                {buttonStates.map((state) => (\n                  <article key={state.label} className={styles.stateCard}>\n                    <div className={styles.stateLabelRow}>\n                      <h3>{state.label}</h3>\n                      <span>{state.description}</span>\n                    </div>\n                    <div className={styles.buttonStage}>\n                      <Button\n                        variant=\"secondary\"\n                        className={state.className}\n                        type=\"button\"\n                      >\n                        Back to overview\n                      </Button>\n                    </div>\n                  </article>\n                ))}\n              </div>\n\n              <div className={styles.liveCard}>\n                <div className={styles.stateLabelRow}>\n                  <h3>Interactive</h3>\n                  <span>\n                    Live control for manual hover, click, and focus tests.\n                  </span>\n                </div>\n                <div className={styles.buttonStage}>\n                  <Button variant=\"secondary\" type=\"button\">\n                    Back to overview\n                  </Button>\n                </div>\n              </div>\n\n              <div className={styles.subsectionHeader}>\n                <h3>Footer button state</h3>\n                <span>\n                  Continue-button snapshots inside footer-style framing.\n                </span>\n              </div>\n\n              <div className={styles.footerGrid}>\n                {footerWaitVariants.map((footerVariant) => (\n                  <article\n                    key={`${theme.label}-${footerVariant.label}-footer`}\n                    className={styles.footerCard}\n                  >\n                    <div className={styles.stateLabelRow}>\n                      <h3>{footerVariant.label}</h3>\n                      <span>{footerVariant.description}</span>\n                    </div>\n                    <div className={styles.footerStage}>\n                      <div className={styles.footerFrame}>\n                        <Button\n                          disabled={footerVariant.disabled}\n                          variant={footerVariant.variant}\n                        >\n                          Continue\n                        </Button>\n                      </div>\n                    </div>\n                  </article>\n                ))}\n              </div>\n            </section>\n          ))}\n        </div>\n      </div>\n    </main>\n  );\n}\n"
  },
  {
    "path": "src/app/docs/[[...slug]]/doc_data.ts",
    "content": "import fs from \"node:fs/promises\";\nimport React from \"react\";\n\nconst basefolder = \"public/docs\";\n\nexport async function getPageData(path: string) {\n  try {\n    const res = await fs.readFile(basefolder + \"/\" + path + \".mdx\", \"utf8\");\n    const data = res.split(\"---\");\n    const metadata: Record<string, string> = {};\n    for (const line of data[1].split(\"\\n\")) {\n      const pos = line.indexOf(\":\");\n      if (pos === -1) continue;\n      const key = line.slice(0, pos).trim();\n      const value = line.slice(pos + 1).trim();\n      metadata[key.trim()] = (value.match(/\\s*\"(.*)\"\\s*/) || [\"\", \"\"])[1];\n    }\n    metadata.body = data[2];\n    return metadata;\n  } catch {\n    return { title: path, body: \"\" };\n  }\n}\n\ninterface DocData {\n  navigation: {\n    group: string;\n    pages: { slug: string; title: string }[];\n  }[];\n}\n\nexport const getDocsData = React.cache(async () => {\n  const data = JSON.parse(await fs.readFile(basefolder + \"/docs.json\", \"utf8\"));\n\n  for (const group of data[\"navigation\"]) {\n    const pages_new = [];\n    for (const page of group.pages) {\n      const d = await getPageData(page);\n      pages_new.push({ slug: page, title: d.title });\n    }\n    group.pages = pages_new;\n  }\n  return data as DocData;\n});\n"
  },
  {
    "path": "src/app/docs/[[...slug]]/page.tsx",
    "content": "import React from \"react\";\nimport Link from \"next/link\";\nimport { notFound } from \"next/navigation\";\nimport { MDXRemote } from \"next-mdx-remote/rsc\";\n\nimport { getDocsData, getPageData } from \"./doc_data\";\nimport { MDXComponents } from \"mdx/types\";\nimport CustomMDXServer from \"@/components/Docs/CustomMDXServer\";\nimport {\n  docsAlertBoxClass,\n  docsChannelLinkClass,\n  docsEditButtonClass,\n  docsEditButtonContainerClass,\n  docsFooterClass,\n  docsFooterLinkClass,\n  docsHeaderIntroClass,\n  docsImageWrapperClass,\n  docsInfoBoxClass,\n  docsPageMainClass,\n  docsRightTocClass,\n  docsRightTocInnerClass,\n  docsWarningBoxClass,\n} from \"@/components/Docs/docsClasses\";\n\nexport const dynamic = \"force-static\";\nexport const dynamicParams = true;\n\nexport async function generateStaticParams() {\n  const data = await getDocsData();\n\n  const pages = [{ slug: [] as string[] }];\n  for (let group of data.navigation) {\n    for (let page of group.pages) {\n      pages.push({ slug: page.slug.split(\"/\") });\n    }\n  }\n\n  return pages;\n}\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ slug: string[] }>;\n}) {\n  const path = SlugToPath((await params).slug);\n  const data = await getPageData(path);\n\n  return {\n    title: data.title + \" | Duostories Docs\",\n    description: data.description,\n  };\n}\n\nfunction save_tag(tag: string) {\n  return tag.trim().toLowerCase().replace(/\\s+/g, \"-\");\n}\n\nconst components: MDXComponents = {\n  Info: (props) => (\n    <p {...props} className={docsInfoBoxClass}>\n      {props.children}\n    </p>\n  ),\n  Warning: (props) => (\n    <p {...props} className={docsWarningBoxClass}>\n      {props.children}\n    </p>\n  ),\n  Alert: (props) => (\n    <p {...props} className={docsAlertBoxClass}>\n      {props.children}\n    </p>\n  ),\n  Channel: (props) => (\n    <Link {...props} className={docsChannelLinkClass}>\n      {props.children}\n    </Link>\n  ),\n  a: (props) => <Link href={props.href as string}>{props.children}</Link>,\n  Image: (props) => (\n    <div className={docsImageWrapperClass}>{props.children}</div>\n  ),\n  h3: (props) => (\n    <h3 {...props} id={save_tag(props.children as string)}>\n      {props.children}\n    </h3>\n  ),\n};\n\nfunction CustomMDX(props: { source: string }) {\n  return (\n    <MDXRemote\n      {...props}\n      components={components} //...(props.components || {})\n    />\n  );\n}\n\nfunction SlugToPath(slug: string[]) {\n  let path = \"\";\n  for (const p of slug || [\"introduction\"]) {\n    if (p.indexOf(\".\") !== -1) continue;\n    path += \"/\" + p;\n  }\n  if (path.endsWith(\".js\") || path.endsWith(\".mdx\")) return notFound();\n  return path;\n}\n\nexport interface Heading {\n  id: string;\n  text: string;\n  level: number;\n}\n\nfunction getHeadings(title: string, body: string) {\n  const headings: Heading[] = [{ id: save_tag(title), level: 1, text: title }];\n  for (const line of body.split(\"\\n\")) {\n    if (line.startsWith(\"#\")) {\n      const [, count, text] = line.match(\"(#*)s*(.*)\") || [\"\", \"####\", \"\"];\n      headings.push({ id: save_tag(text), level: count.length, text: text });\n    }\n  }\n  return headings;\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ slug: string[] }>;\n}) {\n  const path = SlugToPath((await params).slug);\n  let data = await getPageData(path);\n  let doc_data = await getDocsData();\n  let previous = null;\n  let found = false;\n  let next = null;\n  for (let group of doc_data.navigation) {\n    for (let page of group.pages) {\n      if (!next && found) next = page.slug;\n      if (\"/\" + page.slug === path) {\n        data.group = group.group;\n        found = true;\n      }\n      if (!found) {\n        previous = page.slug;\n      }\n    }\n  }\n  let previousData = await getPageData(\"/\" + previous);\n  let nextData = await getPageData(\"/\" + next);\n\n  const headings = getHeadings(data.title, data.body);\n\n  return (\n    <>\n      <div className={docsPageMainClass}>\n        <header id=\"header\" className={docsHeaderIntroClass}>\n          <div className=\"mb-2 text-[0.95rem] font-bold tracking-[0.01em] text-gray-500\">\n            {data.group}\n          </div>\n          <h1 className=\"m-0\">{data.title}</h1>\n          <p className=\"mt-2.5 mb-0 max-w-[70ch] text-gray-600 max-[640px]:mt-2\">\n            {data.description}\n          </p>\n        </header>\n        <CustomMDXServer source={data.body} />\n        {/*<CustomMDX source={data.body} />*/}\n        <div className={docsEditButtonContainerClass}>\n          <Link\n            className={docsEditButtonClass}\n            href={`https://github.com/${process.env.VERCEL_GIT_REPO_OWNER}/${process.env.VERCEL_GIT_REPO_SLUG}/edit/${process.env.VERCEL_GIT_COMMIT_REF}/public/docs${path}.mdx`}\n          >\n            <small>Suggest edits</small>\n          </Link>\n        </div>\n        <footer className={docsFooterClass}>\n          {previous ? (\n            <Link className={docsFooterLinkClass} href={\"/docs/\" + previous}>\n              <span className=\"mr-[10px]\">‹</span>\n              {previousData.title}\n            </Link>\n          ) : (\n            <span></span>\n          )}\n          {next ? (\n            <Link className={docsFooterLinkClass} href={\"/docs/\" + next}>\n              {nextData.title}\n              <span className=\"ml-[10px]\">›</span>\n            </Link>\n          ) : (\n            <span></span>\n          )}\n        </footer>\n        <hr />\n      </div>\n      <div className={docsRightTocClass}>\n        <div className={docsRightTocInnerClass}>\n          {headings.map((h, i) => (\n            <p key={i}>\n              <a href={\"#\" + save_tag(h.text)}>{h.text}</a>\n            </p>\n          ))}\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/docs/layout.tsx",
    "content": "import DocsHeader from \"@/components/DocsHeader\";\nimport DocsBreadCrumbNav from \"@/components/DocsBreadCrumbNav\";\nimport DocsNavigation from \"@/components/DocsNavigation\";\nimport React from \"react\";\nimport { getDocsData, getPageData } from \"./[[...slug]]/doc_data\";\nimport DocsNavigationBackdrop from \"@/components/DocsNavigationBackdrop\";\nimport {\n  docsMainContainerClass,\n  docsRootClass,\n} from \"@/components/Docs/docsClasses\";\n\nexport default async function Layout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const data = await getDocsData();\n\n  const path_titles: Record<string, { group: string; title: string }> = {};\n  for (let group of data.navigation) {\n    for (let page of group.pages) {\n      const datax = await getPageData(page.slug);\n      path_titles[page.slug] = {\n        group: group.group,\n        title: datax.title,\n      };\n    }\n  }\n\n  return (\n    <div className={docsRootClass} id=\"container\">\n      <DocsNavigationBackdrop>\n        <DocsHeader />\n        <DocsBreadCrumbNav path_titles={path_titles} />\n\n        <div className={docsMainContainerClass}>\n          <DocsNavigation data={data} />\n          {children}\n        </div>\n      </DocsNavigationBackdrop>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/docs/loading.tsx",
    "content": "import React from \"react\";\nimport {\n  docsPageMainClass,\n  docsRightTocClass,\n  docsRightTocInnerClass,\n} from \"@/components/Docs/docsClasses\";\n\nexport default function Loading() {\n  return (\n    <>\n      <div className={docsPageMainClass}>\n        <header id=\"header\">\n          <div></div>\n          <h1>Loading...</h1>\n          <div></div>\n        </header>\n      </div>\n      <div className={docsRightTocClass}>\n        <div className={docsRightTocInnerClass}></div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/import/[from_id]/import_list.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport { useMutation, useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport { SpinnerBlue } from \"@/components/ui/spinner\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { useRouter } from \"next/navigation\";\nimport { CourseImportProps } from \"@/app/editor/(course)/types\";\n\nexport default function ImportList({\n  courseId,\n  fromId,\n}: {\n  courseId: string;\n  fromId: string;\n}) {\n  const course = useQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: courseId,\n  });\n  const courseFrom = useQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: fromId,\n  });\n  const imports = useQuery(\n    api.editorRead.getEditorCourseImport,\n    course && courseFrom\n      ? { courseLegacyId: course.id, fromLegacyId: courseFrom.id }\n      : \"skip\",\n  );\n  const [importing, setImporting] = React.useState<number | undefined>(\n    undefined,\n  );\n  const importStoryMutation = useMutation(api.storyWrite.importStory);\n  const router = useRouter();\n\n  if (\n    course === undefined ||\n    courseFrom === undefined ||\n    imports === undefined\n  ) {\n    return <Spinner />;\n  }\n\n  if (!course || !courseFrom) {\n    return <p>Course not found.</p>;\n  }\n\n  const courseLegacyId = course.id;\n  const courseShort = course.short;\n\n  async function do_import(id: number) {\n    // prevent clicking the button twice\n    if (importing) return;\n    setImporting(id);\n\n    const response = await importStoryMutation({\n      sourceLegacyStoryId: id,\n      targetLegacyCourseId: courseLegacyId,\n      operationKey: `story:${id}:import_to:${courseLegacyId}:client`,\n    });\n    if (!response) {\n      setImporting(undefined);\n      return;\n    }\n    const id2 = response.id;\n    await router.push(`/editor/course/${courseShort}/story/${id2}`);\n  }\n\n  return (\n    <>\n      <div>\n        Importing from {courseFrom.learning_language_name} (from{\" \"}\n        {courseFrom.from_language_name}).\n      </div>\n      <table\n        className=\"story_list js-sort-table js-sort-5 js-sort-desc mb-[100px] w-full border-collapse [&_a]:font-bold [&_a]:text-[var(--text-color)] [&_th]:bg-[var(--button-background)] [&_th]:px-[5px] [&_th]:pb-[5px] [&_th]:pt-[5px] [&_th]:text-left [&_th]:text-[var(--button-color)] [&_td]:px-[5px] [&_td]:py-[5px] [&_td:nth-child(2)]:w-[44px] [&_td:nth-child(2)]:min-w-[44px] [&_td:nth-child(2)]:max-w-[44px] [&_td:nth-child(2)_img]:h-[40px] [&_td:nth-child(2)_img]:w-[44px] [&_td:nth-child(2)_img]:max-w-none [&_tr:nth-child(2n)]:bg-[var(--body-background-faint)]\"\n        data-cy=\"story_list\"\n        data-js-sort-table=\"true\"\n      >\n        <thead>\n          <tr>\n            <th data-js-sort-colnum=\"0\">Set</th>\n            <th style={{ width: \"100%\" }} colSpan={2} data-js-sort-colnum=\"1\">\n              Name\n            </th>\n            <th data-js-sort-colnum=\"5\" className=\"js-sort-active\">\n              Copies\n            </th>\n          </tr>\n        </thead>\n        <tbody>\n          {(imports as CourseImportProps[]).map((story, i) => (\n            <tr key={story.id} className={\"\"}>\n              <td>\n                <span>\n                  <b>{pad(story.set_id)}</b>&nbsp;-&nbsp;{pad(story.set_index)}\n                </span>\n              </td>\n              <td className=\"w-[44px] min-w-[44px] max-w-[44px]\">\n                <img\n                  alt={\"story title\"}\n                  src={\n                    parseInt(story.copies) > 0 ? story.image_done : story.image\n                  }\n                  width={44}\n                  height={40}\n                  className=\"block h-[40px] w-[44px] max-w-none\"\n                />\n              </td>\n              <td style={{ width: \"100%\" }}>\n                {importing === story.id ? (\n                  <span>\n                    Importing <SpinnerBlue />\n                  </span>\n                ) : (\n                  <a\n                    href={`#`}\n                    title={story.name}\n                    onClick={(event) => {\n                      event.preventDefault();\n                      void do_import(story.id);\n                    }}\n                  >\n                    {story.name}\n                  </a>\n                )}\n              </td>\n              <td>{story.copies}x</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </>\n  );\n}\n\nfunction pad(x: number) {\n  if (x < 10) return \"0\" + x;\n  return x.toString();\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/import/[from_id]/page.tsx",
    "content": "import { notFound } from \"next/navigation\";\nimport React from \"react\";\nimport { Metadata } from \"next\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\nimport ImportPageClient from \"./page_client\";\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ course_id: string; from_id: string }>;\n}): Promise<Metadata> {\n  const course = await fetchQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: (await params).course_id,\n  });\n\n  if (!course) notFound();\n\n  return {\n    title: `Import | ${course.learning_language_name} (from ${course.from_language_name}) | Duostories Editor`,\n    alternates: {\n      canonical: `https://duostories.org/editor/${course.short}`,\n    },\n  };\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ course_id: string; from_id: string }>;\n}) {\n  const p = await params;\n  return <ImportPageClient courseId={p.course_id} fromId={p.from_id} />;\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/import/[from_id]/page_client.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport Link from \"next/link\";\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport LanguageFlag from \"@/components/ui/language-flag\";\nimport ImportList from \"./import_list\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport type { CourseProps } from \"@/app/editor/(course)/types\";\n\nexport default function ImportPageClient({\n  courseId,\n  fromId,\n}: {\n  courseId: string;\n  fromId: string;\n}) {\n  const sidebarData = useQuery(api.editorRead.getEditorSidebarData, {});\n  const course = useQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: courseId,\n  });\n\n  if (sidebarData === undefined || course === undefined) {\n    return <Spinner />;\n  }\n\n  if (!course) {\n    return <p>Course not found.</p>;\n  }\n\n  const courses = (sidebarData.courses ?? []) as CourseProps[];\n\n  const courseSelection: CourseProps[] = [];\n  for (const item of courses) {\n    if (item.short === \"es-en\") courseSelection.push(item);\n  }\n  for (const item of courses) {\n    if (item.short === \"en-es-o\") courseSelection.push(item);\n  }\n  for (const item of courses) {\n    if (item.official && item.short !== \"es-en\" && item.short !== \"en-es-o\") {\n      courseSelection.push(item);\n    }\n  }\n\n  return (\n    <>\n      <div className=\"flex gap-3 overflow-scroll whitespace-nowrap p-1\">\n        {courseSelection.map((item, index) => (\n          <Link\n            key={index}\n            href={`/editor/course/${course.short}/import/${item.short}`}\n          >\n            <span className=\"flex items-center flex-col rounded-lg bg-[var(--body-background)] px-2 py-1 hover:brightness-90\">\n              <span className=\"flex\">\n                <LanguageFlag languageId={item.learningLanguageId} width={40} />\n                <LanguageFlag\n                  languageId={item.fromLanguageId}\n                  width={36}\n                  className=\"ml-[-28px] mt-[10px]\"\n                />\n              </span>\n              <span>\n                {item.from_language_short}-{item.learning_language_short}\n              </span>\n            </span>\n          </Link>\n        ))}\n      </div>\n      <ImportList courseId={courseId} fromId={fromId} />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/localization/page.tsx",
    "content": "import React from \"react\";\nimport { notFound } from \"next/navigation\";\nimport { Metadata } from \"next\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\nimport CourseLocalizationPageClient from \"./page_client\";\n\nfunction getCanonicalLocalizationPath(courseShort: string) {\n  return `/editor/course/${courseShort}/localization`;\n}\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ course_id: string }>;\n}): Promise<Metadata> {\n  const courseId = (await params).course_id;\n  const course = await fetchQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: courseId,\n  });\n\n  if (!course) notFound();\n\n  return {\n    title: `Localization | ${course.learning_language_name} (from ${course.from_language_name}) | Duostories Editor`,\n    alternates: {\n      canonical: `https://duostories.org${getCanonicalLocalizationPath(course.short ?? courseId)}`,\n    },\n  };\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ course_id: string }>;\n}) {\n  return <CourseLocalizationPageClient courseId={(await params).course_id} />;\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/localization/page_client.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { Breadcrumbs } from \"@/app/editor/_components/breadcrumbs\";\nimport { EditorHeaderBreadcrumbs } from \"@/app/editor/_components/header_context\";\nimport LocalizationEditor from \"@/app/editor/localization/[language]/localization_editor\";\nimport type { DetailedCourseProps } from \"@/app/editor/(course)/types\";\n\nexport default function CourseLocalizationPageClient({\n  courseId,\n}: {\n  courseId: string;\n}) {\n  const course = useQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: courseId,\n  }) as DetailedCourseProps | null | undefined;\n\n  if (course === undefined) return <Spinner />;\n  if (!course) return <p>Course not found.</p>;\n\n  return (\n    <>\n      <EditorHeaderBreadcrumbs>\n        <Breadcrumbs\n          path={[\n            { type: \"Editor\", href: `/editor` },\n            { type: \"sep\" },\n            {\n              type: \"course\",\n              lang1: {\n                languageId: course.learningLanguageId,\n                name: course.learning_language_name,\n              },\n              lang2: {\n                languageId: course.fromLanguageId,\n                name: course.from_language_name,\n              },\n              href: `/editor/course/${course.short}`,\n            },\n            { type: \"sep\" },\n            { type: \"Localization\" },\n          ]}\n        />\n      </EditorHeaderBreadcrumbs>\n      <LocalizationEditor identifier={courseId} renderHeader={false} />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/page.tsx",
    "content": "import { notFound } from \"next/navigation\";\nimport { Metadata } from \"next\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\nimport CourseEditorPageClient from \"./page_client\";\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ course_id: string }>;\n}): Promise<Metadata> {\n  const course = await fetchQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: (await params).course_id,\n  });\n\n  if (!course) notFound();\n\n  return {\n    title: `${course.learning_language_name} (from ${course.from_language_name}) | Duostories Editor`,\n    alternates: {\n      canonical: `https://duostories.org/editor/${course.short}`,\n    },\n  };\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ course_id: string }>;\n}) {\n  return <CourseEditorPageClient courseId={(await params).course_id} />;\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/page_client.tsx",
    "content": "\"use client\";\n\nimport React, { useEffect, useRef } from \"react\";\nimport { useMutation, useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport EditList from \"../../edit_list\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport type {\n  DetailedCourseProps,\n  StoryListDataProps,\n} from \"@/app/editor/(course)/types\";\n\nexport default function CourseEditorPageClient({\n  courseId,\n}: {\n  courseId: string;\n}) {\n  const recomputePublishedCount = useMutation(\n    api.courseWrite.recomputePublishedCount,\n  );\n  const course = useQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: courseId,\n  });\n\n  const stories = useQuery(api.editorRead.getEditorStoriesByCourseLegacyId, {\n    identifier: courseId,\n  });\n  const attemptedMismatchRef = useRef<string | null>(null);\n\n  useEffect(() => {\n    if (!course || stories === undefined) return;\n\n    const expectedPublishedCount = stories.filter(\n      (story) => story.public,\n    ).length;\n    if (course.count === expectedPublishedCount) {\n      attemptedMismatchRef.current = null;\n      return;\n    }\n\n    const mismatchKey = `${course.id}:${course.count}:${expectedPublishedCount}`;\n    if (attemptedMismatchRef.current === mismatchKey) return;\n    attemptedMismatchRef.current = mismatchKey;\n\n    void recomputePublishedCount({\n      legacyCourseId: course.id,\n    }).catch((error) => {\n      console.error(\"Failed to recompute published story count\", error);\n    });\n  }, [course, recomputePublishedCount, stories]);\n\n  if (course === undefined || stories === undefined) {\n    return <Spinner />;\n  }\n\n  if (!course) {\n    return <p>Course not found.</p>;\n  }\n\n  return (\n    <EditList\n      stories={(stories ?? []) as StoryListDataProps[]}\n      course={course as DetailedCourseProps}\n    />\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/story/[story]/audio-cutter/page.tsx",
    "content": "import React from \"react\";\nimport AudioCutterPageClient from \"@/app/editor/(course)/course/[course_id]/story/[story]/audio-cutter/page_client\";\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ course_id: string; story: string }>;\n}) {\n  const resolvedParams = await params;\n\n  return (\n    <AudioCutterPageClient\n      courseId={resolvedParams.course_id}\n      storyId={Number(resolvedParams.story)}\n    />\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/story/[story]/audio-cutter/page_client.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { EditorState } from \"@codemirror/state\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { useMutation, useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport AudioCutterDialog from \"@/app/editor/story/[story]/audio-cutter-dialog\";\nimport {\n  loadAudioCutterTranscript,\n  storeAudioCutterTranscript,\n  type AudioCutterPreparedSegment,\n  type AudioCutterTranscriptItem,\n} from \"@/app/editor/story/[story]/audio-cutter-storage\";\nimport type {\n  DetailedCourseProps,\n  StoryListDataProps,\n} from \"@/app/editor/(course)/types\";\nimport type {\n  Avatar,\n  StoryEditorPageData,\n} from \"@/app/editor/story/[story]/types\";\nimport { Breadcrumbs } from \"@/app/editor/_components/breadcrumbs\";\nimport {\n  EditorHeaderActions,\n  EditorHeaderBreadcrumbs,\n} from \"@/app/editor/_components/header_context\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { processStoryFile } from \"@/components/editor/story/syntax_parser_new\";\nimport type {\n  StoryElement,\n  StoryElementHeader,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport { timings_to_text } from \"@/lib/editor/audio/audio_edit_tools\";\n\nexport default function AudioCutterPageClient({\n  storyId,\n  courseId,\n}: {\n  storyId: number;\n  courseId: string;\n}) {\n  const router = useRouter();\n  const [transcriptItems, setTranscriptItems] = React.useState<\n    AudioCutterTranscriptItem[]\n  >([]);\n  const [saveProgress, setSaveProgress] = React.useState<{\n    total: number;\n    uploaded: number;\n    phase: \"idle\" | \"uploading\" | \"saving\";\n  }>({\n    total: 0,\n    uploaded: 0,\n    phase: \"idle\",\n  });\n  const [saveSuccessOpen, setSaveSuccessOpen] = React.useState(false);\n  const setStoryMutation = useMutation(api.storyWrite.setStory);\n  const data = useQuery(api.editorRead.getEditorStoryPageData, {\n    storyId,\n  }) as StoryEditorPageData | null | undefined;\n  const effectiveCourseId =\n    data?.story_data.short && data.story_data.short !== courseId\n      ? data.story_data.short\n      : courseId;\n  const course = useQuery(\n    api.editorRead.getEditorCourseByIdentifier,\n    effectiveCourseId ? { identifier: effectiveCourseId } : \"skip\",\n  ) as DetailedCourseProps | null | undefined;\n  const stories = useQuery(\n    api.editorRead.getEditorStoriesByCourseLegacyId,\n    effectiveCourseId ? { identifier: effectiveCourseId } : \"skip\",\n  ) as StoryListDataProps[] | undefined;\n  const avatarRows = useQuery(\n    api.editorRead.getEditorAvatarNamesByLanguageLegacyId,\n    data ? { languageLegacyId: data.story_data.learning_language } : \"skip\",\n  ) as Avatar[] | undefined;\n  const learningLanguage = useQuery(\n    api.editorRead.getEditorLanguageByLegacyId,\n    data ? { legacyLanguageId: data.story_data.learning_language } : \"skip\",\n  ) as LanguageData | null | undefined;\n  const fromLanguage = useQuery(\n    api.editorRead.getEditorLanguageByLegacyId,\n    data ? { legacyLanguageId: data.story_data.from_language } : \"skip\",\n  ) as LanguageData | null | undefined;\n\n  React.useEffect(() => {\n    const syncFromStorage = () => {\n      setTranscriptItems(loadAudioCutterTranscript(storyId));\n    };\n\n    syncFromStorage();\n    window.addEventListener(\"focus\", syncFromStorage);\n\n    return () => {\n      window.removeEventListener(\"focus\", syncFromStorage);\n    };\n  }, [storyId]);\n\n  const storyIndex =\n    stories?.findIndex((story) => story.id === data?.story_data.id) ?? -1;\n  const previousStory = storyIndex > 0 ? stories?.[storyIndex - 1] : null;\n  const nextStory =\n    storyIndex >= 0 && stories && storyIndex < stories.length - 1\n      ? stories[storyIndex + 1]\n      : null;\n  const nextStoryData = useQuery(\n    api.editorRead.getEditorStoryPageData,\n    nextStory ? { storyId: nextStory.id } : \"skip\",\n  ) as StoryEditorPageData | null | undefined;\n  const nextAvatarRows = useQuery(\n    api.editorRead.getEditorAvatarNamesByLanguageLegacyId,\n    nextStoryData\n      ? { languageLegacyId: nextStoryData.story_data.learning_language }\n      : \"skip\",\n  ) as Avatar[] | undefined;\n  const nextLearningLanguage = useQuery(\n    api.editorRead.getEditorLanguageByLegacyId,\n    nextStoryData\n      ? { legacyLanguageId: nextStoryData.story_data.learning_language }\n      : \"skip\",\n  ) as LanguageData | null | undefined;\n  const nextFromLanguage = useQuery(\n    api.editorRead.getEditorLanguageByLegacyId,\n    nextStoryData\n      ? { legacyLanguageId: nextStoryData.story_data.from_language }\n      : \"skip\",\n  ) as LanguageData | null | undefined;\n  const coursePathId = course?.short ?? effectiveCourseId;\n  const saveProgressLabel =\n    saveProgress.phase === \"uploading\"\n      ? `Saving to story (${saveProgress.uploaded}/${saveProgress.total} uploaded)...`\n      : saveProgress.phase === \"saving\"\n        ? `Saving to story (${saveProgress.total}/${saveProgress.total} uploaded)...`\n        : undefined;\n  const footerStatusText =\n    saveProgress.phase === \"idle\"\n      ? \"Upload the generated clips and insert their audio refs directly into the story.\"\n      : saveProgress.phase === \"uploading\"\n        ? `Uploading ${saveProgress.uploaded} of ${saveProgress.total} audio files to the story...`\n        : \"Writing updated audio refs and speech marks into the story...\";\n  const nextStoryTranscriptItems = React.useMemo(() => {\n    if (\n      !nextStoryData ||\n      !nextAvatarRows ||\n      !nextLearningLanguage ||\n      !nextFromLanguage\n    ) {\n      return null;\n    }\n\n    const avatarNames: Record<number, Avatar> = {};\n    for (const avatar of nextAvatarRows) {\n      avatarNames[avatar.avatar_id] = avatar;\n    }\n\n    const [parsedStory] = processStoryFile(\n      nextStoryData.story_data.text,\n      nextStoryData.story_data.id,\n      avatarNames,\n      {\n        learning_language: nextLearningLanguage.short,\n        from_language: nextFromLanguage.short,\n      },\n      nextLearningLanguage.tts_replace ?? \"\",\n    );\n\n    return getAudioCutterTranscriptItems(parsedStory.elements);\n  }, [nextAvatarRows, nextFromLanguage, nextLearningLanguage, nextStoryData]);\n  const canContinueToNextStory = Boolean(nextStory && nextStoryTranscriptItems);\n\n  const goToStoryPage = React.useCallback(() => {\n    setSaveSuccessOpen(false);\n    router.push(`/editor/course/${coursePathId ?? courseId}/story/${storyId}`);\n  }, [courseId, coursePathId, router, storyId]);\n\n  const continueToNextStory = React.useCallback(() => {\n    if (!nextStory || !nextStoryTranscriptItems) return;\n\n    storeAudioCutterTranscript(nextStory.id, nextStoryTranscriptItems);\n    setSaveSuccessOpen(false);\n    router.push(\n      `/editor/course/${coursePathId ?? courseId}/story/${nextStory.id}/audio-cutter`,\n    );\n  }, [courseId, coursePathId, nextStory, nextStoryTranscriptItems, router]);\n\n  return (\n    <>\n      {course && data ? (\n        <EditorHeaderBreadcrumbs>\n          <Breadcrumbs\n            path={[\n              { type: \"Editor\", href: `/editor` },\n              { type: \"sep\", href: \"#\" },\n              {\n                type: \"course\",\n                lang1: {\n                  languageId: course.learningLanguageId,\n                  name: course.learning_language_name,\n                },\n                lang2: {\n                  languageId: course.fromLanguageId,\n                  name: course.from_language_name,\n                },\n                href: coursePathId\n                  ? `/editor/course/${coursePathId}`\n                  : undefined,\n              },\n              { type: \"sep\", href: \"#\" },\n              {\n                type: \"story\",\n                href: coursePathId\n                  ? `/editor/course/${coursePathId}/story/${storyId}`\n                  : undefined,\n                data: data.story_data,\n              },\n              { type: \"sep\", href: \"#\" },\n              { type: \"Audio cutter\" },\n            ]}\n          />\n        </EditorHeaderBreadcrumbs>\n      ) : null}\n      <EditorHeaderActions>\n        <div className=\"flex items-center\">\n          <StoryNavButton\n            href={\n              previousStory && coursePathId\n                ? `/editor/course/${coursePathId}/story/${previousStory.id}/audio-cutter`\n                : undefined\n            }\n            label=\"Previous\"\n            title={previousStory?.name}\n            compactIconDirection=\"left\"\n          />\n          <StoryNavButton\n            href={\n              nextStory && coursePathId\n                ? `/editor/course/${coursePathId}/story/${nextStory.id}/audio-cutter`\n                : undefined\n            }\n            label=\"Next\"\n            title={nextStory?.name}\n            compactIconDirection=\"right\"\n          />\n        </div>\n      </EditorHeaderActions>\n      <AudioCutterDialog\n        open={true}\n        renderInDialog={false}\n        onOpenChange={(nextOpen) => {\n          if (nextOpen) return;\n          router.push(\n            `/editor/course/${coursePathId ?? courseId}/story/${storyId}`,\n          );\n        }}\n        expectedSegmentCount={transcriptItems.length}\n        transcriptItems={transcriptItems}\n        primaryActionLabel=\"Save segments to story\"\n        primaryActionPendingLabel={saveProgressLabel}\n        footerStatusText={footerStatusText}\n        onUseSegments={async (segments) => {\n          if (!data) {\n            throw new Error(\"Story data is still loading.\");\n          }\n          if (!learningLanguage || !fromLanguage) {\n            throw new Error(\"Language data is still loading.\");\n          }\n          if (!avatarRows) {\n            throw new Error(\"Avatar data is still loading.\");\n          }\n          if (segments.length === 0) return false;\n          if (transcriptItems.some((item) => !item.ssml)) {\n            throw new Error(\n              \"This cutter session is missing story audio anchors. Reopen the cutter from the story editor and try again.\",\n            );\n          }\n          if (data.story_data.official && !data.isAdmin) {\n            throw new Error(\n              \"Official stories cannot be overwritten unless you are an admin.\",\n            );\n          }\n          if (data.story_data.official) {\n            const confirmed = window.confirm(\n              \"This is an official story. Saving will overwrite it. Continue?\",\n            );\n            if (!confirmed) {\n              return false;\n            }\n          }\n\n          const uploadedSegments: UploadedSegment[] = [];\n          setSaveProgress({\n            total: segments.length,\n            uploaded: 0,\n            phase: \"uploading\",\n          });\n\n          try {\n            for (const [index, segment] of segments.entries()) {\n              const uploadedFilename = stripAudioPathPrefix(\n                await uploadAudioFile(segment.file, storyId),\n              );\n              uploadedSegments.push({\n                ...segment,\n                uploadedFilename,\n              });\n              setSaveProgress({\n                total: segments.length,\n                uploaded: index + 1,\n                phase: \"uploading\",\n              });\n            }\n\n            setSaveProgress({\n              total: segments.length,\n              uploaded: segments.length,\n              phase: \"saving\",\n            });\n\n            const avatarNames: Record<number, Avatar> = {};\n            for (const avatar of avatarRows) {\n              avatarNames[avatar.avatar_id] = avatar;\n            }\n\n            const [, , audioInsertLines] = processStoryFile(\n              data.story_data.text,\n              data.story_data.id,\n              avatarNames,\n              {\n                learning_language: learningLanguage.short,\n                from_language: fromLanguage.short,\n              },\n              learningLanguage.tts_replace ?? \"\",\n            );\n\n            const storyText = applyAudioUpdatesToText(\n              data.story_data.text,\n              uploadedSegments.map((segment) => ({\n                ssml: segment.ssml,\n                serializedText: timings_to_text({\n                  filename: segment.uploadedFilename,\n                  keypoints: segment.keypoints,\n                }),\n              })),\n              audioInsertLines,\n            );\n\n            const [nextParsedStoryBase, nextParsedMeta] = processStoryFile(\n              storyText,\n              data.story_data.id,\n              avatarNames,\n              {\n                learning_language: learningLanguage.short,\n                from_language: fromLanguage.short,\n              },\n              learningLanguage.tts_replace ?? \"\",\n            );\n\n            await setStoryMutation({\n              legacyStoryId: data.story_data.id,\n              duo_id: data.story_data.duo_id ?? \"\",\n              name: nextParsedMeta.fromLanguageName,\n              image: nextParsedMeta.icon ?? \"\",\n              set_id: nextParsedMeta.set_id,\n              set_index: nextParsedMeta.set_index,\n              legacyCourseId: data.story_data.course_id,\n              text: storyText,\n              json: toConvexValue(nextParsedStoryBase),\n              todo_count: nextParsedMeta.todo_count,\n              change_date: new Date().toISOString(),\n              confirmOfficialOverwrite: data.story_data.official || undefined,\n              operationKey: `story:${data.story_data.id}:audio-cutter:${Date.now()}`,\n            });\n            setSaveSuccessOpen(true);\n            return false;\n          } finally {\n            setSaveProgress({\n              total: 0,\n              uploaded: 0,\n              phase: \"idle\",\n            });\n          }\n        }}\n      />\n      <Dialog open={saveSuccessOpen} onOpenChange={setSaveSuccessOpen}>\n        <DialogContent className=\"max-w-[520px]\">\n          <DialogTitle className=\"text-lg font-semibold text-[var(--text-color)]\">\n            Segments saved to story\n          </DialogTitle>\n          <DialogDescription className=\"text-sm text-[var(--text-color-dim)]\">\n            {data?.story_data.name\n              ? `The generated audio files and speech marks were saved to \"${data.story_data.name}\".`\n              : \"The generated audio files and speech marks were saved to the story.\"}\n          </DialogDescription>\n          <div className=\"mt-4 flex flex-wrap justify-end gap-2\">\n            <button\n              type=\"button\"\n              className=\"inline-flex h-9 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n              onClick={goToStoryPage}\n            >\n              Show story\n            </button>\n            <button\n              type=\"button\"\n              className=\"inline-flex h-9 items-center justify-center rounded-md border border-[#0f5f83] bg-[#1cb0f6] px-3 text-sm font-semibold leading-none text-white transition-colors hover:bg-[#1598d7] disabled:cursor-default disabled:opacity-70\"\n              onClick={continueToNextStory}\n              disabled={!canContinueToNextStory}\n            >\n              {nextStory\n                ? canContinueToNextStory\n                  ? \"Continue with next story\"\n                  : \"Preparing next story...\"\n                : \"No next story\"}\n            </button>\n          </div>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n\ntype LanguageData = {\n  short: string;\n  rtl: boolean;\n  tts_replace: string | null;\n};\n\ntype UploadedSegment = AudioCutterPreparedSegment & {\n  uploadedFilename: string;\n};\n\nfunction getElementAudio(\n  element: StoryElementLine | StoryElementHeader | undefined,\n) {\n  if (!element) return undefined;\n  if (element.type === \"HEADER\") return element.audio;\n  return element.line.content.audio ?? element.audio;\n}\n\nfunction getAudioCutterTranscriptItems(elements: StoryElement[]) {\n  const items: AudioCutterTranscriptItem[] = [];\n  let order = 1;\n\n  for (const element of elements) {\n    if (element.type !== \"HEADER\" && element.type !== \"LINE\") continue;\n    const audio = getElementAudio(element);\n    if (!audio?.ssml) continue;\n\n    const text =\n      element.type === \"HEADER\"\n        ? element.learningLanguageTitleContent?.text\n        : element.line.content?.text;\n    const content =\n      element.type === \"HEADER\"\n        ? element.learningLanguageTitleContent\n        : element.line.content;\n    const speaker =\n      element.type === \"HEADER\"\n        ? \"Narrator\"\n        : element.line.type === \"CHARACTER\"\n          ? (element.line.characterName ??\n            element.line.characterId?.toString() ??\n            \"Narrator\")\n          : \"Narrator\";\n\n    if (!text || !content) continue;\n\n    items.push({\n      id: `${element.type}-${element.trackingProperties.line_index}-${audio.ssml.inser_index}`,\n      order,\n      lineIndex: element.trackingProperties.line_index || 0,\n      type: element.type,\n      speaker,\n      content,\n      existingFilename: audio.url?.replace(/^audio\\//, \"\") ?? \"\",\n      existingKeypoints: audio.keypoints ?? [],\n      ssml: audio.ssml,\n    });\n    order += 1;\n  }\n\n  return items;\n}\n\nfunction stripAudioPathPrefix(filename: string) {\n  if (!filename) return \"\";\n  if (filename.startsWith(\"audio/\")) {\n    return filename.slice(\"audio/\".length);\n  }\n  return filename;\n}\n\nasync function uploadAudioFile(file: File, storyId: number) {\n  const data = new FormData();\n  data.set(\"file\", file);\n  data.set(\"story_id\", String(storyId));\n  const controller = new AbortController();\n  const timeoutId = window.setTimeout(() => {\n    controller.abort();\n  }, 20_000);\n\n  let response: Response;\n  try {\n    response = await fetch(\"/audio/upload\", {\n      method: \"POST\",\n      body: data,\n      signal: controller.signal,\n    });\n  } catch (error) {\n    if (error instanceof DOMException && error.name === \"AbortError\") {\n      throw new Error(\"Upload timed out. Please try again.\");\n    }\n    throw error;\n  } finally {\n    window.clearTimeout(timeoutId);\n  }\n\n  if (!response.ok) {\n    throw new Error(await response.text());\n  }\n\n  const payload = (await response.json()) as {\n    success?: boolean;\n    filename?: string;\n  };\n\n  if (!payload.success || !payload.filename) {\n    throw new Error(\"Upload failed.\");\n  }\n\n  return payload.filename;\n}\n\nfunction applyAudioUpdatesToText(\n  docText: string,\n  updates: { serializedText: string; ssml: { inser_index: number } }[],\n  audioInsertLines: [number | undefined, number][],\n) {\n  const state = EditorState.create({ doc: docText });\n  const changes = updates\n    .map((update) => {\n      const insertTarget = audioInsertLines[update.ssml.inser_index];\n      if (!insertTarget) return null;\n\n      const [line, lineInsert] = insertTarget;\n      if (line !== undefined) {\n        const lineNumber = Math.min(Math.max(1, line), state.doc.lines);\n        const lineState = state.doc.line(lineNumber);\n        return {\n          from: lineState.from,\n          to: lineState.to,\n          insert: update.serializedText,\n        };\n      }\n\n      const lineInsertNumber = Math.min(\n        Math.max(1, lineInsert - 1),\n        state.doc.lines,\n      );\n      const lineState = state.doc.line(lineInsertNumber);\n      return {\n        from: lineState.from,\n        to: lineState.from,\n        insert: `${update.serializedText}\\n`,\n      };\n    })\n    .filter((change): change is NonNullable<typeof change> => change !== null)\n    .sort((left, right) => left.from - right.from || left.to - right.to);\n\n  if (changes.length === 0) return docText;\n\n  return state.update({ changes }).state.doc.toString();\n}\n\nfunction toConvexValue(value: unknown): unknown {\n  if (value === undefined) return null;\n  if (Array.isArray(value)) return value.map((item) => toConvexValue(item));\n  if (value && typeof value === \"object\") {\n    const result: Record<string, unknown> = {};\n    for (const [key, item] of Object.entries(value)) {\n      result[key] = toConvexValue(item);\n    }\n    return result;\n  }\n  return value;\n}\n\nfunction StoryNavButton({\n  href,\n  label,\n  title,\n  compactIconDirection,\n}: {\n  href?: string;\n  label: string;\n  title?: string;\n  compactIconDirection: \"left\" | \"right\";\n}) {\n  const className =\n    \"px-3 py-2 text-center text-sm text-[var(--text-color-dim)] no-underline transition-colors hover:text-[var(--text-color)]\";\n  const content = (\n    <>\n      <span className=\"max-[1100px]:hidden\">{label}</span>\n      <span className=\"min-[1101px]:hidden\">\n        <ChevronIcon direction={compactIconDirection} />\n      </span>\n    </>\n  );\n\n  if (!href) {\n    return (\n      <span\n        className={`${className} hidden min-[701px]:block min-[701px]:min-w-[48px] min-[1101px]:min-w-[86px] cursor-default opacity-50`}\n        aria-disabled=\"true\"\n      >\n        {content}\n      </span>\n    );\n  }\n\n  return (\n    <Link\n      href={href}\n      className={`${className} hidden min-[701px]:block min-[701px]:min-w-[48px] min-[1101px]:min-w-[86px]`}\n      title={title ? `${label}: ${title}` : label}\n    >\n      {content}\n    </Link>\n  );\n}\n\nfunction ChevronIcon({ direction }: { direction: \"left\" | \"right\" }) {\n  return (\n    <span aria-hidden=\"true\" className=\"text-lg leading-none\">\n      {direction === \"left\" ? \"‹\" : \"›\"}\n    </span>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/story/[story]/layout.tsx",
    "content": "import React from \"react\";\n\nexport default function Layout({ children }: { children: React.ReactNode }) {\n  return <div className=\"flex h-full min-h-0 flex-col\">{children}</div>;\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/story/[story]/page.tsx",
    "content": "import React from \"react\";\nimport { notFound } from \"next/navigation\";\nimport { Metadata } from \"next\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\nimport StoryEditorPageClient from \"@/app/editor/story/[story]/page_client\";\n\nfunction getCanonicalStoryEditorPath(courseShort: string, storyId: number) {\n  return `/editor/course/${courseShort}/story/${storyId}`;\n}\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ course_id: string; story: number }>;\n}): Promise<Metadata> {\n  const resolvedParams = await params;\n  const storyId = Number(resolvedParams.story);\n  const story = await fetchQuery(api.editorRead.getEditorStoryPageData, {\n    storyId,\n  });\n\n  if (!story) notFound();\n\n  return {\n    title: `${story.story_data.name} | Duostories Editor`,\n    alternates: {\n      canonical: `https://duostories.org${getCanonicalStoryEditorPath(\n        story.story_data.short,\n        story.story_data.id,\n      )}`,\n    },\n  };\n}\n\nexport default async function Page({\n  params,\n  searchParams,\n}: {\n  params: Promise<{ course_id: string; story: number }>;\n  searchParams?: Promise<{\n    line?: string | string[];\n    bulkAudio?: string | string[];\n  }>;\n}) {\n  const resolvedParams = await params;\n  const storyId = Number(resolvedParams.story);\n  const resolvedSearchParams = searchParams ? await searchParams : undefined;\n  const rawLine = resolvedSearchParams?.line;\n  const rawBulkAudio = resolvedSearchParams?.bulkAudio;\n  const initialFocusLine =\n    typeof rawLine === \"string\"\n      ? Number(rawLine)\n      : Array.isArray(rawLine)\n        ? Number(rawLine[0])\n        : undefined;\n  const initialBulkAudioOpen =\n    rawBulkAudio === \"1\" ||\n    (Array.isArray(rawBulkAudio) && rawBulkAudio[0] === \"1\");\n  const validatedInitialFocusLine =\n    typeof initialFocusLine === \"number\" &&\n    Number.isFinite(initialFocusLine) &&\n    initialFocusLine > 0\n      ? initialFocusLine\n      : undefined;\n\n  return (\n    <StoryEditorPageClient\n      storyId={storyId}\n      courseId={resolvedParams.course_id}\n      initialFocusLine={validatedInitialFocusLine}\n      initialBulkAudioOpen={initialBulkAudioOpen}\n    />\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/voices/edit/page.tsx",
    "content": "import React from \"react\";\nimport { notFound } from \"next/navigation\";\nimport { Metadata } from \"next\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\nimport CourseVoicesEditPageClient from \"./page_client\";\n\nfunction getCanonicalVoicesEditPath(courseShort: string) {\n  return `/editor/course/${courseShort}/voices/edit`;\n}\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ course_id: string }>;\n}): Promise<Metadata> {\n  const courseId = (await params).course_id;\n  const course = await fetchQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: courseId,\n  });\n\n  if (!course) notFound();\n\n  return {\n    title: `Voices Edit | ${course.learning_language_name} (from ${course.from_language_name}) | Duostories Editor`,\n    alternates: {\n      canonical: `https://duostories.org${getCanonicalVoicesEditPath(course.short ?? courseId)}`,\n    },\n  };\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ course_id: string }>;\n}) {\n  return <CourseVoicesEditPageClient courseId={(await params).course_id} />;\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/voices/edit/page_client.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { Breadcrumbs } from \"@/app/editor/_components/breadcrumbs\";\nimport { EditorHeaderBreadcrumbs } from \"@/app/editor/_components/header_context\";\nimport TtsEdit from \"@/app/editor/language/[language]/tts_edit/tts_edit\";\nimport type { DetailedCourseProps } from \"@/app/editor/(course)/types\";\nimport type {\n  CourseStudType,\n  LanguageType,\n  SpeakersType,\n} from \"@/app/editor/language/[language]/types\";\n\nexport default function CourseVoicesEditPageClient({\n  courseId,\n}: {\n  courseId: string;\n}) {\n  const course = useQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: courseId,\n  }) as DetailedCourseProps | null | undefined;\n  const learningLanguage = useQuery(\n    api.editorRead.getEditorLanguageByLegacyId,\n    course ? { legacyLanguageId: course.learning_language } : \"skip\",\n  ) as LanguageType | null | undefined;\n  const fromLanguage = useQuery(\n    api.editorRead.getEditorLanguageByLegacyId,\n    course ? { legacyLanguageId: course.from_language } : \"skip\",\n  ) as LanguageType | null | undefined;\n  const speakers = useQuery(\n    api.editorRead.getEditorSpeakersByLanguageLegacyId,\n    learningLanguage ? { languageLegacyId: learningLanguage.id } : \"skip\",\n  ) as SpeakersType[] | undefined;\n\n  if (course === undefined) return <Spinner />;\n  if (\n    learningLanguage === undefined ||\n    fromLanguage === undefined ||\n    speakers === undefined\n  ) {\n    return (\n      <>\n        {course ? (\n          <EditorHeaderBreadcrumbs>\n            <Breadcrumbs\n              path={[\n                { type: \"Editor\", href: `/editor` },\n                { type: \"sep\" },\n                {\n                  type: \"course\",\n                  lang1: {\n                    languageId: course.learningLanguageId,\n                    name: course.learning_language_name,\n                  },\n                  lang2: {\n                    languageId: course.fromLanguageId,\n                    name: course.from_language_name,\n                  },\n                  href: `/editor/course/${course.short}`,\n                },\n                { type: \"sep\" },\n                {\n                  type: \"Voices\",\n                  href: `/editor/course/${course.short}/voices`,\n                },\n                { type: \"sep\" },\n                { type: \"Edit\" },\n              ]}\n            />\n          </EditorHeaderBreadcrumbs>\n        ) : null}\n        <Spinner />\n      </>\n    );\n  }\n\n  if (!course || !learningLanguage) {\n    return <p>Course not found.</p>;\n  }\n\n  return (\n    <>\n      <EditorHeaderBreadcrumbs>\n        <Breadcrumbs\n          path={[\n            { type: \"Editor\", href: `/editor` },\n            { type: \"sep\" },\n            {\n              type: \"course\",\n              lang1: {\n                languageId: course.learningLanguageId,\n                name: course.learning_language_name,\n              },\n              lang2: {\n                languageId: course.fromLanguageId,\n                name: course.from_language_name,\n              },\n              href: `/editor/course/${course.short}`,\n            },\n            { type: \"sep\" },\n            { type: \"Voices\", href: `/editor/course/${course.short}/voices` },\n            { type: \"sep\" },\n            { type: \"Edit\" },\n          ]}\n        />\n      </EditorHeaderBreadcrumbs>\n      <TtsEdit\n        language={learningLanguage}\n        language2={fromLanguage ?? undefined}\n        speakers={speakers ?? []}\n        course={\n          {\n            learning_language: course.learning_language,\n            from_language: course.from_language,\n            short: course.short ?? courseId,\n          } as CourseStudType\n        }\n        renderHeader={false}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/voices/page.tsx",
    "content": "import React from \"react\";\nimport { notFound } from \"next/navigation\";\nimport { Metadata } from \"next\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\nimport CourseVoicesPageClient from \"./page_client\";\n\nfunction getCanonicalVoicesPath(courseShort: string) {\n  return `/editor/course/${courseShort}/voices`;\n}\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ course_id: string }>;\n}): Promise<Metadata> {\n  const courseId = (await params).course_id;\n  const course = await fetchQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: courseId,\n  });\n\n  if (!course) notFound();\n\n  return {\n    title: `Voices | ${course.learning_language_name} (from ${course.from_language_name}) | Duostories Editor`,\n    alternates: {\n      canonical: `https://duostories.org${getCanonicalVoicesPath(course.short ?? courseId)}`,\n    },\n  };\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ course_id: string }>;\n}) {\n  return <CourseVoicesPageClient courseId={(await params).course_id} />;\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course/[course_id]/voices/page_client.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { Breadcrumbs } from \"@/app/editor/_components/breadcrumbs\";\nimport {\n  EditorHeaderActions,\n  EditorHeaderBreadcrumbs,\n} from \"@/app/editor/_components/header_context\";\nimport EditorButton from \"@/app/editor/editor_button\";\nimport LanguageEditor from \"@/app/editor/language/[language]/language_editor\";\nimport type { DetailedCourseProps } from \"@/app/editor/(course)/types\";\n\nexport default function CourseVoicesPageClient({\n  courseId,\n}: {\n  courseId: string;\n}) {\n  const course = useQuery(api.editorRead.getEditorCourseByIdentifier, {\n    identifier: courseId,\n  }) as DetailedCourseProps | null | undefined;\n\n  if (course === undefined) return <Spinner />;\n  if (!course) return <p>Course not found.</p>;\n\n  return (\n    <>\n      <EditorHeaderBreadcrumbs>\n        <Breadcrumbs\n          path={[\n            { type: \"Editor\", href: `/editor` },\n            { type: \"sep\" },\n            {\n              type: \"course\",\n              lang1: {\n                languageId: course.learningLanguageId,\n                name: course.learning_language_name,\n              },\n              lang2: {\n                languageId: course.fromLanguageId,\n                name: course.from_language_name,\n              },\n              href: `/editor/course/${course.short}`,\n            },\n            { type: \"sep\" },\n            { type: \"Voices\" },\n          ]}\n        />\n      </EditorHeaderBreadcrumbs>\n      <EditorHeaderActions>\n        <EditorButton\n          id=\"button_edit\"\n          href={`/editor/course/${course.short}/voices/edit`}\n          data-cy=\"button_edit\"\n          img={\"import.svg\"}\n          text={\"Edit\"}\n        />\n      </EditorHeaderActions>\n      <LanguageEditor identifier={courseId} renderHeader={false} />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course_list.tsx",
    "content": "\"use client\";\nimport Link from \"next/link\";\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport LanguageFlag from \"@/components/ui/language-flag\";\nimport { useInput } from \"@/lib/hooks\";\nimport { useRouter } from \"next/navigation\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport type { CourseProps } from \"./types\";\n\ninterface CourseListProps {\n  course_id: string | undefined;\n  showList: boolean;\n  toggleShow: () => void;\n}\n\nexport default function CourseList({\n  course_id,\n  showList,\n  toggleShow,\n}: CourseListProps) {\n  const data = useQuery(api.editorRead.getEditorSidebarData, {});\n  const courses = data?.courses as CourseProps[] | undefined;\n\n  const [search, setSearch] = useInput(\"\");\n  const router = useRouter();\n\n  if (courses === undefined)\n    return (\n      <div className=\"[grid-area:nav] border-r border-[var(--header-border)] max-[1250px]:relative max-[1250px]:w-0\">\n        <Spinner />\n      </div>\n    );\n  // Error loading courses\n  if (courses.length === 0) {\n    return (\n      <div className=\"[grid-area:nav] border-r border-[var(--header-border)] max-[1250px]:relative max-[1250px]:w-0\">\n        Error loading courses\n      </div>\n    );\n  }\n\n  let filtered_courses: CourseProps[] = [];\n  if (search === \"\") filtered_courses = courses;\n  else {\n    for (let course of courses) {\n      if (\n        course.learning_language_name\n          .toLowerCase()\n          .indexOf(search.toLowerCase()) !== -1\n        //|| course.from_language_name.toLowerCase().indexOf(search.toLowerCase()) !== -1\n      ) {\n        filtered_courses.push(course);\n      }\n    }\n  }\n\n  return (\n    <div\n      className=\"group relative [grid-area:nav] min-h-0 min-w-0 border-r border-[var(--header-border)] max-[1250px]:w-0\"\n      data-show={!course_id ? true : showList}\n    >\n      <div\n        className=\"pointer-events-none hidden bg-black opacity-0 transition-opacity duration-500 ease-[ease] max-[1250px]:absolute max-[1250px]:inset-0 max-[1250px]:block max-[1250px]:h-full max-[1250px]:w-screen max-[1250px]:group-data-[show=true]:pointer-events-auto max-[1250px]:group-data-[show=true]:opacity-50\"\n        onClick={() => toggleShow()}\n      ></div>\n      <div className=\"h-full min-h-0 overflow-auto max-[1250px]:absolute max-[1250px]:top-0 max-[1250px]:left-0 max-[1250px]:h-full max-[1250px]:w-[min(100vw,400px)] max-[1250px]:translate-x-[calc(-100%-10px)] max-[1250px]:bg-[var(--body-background)] max-[1250px]:shadow-[2px_19px_10px_hsl(20deg_10%_10%_/_0.5)] max-[1250px]:transition-transform max-[1250px]:duration-500 max-[1250px]:ease-in max-[1250px]:group-data-[show=true]:translate-x-0 max-[1250px]:group-data-[show=true]:duration-700 max-[1250px]:group-data-[show=true]:ease-out\">\n        <div className=\"sticky top-0 flex h-10 items-center border-b border-[var(--header-border)] bg-[var(--body-background)] pr-[10px]\">\n          <span className=\"px-[10px]\">Search</span>\n          <input\n            className=\"mr-[10px] w-full rounded-2xl border-2 border-[var(--input-border)] bg-[var(--input-background)] px-[6px] py-[1px] text-[19px] text-[var(--text-color)]\"\n            value={search}\n            onChange={setSearch}\n          />\n        </div>\n        <div>\n          {filtered_courses.map((course, index) => (\n            <div key={index}>\n              <Link\n                className={\n                  \"flex items-center border-b border-[var(--header-border)] bg-[var(--body-background)] text-[var(--text-color)] no-underline outline-offset-[-2px] hover:brightness-90 focus:brightness-90 \" +\n                  (course_id === course.short ? \"brightness-90\" : \"\")\n                }\n                href={`/editor/course/${course.short}`}\n                onClick={() => {\n                  router.push(`/editor/course/${course.short}`);\n                  toggleShow();\n                }}\n              >\n                <span className=\"w-[45px] text-right text-[var(--text-color-dim)]\">\n                  {course.count}\n                </span>\n                <LanguageFlag\n                  className=\"m-1 ml-4\"\n                  languageId={course.learningLanguageId}\n                  width={40}\n                />\n                <span className=\"overflow-hidden text-ellipsis whitespace-nowrap\">{`${\n                  course.learning_language_name\n                } [${course.from_language_short}] `}</span>\n                <span className=\"flex grow items-center justify-end gap-[6px] whitespace-nowrap pr-[10px]\">\n                  {course.todo_count ? (\n                    <span\n                      className=\"inline-flex shrink-0 items-center whitespace-nowrap rounded-full bg-amber-100 px-[8px] py-[5px] text-[14px] leading-none font-bold text-amber-900\"\n                      role=\"img\"\n                      title={`This course has ${course.todo_count} TODOs.`}\n                      aria-label={`${course.todo_count} TODOs`}\n                    >\n                      <span aria-hidden=\"true\">📝 {course.todo_count}</span>\n                    </span>\n                  ) : null}\n                  {course.official ? (\n                    <span className=\"inline-flex h-7 w-7 shrink-0 items-center justify-center\">\n                      <img\n                        src=\"https://d35aaqx5ub95lt.cloudfront.net/vendor/b3ede3d53c932ee30d981064671c8032.svg\"\n                        title=\"official\"\n                        alt=\"👑\"\n                        className=\"block h-6 w-6 object-contain\"\n                      />\n                    </span>\n                  ) : course.contributors.length ? (\n                    <span className=\"inline-flex items-center leading-none\">\n                      {`🧑 ${course.contributors.length}`}\n                    </span>\n                  ) : (\n                    <span className=\"inline-flex items-center leading-none\">\n                      💤\n                    </span>\n                  )}\n                </span>\n              </Link>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/course_view_memory.ts",
    "content": "\"use client\";\n\nconst COURSE_SCROLL_KEY_PREFIX = \"editor-course-scroll:\";\nconst COURSE_FILTER_KEY_PREFIX = \"editor-course-filter:\";\nconst COURSE_SCROLL_CONTAINER_SELECTOR =\n  '[data-editor-scroll-container=\"course-main\"]';\nconst COURSE_FILTER_VALUES = [\n  \"all\",\n  \"draft\",\n  \"feedback\",\n  \"finished\",\n  \"published\",\n] as const;\n\ntype CourseFilterValue = (typeof COURSE_FILTER_VALUES)[number];\n\nfunction getCourseScrollKey(courseIdentifier: string) {\n  return `${COURSE_SCROLL_KEY_PREFIX}${courseIdentifier}`;\n}\n\nfunction getCourseFilterKey(courseIdentifier: string) {\n  return `${COURSE_FILTER_KEY_PREFIX}${courseIdentifier}`;\n}\n\nfunction getCourseScrollContainer() {\n  if (typeof document === \"undefined\") return null;\n\n  return document.querySelector<HTMLElement>(COURSE_SCROLL_CONTAINER_SELECTOR);\n}\n\nexport function rememberCourseScrollPosition(courseIdentifier: string) {\n  if (typeof window === \"undefined\") return;\n\n  const scrollContainer = getCourseScrollContainer();\n  const scrollTop = scrollContainer?.scrollTop ?? window.scrollY;\n\n  window.sessionStorage.setItem(\n    getCourseScrollKey(courseIdentifier),\n    String(scrollTop),\n  );\n}\n\nexport function readCourseScrollPosition(courseIdentifier: string) {\n  if (typeof window === \"undefined\") return null;\n\n  const storageKey = getCourseScrollKey(courseIdentifier);\n  const storedValue = window.sessionStorage.getItem(storageKey);\n  if (storedValue === null) return null;\n\n  const scrollPosition = Number(storedValue);\n  if (!Number.isFinite(scrollPosition)) return null;\n\n  return scrollPosition;\n}\n\nexport function rememberCourseFilter(\n  courseIdentifier: string,\n  filter: CourseFilterValue,\n) {\n  if (typeof window === \"undefined\") return;\n\n  window.sessionStorage.setItem(getCourseFilterKey(courseIdentifier), filter);\n}\n\nexport function readCourseFilter(\n  courseIdentifier: string,\n): CourseFilterValue | null {\n  if (typeof window === \"undefined\") return null;\n\n  const storedFilter = window.sessionStorage.getItem(\n    getCourseFilterKey(courseIdentifier),\n  );\n  if (storedFilter === null) return null;\n\n  return isCourseFilterValue(storedFilter) ? storedFilter : null;\n}\n\nexport function restoreCourseScrollPosition(\n  courseIdentifier: string,\n  frameCount = 8,\n) {\n  const storedScrollPosition = readCourseScrollPosition(courseIdentifier);\n  if (storedScrollPosition === null) return () => {};\n\n  const applyScroll = () => {\n    const scrollContainer = getCourseScrollContainer();\n    if (scrollContainer) {\n      scrollContainer.scrollTop = storedScrollPosition;\n    } else {\n      window.scrollTo({\n        top: storedScrollPosition,\n        behavior: \"auto\",\n      });\n    }\n  };\n\n  // Apply immediately so a layout effect can restore before the first paint.\n  applyScroll();\n\n  let isCancelled = false;\n  let animationFrameId: number | null = null;\n  let remainingFrames = frameCount - 1;\n  const keepScrollApplied = () => {\n    if (isCancelled) return;\n\n    applyScroll();\n    remainingFrames -= 1;\n    if (remainingFrames > 0) {\n      animationFrameId = window.requestAnimationFrame(keepScrollApplied);\n    }\n  };\n\n  if (remainingFrames > 0) {\n    animationFrameId = window.requestAnimationFrame(keepScrollApplied);\n  }\n\n  return () => {\n    isCancelled = true;\n    if (animationFrameId !== null) {\n      window.cancelAnimationFrame(animationFrameId);\n    }\n  };\n}\n\nfunction isCourseFilterValue(value: string): value is CourseFilterValue {\n  return COURSE_FILTER_VALUES.includes(value as CourseFilterValue);\n}\n"
  },
  {
    "path": "src/app/editor/(course)/edit_list.tsx",
    "content": "\"use client\";\nimport Link from \"next/link\";\nimport {\n  useDeferredValue,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { SpinnerBlue } from \"@/components/ui/spinner\";\nimport { useRouter } from \"next/navigation\";\nimport { useMutation } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport ContributorList from \"@/components/ContributorList\";\nimport Input from \"@/components/ui/input\";\nimport { matchesStorySearch, parseStorySearch } from \"@/lib/story-search\";\nimport {\n  readCourseFilter,\n  rememberCourseFilter,\n  rememberCourseScrollPosition,\n  restoreCourseScrollPosition,\n} from \"./course_view_memory\";\nimport type {\n  DetailedCourseProps,\n  StoryListDataProps,\n} from \"@/app/editor/(course)/types\";\n\ntype StoryState = \"draft\" | \"feedback\" | \"finished\" | \"published\";\ntype StoryFilter = \"all\" | StoryState;\n\nconst STORY_FILTER_ORDER: StoryFilter[] = [\n  \"all\",\n  \"draft\",\n  \"feedback\",\n  \"finished\",\n  \"published\",\n];\n\nexport default function EditList({\n  stories,\n  course,\n}: {\n  stories: StoryListDataProps[];\n  course: DetailedCourseProps;\n}) {\n  const courseStorageKey = course.short ?? String(course.id);\n  const [storyList, setStoryList] = useState<StoryListDataProps[]>(\n    stories ?? [],\n  );\n  const [activeFilter, setActiveFilter] = useState<StoryFilter>(\"all\");\n  const [storySearch, setStorySearch] = useState(\"\");\n  const [isStoredFilterApplied, setIsStoredFilterApplied] = useState(false);\n  const restoredViewKeyRef = useRef<string | null>(null);\n  const deferredStorySearch = useDeferredValue(storySearch);\n\n  useEffect(() => {\n    setStoryList(stories ?? []);\n  }, [stories]);\n\n  useLayoutEffect(() => {\n    restoredViewKeyRef.current = null;\n    setIsStoredFilterApplied(false);\n\n    const storedFilter = readCourseFilter(courseStorageKey) ?? \"all\";\n    setActiveFilter(storedFilter);\n    setIsStoredFilterApplied(true);\n  }, [courseStorageKey]);\n\n  useEffect(() => {\n    if (!isStoredFilterApplied) return;\n    rememberCourseFilter(courseStorageKey, activeFilter);\n  }, [activeFilter, courseStorageKey, isStoredFilterApplied]);\n\n  useLayoutEffect(() => {\n    if (!isStoredFilterApplied) return;\n\n    const viewKey = `${courseStorageKey}:${activeFilter}`;\n    if (restoredViewKeyRef.current === viewKey) return;\n\n    restoredViewKeyRef.current = viewKey;\n    return restoreCourseScrollPosition(courseStorageKey);\n  }, [activeFilter, courseStorageKey, isStoredFilterApplied]);\n\n  const counts = storyList.reduce<Record<StoryFilter, number>>(\n    (acc, story) => {\n      acc.all += 1;\n      acc[getStoryState(story)] += 1;\n      return acc;\n    },\n    {\n      all: 0,\n      draft: 0,\n      feedback: 0,\n      finished: 0,\n      published: 0,\n    },\n  );\n\n  const trimmedStorySearch = deferredStorySearch.trim();\n  const parsedStorySearch = parseStorySearch(trimmedStorySearch, {\n    enableStatusFilters: true,\n  });\n\n  const filteredStoriesByState =\n    activeFilter === \"all\"\n      ? storyList\n      : storyList.filter((story) => getStoryState(story) === activeFilter);\n\n  const filteredStories =\n    parsedStorySearch === null\n      ? filteredStoriesByState\n      : filteredStoriesByState.filter((story) =>\n          matchesStorySearch(story, parsedStorySearch),\n        );\n\n  let set_ends = [];\n  // Seed with the first visible story's set so the first rendered row does not\n  // get a divider; only transitions between sets should add the border.\n  let last_set = filteredStories[0]?.set_id;\n  let story_published_count = 0;\n  for (let story of storyList) {\n    story_published_count += story.public ? 1 : 0;\n  }\n\n  for (let story of filteredStories) {\n    if (story.set_id === last_set) set_ends.push(0);\n    else set_ends.push(1);\n    last_set = story.set_id;\n  }\n\n  return (\n    <>\n      <div>\n        {!course.public && story_published_count ? (\n          <div className=\"mx-[10px] my-[10px] rounded-[10px] border-2 bg-[var(--button-inactive-background)] p-[10px]\">\n            {`⚠ This course is not public, but has ${story_published_count} stories set to \"public\".`}\n            <br />\n            Please ask a moderator on discord to check the course and make it\n            public.\n          </div>\n        ) : null}\n        <ul className=\"my-4 list-disc pl-10\">\n          <li>\n            To create a new story click the &quot;Import&quot; button. The story\n            starts as &quot;✍️ draft&quot;.\n          </li>\n          <li>\n            When you have finished working on the story, click the\n            &quot;👍&quot; icon to approve it and change the status to &quot;🗨\n            feedback&quot;.\n          </li>\n          <li>\n            Now tell contributors on Discord to check the story. When one or\n            more people have checked the story and also gave their approval\n            &quot;👍&quot; the status changes to &quot;✅ finished&quot;.\n          </li>\n          <li>\n            When one complete set is finished it will switch to &quot;📢\n            published&quot;.\n          </li>\n        </ul>\n      </div>\n      <p className=\"my-4\">\n        To set character voices, go to the{\" \"}\n        <Link\n          className=\"underline\"\n          href={`/editor/course/${course.short}/voices`}\n        >\n          Character Editor\n        </Link>\n        .\n      </p>\n      {course.from_language_name !== \"English\" && (\n        <p className=\"my-4\">\n          For language localization settings (for the base language of this\n          course), head to the{\" \"}\n          <Link\n            className=\"underline\"\n            href={`/editor/course/${course.short}/localization`}\n          >\n            Localization Editor\n          </Link>\n          .\n        </p>\n      )}\n      <div className=\"my-6 space-y-4\">\n        <div>\n          <h2 className=\"mb-2 font-bold\">Active Contributors</h2>\n          <ContributorList\n            contributors={course.contributors}\n            emptyLabel=\"No contributors\"\n            size=\"sm\"\n          />\n        </div>\n        <div>\n          <h2 className=\"mb-2 font-bold\">Past Contributors</h2>\n          <ContributorList\n            contributors={course.contributors_past}\n            emptyLabel=\"No past contributors\"\n            muted\n            size=\"sm\"\n          />\n        </div>\n      </div>\n      <div className=\"mb-4 flex flex-wrap items-center justify-between gap-3\">\n        <div className=\"w-full min-w-[220px] flex-1 min-[860px]:max-w-[360px]\">\n          <Input\n            id=\"story-search\"\n            type=\"search\"\n            value={storySearch}\n            placeholder=\"Search story names or status\"\n            aria-label=\"Search story names or status\"\n            autoComplete=\"off\"\n            onChange={(event) => setStorySearch(event.target.value)}\n          />\n        </div>\n        <div className=\"flex flex-wrap items-center justify-end gap-2\">\n          {STORY_FILTER_ORDER.map((filter) => {\n            const isActive = activeFilter === filter;\n            return (\n              <button\n                key={filter}\n                type=\"button\"\n                className={\n                  \"inline-flex items-center gap-2 rounded-full border px-3 py-2 text-[14px] leading-none transition-colors duration-150 \" +\n                  (isActive\n                    ? \"border-[var(--button-background)] bg-[var(--button-background)] text-[var(--button-color)]\"\n                    : \"border-[var(--header-border)] bg-[var(--body-background-faint)] text-[var(--text-color)] hover:bg-[var(--body-background)]\")\n                }\n                onClick={() => setActiveFilter(filter)}\n                aria-pressed={isActive}\n              >\n                <span>{getFilterLabel(filter)}</span>\n                <span\n                  className={\n                    \"rounded-full px-2 py-[3px] text-[12px] font-bold \" +\n                    (isActive\n                      ? \"bg-[color:rgba(255,255,255,0.18)] text-[var(--button-color)]\"\n                      : \"bg-[var(--body-background)] text-[var(--text-color-dim)]\")\n                  }\n                >\n                  {counts[filter]}\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      </div>\n      <div className=\"mb-[100px] w-full\">\n        <div className=\"hidden min-[1000px]:block\">\n          <div className=\"min-[1000px]:grid min-[1000px]:grid-cols-[84px_56px_minmax(0,1fr)_210px_120px_150px_120px_150px] min-[1000px]:items-center\">\n            <div\n              className=\"flex self-stretch bg-[var(--button-background)] px-[5px] pb-[5px] pt-[5px] text-left text-[var(--button-color)]\"\n              data-js-sort-colnum=\"0\"\n            >\n              <span className=\"my-auto\">Set</span>\n            </div>\n            <div className=\"self-stretch bg-[var(--button-background)] px-[5px] pb-[5px] pt-[5px] text-left text-[var(--button-color)]\"></div>\n            <div\n              className=\"flex self-stretch bg-[var(--button-background)] px-[5px] pb-[5px] pt-[5px] text-left text-[var(--button-color)]\"\n              data-js-sort-colnum=\"1\"\n            >\n              <span className=\"my-auto\">Name</span>\n            </div>\n            <div\n              className=\"flex self-stretch bg-[var(--button-background)] px-[5px] pb-[5px] pt-[5px] text-left text-[var(--button-color)]\"\n              data-js-sort-colnum=\"2\"\n            >\n              <span className=\"my-auto\">Status</span>\n            </div>\n            <div\n              className=\"flex self-stretch bg-[var(--button-background)] px-[5px] pb-[5px] pt-[5px] text-left text-[var(--button-color)]\"\n              data-js-sort-colnum=\"4\"\n            >\n              <span className=\"my-auto\">Author</span>\n            </div>\n            <div\n              className=\"js-sort-active flex self-stretch bg-[var(--button-background)] px-[5px] pb-[5px] pt-[5px] text-left text-[var(--button-color)]\"\n              data-js-sort-colnum=\"5\"\n            >\n              <span className=\"my-auto\">Created</span>\n            </div>\n            <div\n              className=\"flex self-stretch bg-[var(--button-background)] px-[5px] pb-[5px] pt-[5px] text-left text-[var(--button-color)]\"\n              data-js-sort-colnum=\"6\"\n            >\n              <span className=\"my-auto\">Author</span>\n            </div>\n            <div\n              className=\"flex self-stretch bg-[var(--button-background)] px-[5px] pb-[5px] pt-[5px] text-left text-[var(--button-color)]\"\n              data-js-sort-colnum=\"7\"\n            >\n              <span className=\"my-auto\">Updated</span>\n            </div>\n          </div>\n        </div>\n        <div>\n          {filteredStories.map((story, i) => (\n            <div\n              className={\n                \"items-center py-[5px] transition-[filter,color,background-color] duration-100 ease-in hover:bg-[var(--body-background)] hover:brightness-90 max-[1000px]:flex max-[1000px]:flex-wrap min-[1000px]:grid min-[1000px]:grid-cols-[84px_56px_minmax(0,1fr)_210px_120px_150px_120px_150px] min-[1000px]:items-center \" +\n                (i % 2 === 1 ? \"bg-[var(--body-background-faint)] \" : \"\") +\n                (set_ends[i]\n                  ? \"border-t-[3px] border-[var(--button-background)] \"\n                  : \"\")\n              }\n              key={story.id}\n            >\n              <div className=\"overflow-hidden text-ellipsis whitespace-nowrap max-[1000px]:w-[60px] min-[1000px]:px-[5px]\">\n                <span>\n                  <b>{pad_space(story.set_id)}</b>&nbsp;-&nbsp;\n                  {pad_space(story.set_index)}\n                </span>\n              </div>\n              <div className=\"overflow-hidden text-ellipsis whitespace-nowrap max-[1000px]:w-[45px] max-[1000px]:shrink-0 min-[1000px]:px-[5px]\">\n                <img\n                  alt={\"story title\"}\n                  src={\n                    \"https://stories-cdn.duolingo.com/image/\" +\n                    story.image +\n                    \".svg\"\n                  }\n                  width=\"44px\"\n                  height={\"40px\"}\n                  className=\"block min-w-[44px]\"\n                />\n              </div>\n              <div className=\"overflow-hidden pl-[5px] max-[1000px]:min-w-0 max-[1000px]:flex-1 min-[1000px]:min-w-0 min-[1000px]:px-[5px]\">\n                <div className=\"flex min-w-0 items-center gap-[6px]\">\n                  <Link\n                    className=\"block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap underline underline-offset-2\"\n                    href={`/editor/course/${course.short}/story/${story.id}`}\n                    title={story.name}\n                    onClick={() =>\n                      rememberCourseScrollPosition(courseStorageKey)\n                    }\n                  >\n                    {story.name}\n                  </Link>\n                  {story.todo_count ? (\n                    <span\n                      title={`This story has ${story.todo_count} TODOs.`}\n                      role=\"img\"\n                      aria-label={`${story.todo_count} TODOs`}\n                      className=\"inline-flex shrink-0 items-center whitespace-nowrap rounded-full bg-amber-100 px-[8px] py-[5px] text-[14px] leading-none font-bold text-amber-900\"\n                    >\n                      <span aria-hidden=\"true\">📝 {story.todo_count}</span>\n                    </span>\n                  ) : null}\n                </div>\n              </div>\n              <div className=\"overflow-hidden text-ellipsis whitespace-nowrap text-right max-[1000px]:ml-auto max-[1000px]:shrink-0 max-[500px]:max-w-[85px] max-[500px]:[&>div]:flex max-[500px]:overflow-hidden min-[1000px]:px-[5px]\">\n                <DropDownStatus\n                  id={story.id}\n                  name={story.name}\n                  count={story.approvalCount ?? 0}\n                  status={story.status}\n                  public={story.public}\n                  official={course.official}\n                  onStoryStateChange={(nextStoryState) => {\n                    setStoryList((currentStories) =>\n                      currentStories.map((currentStory) =>\n                        currentStory.id === story.id\n                          ? { ...currentStory, ...nextStoryState }\n                          : currentStory,\n                      ),\n                    );\n                  }}\n                />\n              </div>\n              <div className=\"overflow-hidden text-ellipsis whitespace-nowrap pl-[5px] text-[12px] text-[var(--text-color-dim)] before:content-['Created:_'] max-[1000px]:w-[calc(50%-130px)] max-[500px]:w-[calc(100%-130px)] min-[1000px]:min-w-0 min-[1000px]:px-[5px] min-[1000px]:text-[inherit] min-[1000px]:text-[var(--text-color)] min-[1000px]:before:content-none\">\n                {story.author}\n              </div>\n              <div className=\"overflow-hidden text-ellipsis whitespace-nowrap text-[12px] text-[var(--text-color-dim)] max-[1000px]:w-[130px] min-[1000px]:px-[5px] min-[1000px]:text-[inherit] min-[1000px]:text-[var(--text-color)]\">\n                {formatDate(story.date)}\n              </div>\n              <div className=\"overflow-hidden text-ellipsis whitespace-nowrap pl-[5px] text-[12px] text-[var(--text-color-dim)] before:content-['Changed:_'] max-[1000px]:w-[calc(50%-130px)] max-[500px]:w-[calc(100%-130px)] min-[1000px]:min-w-0 min-[1000px]:px-[5px] min-[1000px]:text-[inherit] min-[1000px]:text-[var(--text-color)] min-[1000px]:before:content-none\">\n                {story.author_change}\n              </div>\n              <div className=\"overflow-hidden text-ellipsis whitespace-nowrap text-[12px] text-[var(--text-color-dim)] max-[1000px]:w-[130px] min-[1000px]:px-[5px] min-[1000px]:text-[inherit] min-[1000px]:text-[var(--text-color)]\">\n                {formatDate(story.change_date)}\n              </div>\n            </div>\n          ))}\n          {filteredStories.length === 0 ? (\n            <div className=\"rounded-[12px] border border-dashed border-[var(--header-border)] bg-[var(--body-background-faint)] px-4 py-6 text-center text-[var(--text-color-dim)]\">\n              {getEmptyStateMessage(activeFilter, trimmedStorySearch)}\n            </div>\n          ) : null}\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction getStoryState(story: Pick<StoryListDataProps, \"status\" | \"public\">) {\n  if (story.public || story.status === \"published\") return \"published\";\n  if (story.status === \"feedback\") return \"feedback\";\n  if (story.status === \"finished\") return \"finished\";\n  return \"draft\";\n}\n\nfunction getFilterLabel(filter: StoryFilter) {\n  if (filter === \"all\") return \"All\";\n  if (filter === \"draft\") return \"✍️ Draft\";\n  if (filter === \"feedback\") return \"🗨️ Feedback\";\n  if (filter === \"finished\") return \"✅ Finished\";\n  return \"📢 Published\";\n}\n\nfunction getEmptyStateMessage(filter: StoryFilter, searchQuery: string) {\n  if (searchQuery) return \"No stories match the current search.\";\n  if (filter === \"all\") return \"No stories yet.\";\n  return \"No stories match the selected state.\";\n}\n\nfunction pad_space(x: number) {\n  if (x < 10) return \" \" + x;\n  return x.toString();\n}\n\nfunction pad(x: number) {\n  if (x < 10) return \"0\" + x;\n  return x.toString();\n}\n\nfunction formatDate(datetime: string | number | Date | undefined) {\n  if (datetime === undefined || datetime === null || datetime === \"\") {\n    return \"-\";\n  }\n  const d = new Date(datetime);\n  if (Number.isNaN(d.getTime())) {\n    return \"-\";\n  }\n  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(\n    d.getHours(),\n  )}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;\n}\n\nfunction DropDownStatus(props: {\n  id: number;\n  name: string;\n  count: number;\n  status: string;\n  public: boolean;\n  official: boolean;\n  onStoryStateChange: (\n    nextStoryState: Pick<\n      StoryListDataProps,\n      \"status\" | \"public\" | \"approvalCount\"\n    >,\n  ) => void;\n}) {\n  let [loading, setLoading] = useState(0);\n  let [status, set_status] = useState(props.status);\n  let [count, setCount] = useState(props.count);\n  let [isPublic, setIsPublic] = useState(props.public);\n  const toggleApprovalMutation = useMutation(\n    api.storyApproval.toggleStoryApproval,\n  );\n  const router = useRouter();\n\n  useEffect(() => {\n    set_status(props.status);\n  }, [props.status]);\n\n  useEffect(() => {\n    setCount(props.count);\n  }, [props.count]);\n\n  useEffect(() => {\n    setIsPublic(props.public);\n  }, [props.public]);\n\n  if (props.official) return <></>;\n\n  async function addApproval() {\n    const confirmed = window.confirm(\n      `Did you check the story \"${props.name}\" and think it is ready to be published? If you want to give your approval click \"ok\".\\n\\nIn case you already gave an approval. \"ok\" will remove it.`,\n    );\n    if (!confirmed) return;\n\n    setLoading(1);\n    try {\n      const response = await toggleApprovalMutation({\n        legacyStoryId: props.id,\n        operationKey: `story_approval:${props.id}:toggle:client`,\n      });\n      if (response?.count !== undefined) {\n        const count = response.count;\n        // Approval toggles can promote stories to published, but they do not\n        // automatically unpublish them again. Reverting `public` is an admin-only\n        // action, so we preserve the existing public state unless the mutation\n        // explicitly reports this story as newly published.\n        const nextIsPublic =\n          Array.isArray(response.published) &&\n          response.published.includes(props.id)\n            ? true\n            : isPublic;\n        setCount(count);\n        setIsPublic(nextIsPublic);\n        props.onStoryStateChange({\n          status: response.story_status,\n          public: nextIsPublic,\n          approvalCount: count,\n        });\n        if (response.published.length) {\n          router.refresh();\n        }\n        set_status(response.story_status);\n      }\n      setLoading(0);\n    } catch (e) {\n      console.error(e);\n      return setLoading(-1);\n    }\n  }\n\n  function status_wrapper(status: string, public_: boolean) {\n    if (props.official) return \"🥇 official\";\n    if (public_) return \"📢 published\";\n    if (status === \"draft\") return \"✍️ draft\";\n    if (status === \"finished\") return \"✅ finished\";\n    if (status === \"feedback\") return \"🗨️ feedback\";\n    if (status === \"published\") return \"📢 published\";\n    return status;\n  }\n\n  return (\n    <div className=\"whitespace-nowrap\">\n      {\n        <span className=\"whitespace-nowrap rounded-[10px] bg-[var(--editor-ssml)] px-[5px] py-[2px]\">\n          {status_wrapper(status, isPublic)}\n        </span>\n      }{\" \"}\n      {loading === 1 ? (\n        <SpinnerBlue />\n      ) : loading === -1 ? (\n        <img\n          title=\"an error occurred\"\n          alt=\"error\"\n          src=\"/editor/icons/error.svg\"\n        />\n      ) : (\n        <></>\n      )}\n      {props.official ? (\n        <></>\n      ) : (\n        <span\n          className=\"cursor-pointer whitespace-nowrap rounded-[10px] bg-[var(--editor-ssml)] px-[5px] py-[2px] hover:brightness-90\"\n          onClick={addApproval}\n        >\n          {\"👍 \" + count}\n        </span>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/layout.tsx",
    "content": "import React from \"react\";\nimport EditorLayoutClient from \"./layout_client\";\n\nexport default async function Layout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return <EditorLayoutClient>{children}</EditorLayoutClient>;\n} // <Login page={\"editor\"} course_id={course?.short}/>s\n"
  },
  {
    "path": "src/app/editor/(course)/layout_client.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport SwiperSideBar from \"./swipe\";\nimport LayoutFlag from \"./layout_flag\";\nimport EditorHeaderShell from \"../_components/header_shell\";\n\nexport default function EditorLayoutClient({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <SwiperSideBar>\n      <div className=\"[grid-area:header] min-w-0\">\n        <EditorHeaderShell />\n        <LayoutFlag />\n      </div>\n      <div\n        className=\"[grid-area:main] min-h-0 min-w-0 overflow-auto\"\n        // This identifies the scroll container used for course list state\n        // restoration when returning from the story editor.\n        data-editor-scroll-container=\"course-main\"\n      >\n        {children}\n      </div>\n    </SwiperSideBar>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/layout_flag.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport { useSelectedLayoutSegments } from \"next/navigation\";\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport EditorButton from \"../editor_button\";\nimport { Breadcrumbs } from \"../_components/breadcrumbs\";\nimport {\n  EditorHeaderActions,\n  EditorHeaderBreadcrumbs,\n} from \"../_components/header_context\";\nimport type { CourseProps } from \"./types\";\n\ninterface BreadcrumbPath {\n  type: string;\n  href?: string;\n  name?: string;\n  lang1?: {\n    languageId: string;\n    name: string;\n  };\n  lang2?: {\n    languageId: string;\n    name: string;\n  };\n}\n\nexport default function LayoutFlag() {\n  const data = useQuery(api.editorRead.getEditorSidebarData, {});\n  const courses = (data?.courses ?? []) as CourseProps[];\n\n  const segment = useSelectedLayoutSegments();\n  const nestedRoute = segment[2];\n  const import_id = nestedRoute === \"import\" ? segment[3] : undefined;\n\n  if (\n    nestedRoute === \"story\" ||\n    nestedRoute === \"voices\" ||\n    nestedRoute === \"localization\"\n  ) {\n    return null;\n  }\n\n  let course: CourseProps | undefined = undefined;\n  let course_import: CourseProps | undefined = undefined;\n\n  for (let c of courses) {\n    if (c.short === segment[1] || `${c.id}` === segment[1]) {\n      course = c;\n    }\n    if (c.short === segment[3] || `${c.id}` === segment[3]) {\n      course_import = c;\n    }\n  }\n  let path: BreadcrumbPath[] = [{ type: \"Editor\" }];\n  if (course) {\n    path = [\n      { type: \"Editor\", href: `/editor` },\n      { type: \"sep\" },\n      {\n        type: \"course\",\n        lang1: {\n          languageId: course.learningLanguageId,\n          name: course.learning_language_name,\n        },\n        lang2: {\n          languageId: course.fromLanguageId,\n          name: course.from_language_name,\n        },\n      },\n    ];\n  }\n  if (import_id && course && course_import) {\n    path[path.length - 1].href = `/editor/course/${course.short}`;\n    path.push({ type: \"sep\" });\n    path.push({\n      type: \"course\",\n      name: \"Import\",\n      lang1: {\n        languageId: course_import.learningLanguageId,\n        name: course_import.learning_language_name,\n      },\n      lang2: {\n        languageId: course_import.fromLanguageId,\n        name: course_import.from_language_name,\n      },\n    });\n  }\n  return (\n    <>\n      <EditorHeaderBreadcrumbs>\n        <Breadcrumbs path={path} />\n      </EditorHeaderBreadcrumbs>\n      <EditorHeaderActions>\n        {course ? (\n          <>\n            {course.official ? (\n              <span className=\"pr-[15px]\" data-cy=\"label_official\">\n                <i>official</i>\n              </span>\n            ) : !import_id ? (\n              <EditorButton\n                id=\"button_import\"\n                href={`/editor/course/${course.short}/import/es-en`}\n                data-cy=\"button_import\"\n                img={\"import.svg\"}\n                text={\"Import\"}\n              />\n            ) : (\n              <EditorButton\n                id=\"button_back\"\n                href={`/editor/course/${course.short}`}\n                data-cy=\"button_back\"\n                img={\"back.svg\"}\n                text={\"Back\"}\n              />\n            )}\n            <div className=\"ml-[50px] max-[1120px]:ml-0\" />\n          </>\n        ) : null}\n      </EditorHeaderActions>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/loading.tsx",
    "content": "import { Spinner } from \"@/components/ui/spinner\";\n\n/*\n    function sleep(ms) {\n        return new Promise(resolve => setTimeout(resolve, ms));\n    }\n\n */\n\nexport default function Loading() {\n  // You can add any UI inside Loading, including a Skeleton.\n  return (\n    <div className=\"flex min-h-[calc(100vh-300px)] flex-col\">\n      <div className=\"mb-[-75px] text-center text-[30px]\">Loading</div>\n      <Spinner />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/page.tsx",
    "content": "import { getUser, isContributor } from \"@/lib/userInterface\";\nimport { Metadata } from \"next\";\n\nexport async function generateMetadata({}): Promise<Metadata> {\n  return {\n    title: `Duostories Editor`,\n    alternates: {\n      canonical: `https://duostories.org/editor/`,\n    },\n  } as Metadata;\n}\n\nexport default async function Page({}) {\n  const user = await getUser();\n\n  if (!user) {\n    //redirect(\"/editor/login\")\n  }\n  if (!isContributor(user)) {\n    //redirect(\"/editor/not_allowed\")\n  }\n\n  return (\n    <p id=\"no_stories\">Click on one of the courses to display its stories.</p>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/swipe.tsx",
    "content": "\"use client\";\n\"use no memo\";\nimport React from \"react\";\nimport { useEffect, useState } from \"react\";\nimport { useSwipeable } from \"react-swipeable\";\nimport CourseList from \"./course_list\";\nimport { useSelectedLayoutSegments } from \"next/navigation\";\n\ninterface SwiperSideBarProps {\n  children: React.ReactNode;\n}\n\nexport default function SwiperSideBar({ children }: SwiperSideBarProps) {\n  const segments = useSelectedLayoutSegments();\n  const courseSegment = segments[1];\n  const nestedRoute = segments[2];\n  const showSidebar =\n    nestedRoute !== \"story\" &&\n    nestedRoute !== \"voices\" &&\n    nestedRoute !== \"localization\";\n\n  // Render data...\n  let [showList, setShowList] = useState(courseSegment === null);\n  useEffect(() => {\n    setShowList(courseSegment === null);\n  }, [courseSegment]);\n\n  let toggleShow = React.useCallback(() => {\n    setShowList((value) => !value);\n  }, []);\n\n  const handlers = useSwipeable({\n    onSwipedRight: () => setShowList(true),\n    onSwipedLeft: () => setShowList(false),\n  });\n\n  useEffect(() => {\n    // Add event listener when the component mounts\n    window.addEventListener(\"toggleSidebar\", toggleShow);\n\n    // Clean up the event listener when the component unmounts\n    return () => {\n      window.removeEventListener(\"toggleSidebar\", toggleShow);\n    };\n  }, [toggleShow]);\n\n  return (\n    <>\n      <div\n        {...handlers}\n        className={\n          \"grid h-[100dvh] min-h-0 w-full overflow-hidden [grid-template-areas:'header_header''nav_main'] [grid-template-rows:auto_minmax(0,1fr)] \" +\n          (showSidebar\n            ? \"[grid-template-columns:400px_minmax(0,1fr)] max-[1250px]:[grid-template-columns:0_minmax(0,1fr)]\"\n            : \"[grid-template-columns:0_minmax(0,1fr)]\")\n        }\n      >\n        {showSidebar ? (\n          <CourseList\n            course_id={courseSegment}\n            showList={showList}\n            toggleShow={toggleShow}\n          />\n        ) : null}\n        {children}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/(course)/types.ts",
    "content": "export type CourseProps = {\n  id: number;\n  short: string | null;\n  about: string | null;\n  official: boolean;\n  count: number;\n  public: boolean;\n  fromLanguageId: string;\n  from_language: number;\n  from_language_short: string;\n  from_language_name: string;\n  learningLanguageId: string;\n  learning_language: number;\n  learning_language_short: string;\n  learning_language_name: string;\n  contributors: string[];\n  contributors_past: string[];\n  todo_count: number;\n};\n\nexport type ContributorSummaryProps = {\n  legacyUserId: number;\n  name: string;\n  image: string | null;\n  discordLinked: boolean;\n};\n\nexport type DetailedCourseProps = Omit<\n  CourseProps,\n  \"contributors\" | \"contributors_past\"\n> & {\n  contributors: ContributorSummaryProps[];\n  contributors_past: ContributorSummaryProps[];\n};\n\nexport type StoryListDataProps = {\n  id: number;\n  name: string;\n  course_id: number;\n  image: string;\n  set_id: number;\n  set_index: number;\n  date?: number | string | Date;\n  change_date?: number | string | Date;\n  status: string;\n  public: boolean;\n  todo_count: number;\n  approvalCount: number;\n  author: string;\n  author_change: string | null;\n};\n\nexport type CourseImportProps = {\n  id: number;\n  set_id: number;\n  set_index: number;\n  name: string;\n  image_done: string;\n  image: string;\n  copies: string;\n};\n"
  },
  {
    "path": "src/app/editor/_components/breadcrumbs.tsx",
    "content": "import React from \"react\";\nimport LanguageFlag from \"@/components/ui/language-flag\";\nimport Link from \"next/link\";\nimport EditorButton from \"../editor_button\";\n\ninterface BreadcrumbLanguage {\n  languageId?: string;\n  name?: string;\n}\n\ninterface BreadcrumbStoryData {\n  image?: string | number | null;\n  name?: string;\n}\n\ninterface BreadcrumbPartData {\n  type: string;\n  href?: string;\n  lang1?: BreadcrumbLanguage;\n  lang2?: BreadcrumbLanguage;\n  name?: string;\n  data?: BreadcrumbStoryData;\n}\n\nfunction MyLink({\n  href,\n  children,\n  className,\n}: {\n  href?: string | undefined;\n  children: React.ReactNode;\n  className: string;\n}) {\n  const linkClassName = `${className} opacity-70 hover:brightness-90 hover:opacity-100 hover:text-[var(--text-color)]`;\n  if (href !== undefined)\n    return (\n      <Link href={href} className={linkClassName}>\n        {children}\n      </Link>\n    );\n  return <span className={className}>{children}</span>;\n}\n\nfunction BreadcrumbPart({\n  part,\n  hide,\n}: {\n  part: BreadcrumbPartData;\n  hide: boolean;\n}) {\n  let class_name =\n    \"flex h-[50px] items-center overflow-hidden rounded-[14px] border-0 bg-[var(--body-background)] px-[5px] py-[5px] no-underline [&_img]:mb-[5px] [&_img]:mr-[5px] [&_img]:pl-0\";\n  if (hide) {\n    class_name += \" max-[460px]:hidden\";\n  }\n  if (part.type === \"sep\") {\n    return (\n      <MyLink className={class_name} href={part.href}>\n        /\n      </MyLink>\n    );\n  }\n  if (part.type === \"course\") {\n    if (!part.lang2) {\n      return (\n        <MyLink className={class_name} href={part.href}>\n          <LanguageFlag languageId={part.lang1?.languageId} width={40} />\n          {part.lang1?.name ? (\n            <span className=\"overflow-hidden text-ellipsis whitespace-nowrap\">{`${\n              part?.name || part.lang1?.name\n            }`}</span>\n          ) : null}\n        </MyLink>\n      );\n    }\n    return (\n      <MyLink className={class_name} href={part.href}>\n        <span className=\"flex\">\n          <LanguageFlag languageId={part.lang1?.languageId} width={40} />\n          <LanguageFlag\n            languageId={part.lang2?.languageId}\n            width={36}\n            className=\"ml-[-28px] mt-[10px]\"\n          />\n        </span>\n        {part.lang1?.name && part.lang2?.name ? (\n          <span className=\"overflow-hidden text-ellipsis whitespace-nowrap\">\n            {part?.name || `${part.lang1?.name} (from ${part.lang2?.name})`}\n          </span>\n        ) : null}\n      </MyLink>\n    );\n  }\n  if (part.type === \"story\") {\n    return (\n      <MyLink className={class_name} href={part.href}>\n        {part.data?.image ? (\n          <img\n            className=\"h-9\"\n            alt=\"story title\"\n            src={`https://stories-cdn.duolingo.com/image/${part.data?.image}.svg`}\n          />\n        ) : (\n          <img alt=\"story title\" src={`/icons/empty_title.svg`} />\n        )}\n        <span className=\"overflow-hidden text-ellipsis whitespace-nowrap\">\n          {part.data?.name}\n        </span>\n      </MyLink>\n    );\n  }\n  return (\n    <MyLink className={class_name} href={part.href}>\n      <span className=\"overflow-hidden text-ellipsis whitespace-nowrap\">\n        {part.type}\n      </span>\n    </MyLink>\n  );\n}\n\nexport function Breadcrumbs({ path }: { path: BreadcrumbPartData[] }) {\n  let link;\n  let hide = path.length > 3;\n  for (let part of path) {\n    if (part.href) link = part.href;\n  }\n  return (\n    <>\n      {hide ? (\n        <div className=\"hidden max-[460px]:inline\">\n          <EditorButton\n            id=\"button_back\"\n            href={link}\n            data-cy=\"button_back\"\n            img={\"back.svg\"}\n            text={\"Back\"}\n            style={{ paddingLeft: 0 }}\n          />\n        </div>\n      ) : (\n        <></>\n      )}\n      {path.map((d, i) => (\n        <BreadcrumbPart key={i} part={d} hide={hide} />\n      ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/_components/editor_command_palette.tsx",
    "content": "\"use client\";\n\nimport React, { startTransition } from \"react\";\nimport { useVirtualizer } from \"@tanstack/react-virtual\";\nimport { useQuery } from \"convex/react\";\nimport {\n  ArrowLeftIcon,\n  BookOpenIcon,\n  BookTextIcon,\n  ChevronRightIcon,\n  CornerDownLeftIcon,\n  DownloadIcon,\n  FileTextIcon,\n  FolderOpenIcon,\n  HouseIcon,\n  LanguagesIcon,\n  MicIcon,\n  PlusIcon,\n  SearchIcon,\n  ShieldIcon,\n  UserIcon,\n  UsersIcon,\n} from \"lucide-react\";\nimport { usePathname, useRouter } from \"next/navigation\";\nimport { api } from \"@convex/_generated/api\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport Flag from \"@/components/ui/flag\";\nimport Input from \"@/components/ui/input\";\nimport { Kbd, KbdGroup } from \"@/components/ui/kbd\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport LanguageFlag from \"@/components/ui/language-flag\";\nimport { authClient } from \"@/lib/auth-client\";\nimport { formatStorySetLabel, matchesStorySearch } from \"@/lib/story-search\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  CourseProps,\n  StoryListDataProps,\n} from \"@/app/editor/(course)/types\";\n\ntype PaletteSection = \"root\" | \"editor\" | \"admin\" | \"public\";\ntype AdminListSection = \"courses\" | \"languages\";\ntype EditorStoryState = \"draft\" | \"feedback\" | \"finished\" | \"published\";\ntype PaletteIcon =\n  | \"add\"\n  | \"admin\"\n  | \"courses\"\n  | \"docs\"\n  | \"editor\"\n  | \"home\"\n  | \"import\"\n  | \"languages\"\n  | \"profile\"\n  | \"public\"\n  | \"stories\"\n  | \"users\"\n  | \"voices\";\n\ntype PublicCourseListItem = {\n  id: number;\n  short: string;\n  name: string;\n  count: number;\n  fromLanguageId: string;\n  from_language_name: string;\n  learningLanguageId: string;\n  learning_language_name: string;\n};\n\ntype PublicCourseStory = {\n  id: number;\n  name: string;\n  image: string;\n  set_id: number;\n  set_index: number;\n};\n\ntype PublicCoursePageData = {\n  short: string;\n  count: number;\n  from_language_name: string;\n  learning_language_name: string;\n  learningLanguageId: string;\n  stories: PublicCourseStory[];\n} | null;\n\ntype AdminLanguageItem = {\n  id: number;\n  name: string;\n  short: string;\n  flag: number;\n  flag_file: string;\n  speaker: string;\n  rtl: boolean;\n};\n\ntype AdminCourseItem = {\n  id: number;\n  learning_language: number;\n  from_language: number;\n  public: boolean;\n  official: boolean;\n  name: string | null;\n  about: string | null;\n  conlang: boolean;\n  short: string | null;\n  tags: string[];\n};\n\ntype PaletteFlag = {\n  flag: number;\n  flag_file: string;\n  short: string;\n};\n\ntype AdminCourseData = {\n  courses: AdminCourseItem[];\n  languages: AdminLanguageItem[];\n};\n\ntype PaletteItem = {\n  id: string;\n  kind:\n    | \"admin-add\"\n    | \"admin-overview\"\n    | \"admin-course\"\n    | \"admin-list\"\n    | \"admin-language\"\n    | \"admin-route\"\n    | \"course\"\n    | \"course-action\"\n    | \"course-overview\"\n    | \"editor-overview\"\n    | \"public-course\"\n    | \"public-course-overview\"\n    | \"public-overview\"\n    | \"public-story\"\n    | \"section\"\n    | \"story\";\n  label: string;\n  subtitle: string;\n  meta?: string;\n  searchable?: boolean;\n  presentation?: \"overview\";\n  course?: CourseProps;\n  flagData?: PaletteFlag;\n  href?: string;\n  icon?: PaletteIcon;\n  adminCourse?: AdminCourseItem;\n  adminLanguage?: AdminLanguageItem;\n  publicCourse?: PublicCourseListItem;\n  publicStory?: PublicCourseStory;\n  section?: Exclude<PaletteSection, \"root\">;\n  story?: StoryListDataProps;\n};\n\nexport default function EditorCommandPalette({\n  canAdmin: canAdminProp,\n}: {\n  canAdmin?: boolean;\n}) {\n  const router = useRouter();\n  const pathname = usePathname();\n  const { data: session } = authClient.useSession();\n  const sessionUser = session?.user as { role?: string } | undefined;\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const itemRefs = React.useRef<Array<HTMLButtonElement | null>>([]);\n  const scrollViewportRef = React.useRef<HTMLDivElement>(null);\n  const [open, setOpen] = React.useState(false);\n  const [query, setQuery] = React.useState(\"\");\n  const [shortcutLabel, setShortcutLabel] = React.useState(\"Ctrl\");\n  const [selectedSection, setSelectedSection] =\n    React.useState<PaletteSection>(\"root\");\n  const [selectedAdminList, setSelectedAdminList] =\n    React.useState<AdminListSection | null>(null);\n  const [selectedCourseKey, setSelectedCourseKey] = React.useState<\n    string | null\n  >(null);\n  const [selectedPublicCourseKey, setSelectedPublicCourseKey] = React.useState<\n    string | null\n  >(null);\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const previousPathnameRef = React.useRef(pathname);\n  const pathSegments = React.useMemo(\n    () => pathname.split(\"/\").filter(Boolean),\n    [pathname],\n  );\n  const isEditorRoute = pathSegments[0] === \"editor\";\n  const isAdminRoute = pathSegments[0] === \"admin\";\n  const currentCourseSegment =\n    isEditorRoute && pathSegments[1] === \"course\"\n      ? (pathSegments[2] ?? null)\n      : null;\n  const isEditorStoryRoute =\n    isEditorRoute &&\n    (pathSegments[1] === \"story\" ||\n      (pathSegments[1] === \"course\" && pathSegments[3] === \"story\"));\n  const currentAdminList =\n    isAdminRoute &&\n    (pathSegments[1] === \"languages\" || pathSegments[1] === \"courses\")\n      ? (pathSegments[1] as AdminListSection)\n      : null;\n  const rootSegment = pathSegments[0] ?? null;\n  const currentPublicCourseSegment =\n    !isEditorRoute &&\n    !isAdminRoute &&\n    rootSegment !== null &&\n    rootSegment.includes(\"-\") &&\n    rootSegment !== \"story\" &&\n    rootSegment !== \"docs\" &&\n    rootSegment !== \"profile\" &&\n    rootSegment !== \"faq\" &&\n    rootSegment !== \"privacy_policy\" &&\n    rootSegment !== \"learn\" &&\n    rootSegment !== \"auth\"\n      ? rootSegment\n      : null;\n  const showTrigger = !isEditorStoryRoute;\n  const canAdmin =\n    canAdminProp === true || isAdminRoute || sessionUser?.role === \"admin\";\n  const sidebarData = useQuery(api.editorRead.getEditorSidebarData, {});\n  const courses = (sidebarData?.courses ?? []) as CourseProps[];\n  const publicCourseList = useQuery(api.landing.getPublicCourseList, {}) as\n    | PublicCourseListItem[]\n    | undefined;\n  const publicCourses = publicCourseList ?? [];\n  const adminLanguageList = useQuery(\n    api.adminData.getAdminLanguages,\n    canAdmin &&\n      open &&\n      (selectedAdminList === \"languages\" || currentAdminList === \"languages\")\n      ? {}\n      : \"skip\",\n  ) as AdminLanguageItem[] | undefined;\n  const adminCourseData = useQuery(\n    api.adminData.getAdminCourses,\n    canAdmin &&\n      open &&\n      (selectedAdminList === \"courses\" || currentAdminList === \"courses\")\n      ? {}\n      : \"skip\",\n  ) as AdminCourseData | undefined;\n  const adminLanguages = adminLanguageList ?? [];\n  const adminCourses = adminCourseData?.courses ?? [];\n\n  let currentCourse: CourseProps | null = null;\n  for (const course of courses) {\n    if (\n      (currentCourseSegment !== null &&\n        course.short === currentCourseSegment) ||\n      String(course.id) === currentCourseSegment\n    ) {\n      currentCourse = course;\n      break;\n    }\n  }\n\n  let currentPublicCourse: PublicCourseListItem | null = null;\n  for (const course of publicCourses) {\n    if (course.short === currentPublicCourseSegment) {\n      currentPublicCourse = course;\n      break;\n    }\n  }\n\n  let selectedCourse: CourseProps | null = null;\n  for (const course of courses) {\n    if (getCourseKey(course) === selectedCourseKey) {\n      selectedCourse = course;\n      break;\n    }\n  }\n\n  let selectedPublicCourse: PublicCourseListItem | null = null;\n  for (const course of publicCourses) {\n    if (getPublicCourseKey(course) === selectedPublicCourseKey) {\n      selectedPublicCourse = course;\n      break;\n    }\n  }\n\n  const storyCourse =\n    open && selectedSection === \"editor\" && selectedCourse\n      ? selectedCourse\n      : open &&\n          selectedSection === \"editor\" &&\n          currentCourse &&\n          selectedCourseKey !== null\n        ? currentCourse\n        : null;\n  const storyCourseIdentifier = storyCourse\n    ? getCourseIdentifier(storyCourse)\n    : null;\n  const storyResults = useQuery(\n    api.editorRead.getEditorStoriesByCourseLegacyId,\n    storyCourseIdentifier ? { identifier: storyCourseIdentifier } : \"skip\",\n  ) as StoryListDataProps[] | undefined;\n  const publicStoryCourse =\n    open && selectedSection === \"public\" && selectedPublicCourse\n      ? selectedPublicCourse\n      : open &&\n          selectedSection === \"public\" &&\n          currentPublicCourse &&\n          selectedPublicCourseKey !== null\n        ? currentPublicCourse\n        : null;\n  const publicCoursePageData = useQuery(\n    api.landing.getPublicCoursePageData,\n    publicStoryCourse?.short ? { short: publicStoryCourse.short } : \"skip\",\n  ) as PublicCoursePageData | undefined;\n\n  React.useEffect(() => {\n    setShortcutLabel(\n      /Mac|iPhone|iPad/.test(window.navigator.platform) ? \"Cmd\" : \"Ctrl\",\n    );\n  }, []);\n\n  React.useEffect(() => {\n    function handleGlobalKeydown(event: KeyboardEvent) {\n      if (\n        !event.key ||\n        event.key.toLocaleLowerCase() !== \"k\" ||\n        (!event.metaKey && !event.ctrlKey) ||\n        event.altKey ||\n        event.shiftKey\n      ) {\n        return;\n      }\n\n      event.preventDefault();\n      openPaletteInstant(\n        canAdmin,\n        currentCourse,\n        currentPublicCourse,\n        currentAdminList,\n        isAdminRoute,\n        isEditorRoute,\n        setOpen,\n        setQuery,\n        setSelectedSection,\n        setSelectedAdminList,\n        setSelectedCourseKey,\n        setSelectedPublicCourseKey,\n        setActiveIndex,\n      );\n    }\n\n    window.addEventListener(\"keydown\", handleGlobalKeydown);\n    return () => window.removeEventListener(\"keydown\", handleGlobalKeydown);\n  }, [\n    canAdmin,\n    currentAdminList,\n    currentCourse,\n    currentPublicCourse,\n    isAdminRoute,\n    isEditorRoute,\n  ]);\n\n  React.useEffect(() => {\n    if (!open) return;\n\n    const shouldSelectText =\n      selectedCourseKey === null &&\n      selectedPublicCourseKey === null &&\n      selectedAdminList === null;\n    const timeoutId = window.setTimeout(() => {\n      inputRef.current?.focus();\n      if (shouldSelectText) {\n        inputRef.current?.select();\n      }\n    }, 0);\n\n    return () => window.clearTimeout(timeoutId);\n  }, [open, selectedAdminList, selectedCourseKey, selectedPublicCourseKey]);\n\n  React.useEffect(() => {\n    if (pathname === previousPathnameRef.current) return;\n\n    previousPathnameRef.current = pathname;\n    setOpen(false);\n    resetPaletteState(\n      setQuery,\n      setSelectedSection,\n      setSelectedAdminList,\n      setSelectedCourseKey,\n      setSelectedPublicCourseKey,\n      setActiveIndex,\n    );\n  }, [pathname]);\n\n  const trimmedQuery = query.trim();\n  const normalizedCourseQuery = trimmedQuery.toLocaleLowerCase();\n\n  const items: Array<PaletteItem> = [];\n  const adminLanguageMap = new Map(\n    (adminCourseData?.languages ?? []).map((language) => [\n      language.id,\n      language,\n    ]),\n  );\n\n  if (selectedSection === \"root\") {\n    const rootItems: PaletteItem[] = [\n      {\n        id: \"section:public\",\n        kind: \"section\",\n        label: \"Stories\",\n        subtitle: \"Browse public courses and published stories\",\n        meta: `${publicCourses.length} courses`,\n        icon: \"public\",\n        section: \"public\",\n      },\n      {\n        id: \"route:docs\",\n        kind: \"admin-route\",\n        label: \"Docs\",\n        subtitle: \"Open contributor documentation\",\n        icon: \"docs\",\n        href: \"/docs\",\n      },\n      {\n        id: \"route:profile\",\n        kind: \"admin-route\",\n        label: \"Profile\",\n        subtitle: \"Open your contributor profile\",\n        icon: \"profile\",\n        href: \"/profile\",\n      },\n      {\n        id: \"section:editor\",\n        kind: \"section\",\n        label: \"Editor\",\n        subtitle: \"Open editor overview, courses, and stories\",\n        meta: `${courses.length} courses`,\n        icon: \"editor\",\n        section: \"editor\",\n      },\n    ];\n\n    if (canAdmin) {\n      rootItems.push({\n        id: \"section:admin\",\n        kind: \"section\",\n        label: \"Admin\",\n        subtitle: \"Open admin overview and moderation tools\",\n        icon: \"admin\",\n        section: \"admin\",\n      });\n    }\n\n    appendRankedPaletteItems(items, rootItems, normalizedCourseQuery);\n  } else if (selectedSection === \"public\" && selectedPublicCourse) {\n    if (trimmedQuery === \"\") {\n      items.push({\n        id: `public-course-overview:${getPublicCourseKey(selectedPublicCourse)}`,\n        kind: \"public-course-overview\",\n        label: `${selectedPublicCourse.learning_language_name} [${selectedPublicCourse.from_language_name}]`,\n        subtitle: \"Open public course page\",\n        meta: `${selectedPublicCourse.count} stories`,\n        searchable: false,\n        presentation: \"overview\",\n        publicCourse: selectedPublicCourse,\n      });\n    }\n\n    for (const story of publicCoursePageData?.stories ?? []) {\n      if (!matchesStorySearch(story, trimmedQuery)) continue;\n      items.push({\n        id: `public-story:${story.id}`,\n        kind: \"public-story\",\n        label: story.name,\n        subtitle: `Set ${formatStorySetLabel(story)}`,\n        publicCourse: selectedPublicCourse,\n        publicStory: story,\n      });\n    }\n  } else if (selectedSection === \"admin\" && selectedAdminList === \"languages\") {\n    if (trimmedQuery === \"\") {\n      items.push({\n        id: \"admin:overview:languages\",\n        kind: \"admin-route\",\n        label: \"Languages\",\n        subtitle: \"Open the admin languages page\",\n        icon: \"languages\",\n        href: \"/admin/languages\",\n        searchable: false,\n        presentation: \"overview\",\n      });\n    }\n\n    const languageActions: PaletteItem[] = [\n      {\n        id: \"admin:add-language\",\n        kind: \"admin-add\",\n        label: \"Add language\",\n        subtitle: \"Create a new language\",\n        icon: \"add\",\n        href: \"/admin/languages?addLanguage=1\",\n      },\n    ];\n\n    appendRankedPaletteItems(items, languageActions, normalizedCourseQuery);\n\n    for (const language of adminLanguages) {\n      if (\n        trimmedQuery &&\n        !language.name.toLocaleLowerCase().includes(normalizedCourseQuery) &&\n        !language.short.toLocaleLowerCase().includes(normalizedCourseQuery) &&\n        !String(language.id).includes(normalizedCourseQuery)\n      ) {\n        continue;\n      }\n\n      items.push({\n        id: `admin-language:${language.id}`,\n        kind: \"admin-language\",\n        label: language.name,\n        subtitle: `${language.short.toUpperCase()} • ${language.speaker || \"No default voice\"}`,\n        meta: language.rtl ? \"RTL\" : undefined,\n        adminLanguage: language,\n        flagData: {\n          short: language.short,\n          flag: language.flag,\n          flag_file: language.flag_file,\n        },\n      });\n    }\n  } else if (selectedSection === \"admin\" && selectedAdminList === \"courses\") {\n    if (trimmedQuery === \"\") {\n      items.push({\n        id: \"admin:overview:courses\",\n        kind: \"admin-route\",\n        label: \"Courses\",\n        subtitle: \"Open the admin courses page\",\n        icon: \"courses\",\n        href: \"/admin/courses\",\n        searchable: false,\n        presentation: \"overview\",\n      });\n    }\n\n    const courseAdminActions: PaletteItem[] = [\n      {\n        id: \"admin:add-course\",\n        kind: \"admin-add\",\n        label: \"Add course\",\n        subtitle: \"Create a new course\",\n        icon: \"add\",\n        href: \"/admin/courses?addCourse=1\",\n      },\n    ];\n\n    appendRankedPaletteItems(items, courseAdminActions, normalizedCourseQuery);\n\n    for (const course of adminCourses) {\n      const learningLanguage = adminLanguageMap.get(course.learning_language);\n      const fromLanguage = adminLanguageMap.get(course.from_language);\n      const normalizedFields = [\n        learningLanguage?.name ?? \"\",\n        fromLanguage?.name ?? \"\",\n        course.short ?? \"\",\n        course.name ?? \"\",\n        String(course.id),\n      ];\n      if (\n        trimmedQuery &&\n        !normalizedFields.some((field) =>\n          field.toLocaleLowerCase().includes(normalizedCourseQuery),\n        )\n      ) {\n        continue;\n      }\n\n      items.push({\n        id: `admin-course:${course.id}`,\n        kind: \"admin-course\",\n        label:\n          course.name?.trim() ||\n          `${learningLanguage?.name ?? \"Unknown\"} [${fromLanguage?.name ?? \"Unknown\"}]`,\n        subtitle: `${learningLanguage?.name ?? \"Unknown\"} from ${fromLanguage?.name ?? \"Unknown\"}`,\n        meta: course.public ? \"Public\" : undefined,\n        adminCourse: course,\n        flagData: learningLanguage\n          ? {\n              short: learningLanguage.short,\n              flag: learningLanguage.flag,\n              flag_file: learningLanguage.flag_file,\n            }\n          : undefined,\n      });\n    }\n  } else if (selectedSection === \"admin\") {\n    if (trimmedQuery === \"\") {\n      items.push({\n        id: \"admin:overview\",\n        kind: \"admin-overview\",\n        label: \"Admin\",\n        subtitle: \"Open admin overview\",\n        icon: \"admin\",\n        href: \"/admin\",\n        searchable: false,\n        presentation: \"overview\",\n      });\n    }\n\n    const adminItems: PaletteItem[] = [\n      {\n        id: \"admin:users\",\n        kind: \"admin-route\",\n        label: \"Users\",\n        subtitle: \"Manage contributor access\",\n        icon: \"users\",\n        href: \"/admin/users\",\n      },\n      {\n        id: \"admin:languages\",\n        kind: \"admin-list\",\n        label: \"Languages\",\n        subtitle: \"Browse and edit languages\",\n        icon: \"languages\",\n      },\n      {\n        id: \"admin:courses\",\n        kind: \"admin-list\",\n        label: \"Courses\",\n        subtitle: \"Browse and edit courses\",\n        icon: \"courses\",\n      },\n      {\n        id: \"admin:story\",\n        kind: \"admin-route\",\n        label: \"Story\",\n        subtitle: \"Open story admin tools\",\n        icon: \"stories\",\n        href: \"/admin/story\",\n      },\n    ];\n\n    appendRankedPaletteItems(items, adminItems, normalizedCourseQuery);\n  } else if (selectedCourse) {\n    if (trimmedQuery === \"\") {\n      items.push({\n        id: `course-overview:${getCourseKey(selectedCourse)}`,\n        kind: \"course-overview\",\n        label: `${selectedCourse.learning_language_name} [${selectedCourse.from_language_short}]`,\n        subtitle: \"Open course overview\",\n        meta: `${selectedCourse.count} stories`,\n        searchable: false,\n        presentation: \"overview\",\n        course: selectedCourse,\n      });\n    }\n\n    const courseActions: PaletteItem[] = [\n      {\n        id: `course-action:voices:${getCourseKey(selectedCourse)}`,\n        kind: \"course-action\",\n        label: \"Character voices\",\n        subtitle: \"Open the character editor for this course\",\n        icon: \"voices\",\n        href: `/editor/course/${getCourseIdentifier(selectedCourse)}/voices`,\n        course: selectedCourse,\n      },\n    ];\n\n    if (!selectedCourse.official) {\n      courseActions.push({\n        id: `course-action:import:${getCourseKey(selectedCourse)}`,\n        kind: \"course-action\",\n        label: \"Import stories\",\n        subtitle: \"Open the story import flow\",\n        icon: \"import\",\n        href: `/editor/course/${getCourseIdentifier(selectedCourse)}/import/es-en`,\n        course: selectedCourse,\n      });\n    }\n\n    appendRankedPaletteItems(items, courseActions, normalizedCourseQuery);\n\n    for (const story of storyResults ?? []) {\n      if (\n        !matchesStorySearch(story, trimmedQuery, { enableStatusFilters: true })\n      )\n        continue;\n      items.push({\n        id: `story:${story.id}`,\n        kind: \"story\",\n        label: story.name,\n        subtitle: `Set ${formatStorySetLabel(story)}`,\n        meta: story.todo_count ? `TODO ${story.todo_count}` : undefined,\n        course: selectedCourse,\n        story,\n      });\n    }\n  } else {\n    const currentCourseKey = currentCourse ? getCourseKey(currentCourse) : null;\n\n    if (selectedSection === \"public\") {\n      const currentPublicCourseKey = currentPublicCourse\n        ? getPublicCourseKey(currentPublicCourse)\n        : null;\n\n      if (trimmedQuery === \"\") {\n        items.push({\n          id: \"public:overview\",\n          kind: \"public-overview\",\n          label: \"Stories\",\n          subtitle: \"Open the public stories home page\",\n          icon: \"home\",\n          href: \"/\",\n          searchable: false,\n          presentation: \"overview\",\n        });\n      }\n\n      if (\n        currentPublicCourse &&\n        matchesPublicCourseSearch(currentPublicCourse, normalizedCourseQuery)\n      ) {\n        items.push({\n          id: `public-course:${currentPublicCourseKey}`,\n          kind: \"public-course\",\n          label: `${currentPublicCourse.learning_language_name} [${currentPublicCourse.from_language_name}]`,\n          subtitle: `Current public course • ${currentPublicCourse.from_language_name}`,\n          meta: `${currentPublicCourse.count} stories`,\n          publicCourse: currentPublicCourse,\n        });\n      }\n\n      for (const course of publicCourses) {\n        const courseKey = getPublicCourseKey(course);\n        if (courseKey === currentPublicCourseKey) continue;\n        if (!matchesPublicCourseSearch(course, normalizedCourseQuery)) continue;\n\n        items.push({\n          id: `public-course:${courseKey}`,\n          kind: \"public-course\",\n          label: `${course.learning_language_name} [${course.from_language_name}]`,\n          subtitle: course.from_language_name,\n          meta: `${course.count} stories`,\n          publicCourse: course,\n        });\n      }\n    } else {\n      if (trimmedQuery === \"\") {\n        items.push({\n          id: \"editor:overview\",\n          kind: \"editor-overview\",\n          label: \"Editor\",\n          subtitle: \"Open editor overview\",\n          icon: \"home\",\n          href: \"/editor\",\n          searchable: false,\n          presentation: \"overview\",\n        });\n      }\n\n      if (\n        currentCourse &&\n        matchesCourseSearch(currentCourse, normalizedCourseQuery)\n      ) {\n        items.push({\n          id: `course:${currentCourseKey}`,\n          kind: \"course\",\n          label: `${currentCourse.learning_language_name} [${currentCourse.from_language_short}]`,\n          subtitle: `Current course • ${currentCourse.from_language_name}`,\n          meta: `${currentCourse.count} stories`,\n          course: currentCourse,\n        });\n      }\n\n      for (const course of courses) {\n        const courseKey = getCourseKey(course);\n        if (courseKey === currentCourseKey) continue;\n        if (!matchesCourseSearch(course, normalizedCourseQuery)) continue;\n\n        items.push({\n          id: `course:${courseKey}`,\n          kind: \"course\",\n          label: `${course.learning_language_name} [${course.from_language_short}]`,\n          subtitle: course.from_language_name,\n          meta: `${course.count} stories`,\n          course,\n        });\n      }\n    }\n  }\n\n  React.useEffect(() => {\n    itemRefs.current = itemRefs.current.slice(0, items.length);\n    setActiveIndex((currentIndex) => {\n      if (items.length === 0) return 0;\n      if (currentIndex >= items.length) return items.length - 1;\n      return currentIndex;\n    });\n  }, [items.length]);\n\n  const overviewItem = items[0]?.presentation === \"overview\" ? items[0] : null;\n  const searchableItems = overviewItem ? items.slice(1) : items;\n  const searchableOffset = overviewItem ? 1 : 0;\n  const listVirtualizer = useVirtualizer({\n    count: searchableItems.length,\n    getScrollElement: () => scrollViewportRef.current,\n    estimateSize: () => 76,\n    overscan: 6,\n  });\n  const shouldVirtualize =\n    (selectedSection === \"editor\" ||\n      selectedSection === \"public\" ||\n      selectedAdminList !== null) &&\n    searchableItems.length > 12;\n\n  React.useEffect(() => {\n    if (items.length === 0) return;\n    if (overviewItem && activeIndex === 0) return;\n\n    listVirtualizer.scrollToIndex(Math.max(activeIndex - searchableOffset, 0), {\n      align: \"auto\",\n    });\n  }, [\n    activeIndex,\n    items.length,\n    listVirtualizer,\n    overviewItem,\n    searchableOffset,\n  ]);\n\n  function onOpenChange(nextOpen: boolean) {\n    if (nextOpen) {\n      openPaletteInstant(\n        canAdmin,\n        currentCourse,\n        currentPublicCourse,\n        currentAdminList,\n        isAdminRoute,\n        isEditorRoute,\n        setOpen,\n        setQuery,\n        setSelectedSection,\n        setSelectedAdminList,\n        setSelectedCourseKey,\n        setSelectedPublicCourseKey,\n        setActiveIndex,\n      );\n      return;\n    }\n\n    setOpen(false);\n    resetPaletteState(\n      setQuery,\n      setSelectedSection,\n      setSelectedAdminList,\n      setSelectedCourseKey,\n      setSelectedPublicCourseKey,\n      setActiveIndex,\n    );\n  }\n\n  function onSelectItem(item: PaletteItem) {\n    if (item.kind === \"section\" && item.section) {\n      setSelectedSection(item.section);\n      setSelectedAdminList(null);\n      setQuery(\"\");\n      setSelectedCourseKey(null);\n      setSelectedPublicCourseKey(null);\n      setActiveIndex(0);\n      return;\n    }\n\n    if (item.kind === \"admin-list\") {\n      if (item.id === \"admin:languages\" || item.id === \"admin:list:languages\") {\n        setSelectedSection(\"admin\");\n        setSelectedAdminList(\"languages\");\n        setQuery(\"\");\n        setActiveIndex(0);\n        return;\n      }\n\n      if (item.id === \"admin:courses\" || item.id === \"admin:list:courses\") {\n        setSelectedSection(\"admin\");\n        setSelectedAdminList(\"courses\");\n        setQuery(\"\");\n        setActiveIndex(0);\n        return;\n      }\n    }\n\n    if (\n      (item.kind === \"admin-add\" ||\n        item.kind === \"editor-overview\" ||\n        item.kind === \"course-action\" ||\n        item.kind === \"public-overview\" ||\n        item.kind === \"admin-overview\" ||\n        item.kind === \"admin-route\") &&\n      item.href\n    ) {\n      setOpen(false);\n      router.push(item.href);\n      return;\n    }\n\n    if (item.kind === \"course\" && item.course) {\n      setSelectedSection(\"editor\");\n      setSelectedAdminList(null);\n      setSelectedCourseKey(getCourseKey(item.course));\n      setSelectedPublicCourseKey(null);\n      setQuery(\"\");\n      setActiveIndex(0);\n      return;\n    }\n\n    if (item.kind === \"admin-language\" && item.adminLanguage) {\n      setOpen(false);\n      router.push(`/admin/languages?editLanguage=${item.adminLanguage.id}`);\n      return;\n    }\n\n    if (item.kind === \"admin-course\" && item.adminCourse) {\n      setOpen(false);\n      router.push(`/admin/courses?editCourse=${item.adminCourse.id}`);\n      return;\n    }\n\n    if (item.kind === \"public-course\" && item.publicCourse) {\n      setSelectedSection(\"public\");\n      setSelectedAdminList(null);\n      setSelectedPublicCourseKey(getPublicCourseKey(item.publicCourse));\n      setSelectedCourseKey(null);\n      setQuery(\"\");\n      setActiveIndex(0);\n      return;\n    }\n\n    if (item.kind === \"course-overview\" && item.course) {\n      const courseIdentifier = getCourseIdentifier(item.course);\n      setOpen(false);\n      router.push(`/editor/course/${courseIdentifier}`);\n      return;\n    }\n\n    if (item.kind === \"public-course-overview\" && item.publicCourse) {\n      setOpen(false);\n      router.push(`/${item.publicCourse.short}`);\n      return;\n    }\n\n    if (item.kind === \"story\" && item.course && item.story) {\n      const courseIdentifier = getCourseIdentifier(item.course);\n      setOpen(false);\n      router.push(`/editor/course/${courseIdentifier}/story/${item.story.id}`);\n      return;\n    }\n\n    if (item.kind === \"public-story\" && item.publicStory) {\n      setOpen(false);\n      router.push(`/story/${item.publicStory.id}`);\n    }\n  }\n\n  function onInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      setActiveIndex((currentIndex) =>\n        items.length === 0 ? 0 : Math.min(currentIndex + 1, items.length - 1),\n      );\n      return;\n    }\n\n    if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      setActiveIndex((currentIndex) => Math.max(currentIndex - 1, 0));\n      return;\n    }\n\n    if (event.key === \"Enter\") {\n      event.preventDefault();\n      const activeItem = items[activeIndex];\n      if (activeItem) onSelectItem(activeItem);\n      return;\n    }\n\n    if (query.trim() === \"\") {\n      if (event.key === \"Backspace\") {\n        if (selectedCourse || selectedPublicCourse) {\n          event.preventDefault();\n          setSelectedCourseKey(null);\n          setSelectedPublicCourseKey(null);\n          setActiveIndex(0);\n          return;\n        }\n\n        if (selectedAdminList !== null) {\n          event.preventDefault();\n          setSelectedAdminList(null);\n          setActiveIndex(0);\n          return;\n        }\n\n        if (selectedSection !== \"root\") {\n          event.preventDefault();\n          setSelectedSection(\"root\");\n          setActiveIndex(0);\n          return;\n        }\n      }\n\n      if (event.key === \"ArrowLeft\") {\n        if (selectedCourse || selectedPublicCourse) {\n          event.preventDefault();\n          setSelectedCourseKey(null);\n          setSelectedPublicCourseKey(null);\n          setActiveIndex(0);\n          return;\n        }\n\n        if (selectedAdminList !== null) {\n          event.preventDefault();\n          setSelectedAdminList(null);\n          setActiveIndex(0);\n          return;\n        }\n\n        if (selectedSection !== \"root\") {\n          event.preventDefault();\n          setSelectedSection(\"root\");\n          setActiveIndex(0);\n        }\n      }\n    }\n  }\n\n  const isLoadingResults =\n    (selectedSection === \"editor\" &&\n      Boolean(selectedCourse) &&\n      storyResults === undefined) ||\n    (selectedSection === \"public\" &&\n      Boolean(selectedPublicCourse) &&\n      publicCoursePageData === undefined) ||\n    (selectedSection === \"admin\" &&\n      ((selectedAdminList === \"languages\" && adminLanguageList === undefined) ||\n        (selectedAdminList === \"courses\" && adminCourseData === undefined)));\n  const emptyState = getEmptyState(\n    selectedSection,\n    Boolean(selectedCourse) || Boolean(selectedPublicCourse),\n    selectedAdminList,\n    trimmedQuery,\n  );\n  const placeholder =\n    selectedSection === \"root\"\n      ? \"Search stories, editor, admin, docs, or profile\"\n      : selectedCourse\n        ? \"Search stories by set, title, or status\"\n        : selectedPublicCourse\n          ? \"Search stories by set or title\"\n          : selectedAdminList === \"languages\"\n            ? \"Search admin languages\"\n            : selectedAdminList === \"courses\"\n              ? \"Search admin courses\"\n              : selectedSection === \"admin\"\n                ? \"Search admin destinations\"\n                : selectedSection === \"public\"\n                  ? \"Search public courses by language\"\n                  : \"Search courses by language\";\n  const ariaLabel =\n    selectedSection === \"root\"\n      ? \"Search navigation destinations\"\n      : selectedCourse || selectedPublicCourse\n        ? \"Search stories\"\n        : selectedAdminList === \"languages\"\n          ? \"Search admin languages\"\n          : selectedAdminList === \"courses\"\n            ? \"Search admin courses\"\n            : selectedSection === \"admin\"\n              ? \"Search admin destinations\"\n              : selectedSection === \"public\"\n                ? \"Search public courses\"\n                : \"Search courses\";\n  const canGoBack =\n    Boolean(selectedCourse) ||\n    Boolean(selectedPublicCourse) ||\n    selectedAdminList !== null ||\n    selectedSection !== \"root\";\n  const breadcrumbs: Array<{\n    key: string;\n    label: string;\n    onClick?: () => void;\n  }> = [{ key: \"root\", label: \"Navigate\" }];\n\n  if (selectedSection === \"editor\") {\n    breadcrumbs.push({\n      key: \"editor\",\n      label: \"Editor\",\n      onClick: selectedCourse\n        ? () => {\n            setSelectedCourseKey(null);\n            setActiveIndex(0);\n          }\n        : undefined,\n    });\n\n    if (selectedCourse) {\n      breadcrumbs.push({\n        key: `editor-course:${getCourseKey(selectedCourse)}`,\n        label: `${selectedCourse.learning_language_name} [${selectedCourse.from_language_short}]`,\n      });\n    }\n  } else if (selectedSection === \"public\") {\n    breadcrumbs.push({\n      key: \"public\",\n      label: \"Stories\",\n      onClick: selectedPublicCourse\n        ? () => {\n            setSelectedPublicCourseKey(null);\n            setActiveIndex(0);\n          }\n        : undefined,\n    });\n\n    if (selectedPublicCourse) {\n      breadcrumbs.push({\n        key: `public-course:${getPublicCourseKey(selectedPublicCourse)}`,\n        label: `${selectedPublicCourse.learning_language_name} [${selectedPublicCourse.from_language_name}]`,\n      });\n    }\n  } else if (selectedSection === \"admin\") {\n    breadcrumbs.push({\n      key: \"admin\",\n      label: \"Admin\",\n      onClick: selectedAdminList\n        ? () => {\n            setSelectedAdminList(null);\n            setActiveIndex(0);\n          }\n        : undefined,\n    });\n\n    if (selectedAdminList === \"languages\") {\n      breadcrumbs.push({\n        key: \"admin-languages\",\n        label: \"Languages\",\n      });\n    } else if (selectedAdminList === \"courses\") {\n      breadcrumbs.push({\n        key: \"admin-courses\",\n        label: \"Courses\",\n      });\n    }\n  }\n\n  return (\n    <>\n      {showTrigger ? (\n        <button\n          type=\"button\"\n          className=\"inline-flex h-11 min-w-0 items-center gap-3 rounded-[16px] border border-[var(--header-border)] bg-[color:color-mix(in_srgb,var(--body-background)_80%,white_20%)] px-3 text-[var(--text-color-dim)] shadow-[inset_0_1px_0_rgba(255,255,255,0.3)] transition-colors hover:bg-[color:color-mix(in_srgb,var(--body-background)_55%,white_45%)] hover:text-[var(--text-color)]\"\n          onClick={() =>\n            openPaletteInstant(\n              canAdmin,\n              currentCourse,\n              currentPublicCourse,\n              currentAdminList,\n              isAdminRoute,\n              isEditorRoute,\n              setOpen,\n              setQuery,\n              setSelectedSection,\n              setSelectedAdminList,\n              setSelectedCourseKey,\n              setSelectedPublicCourseKey,\n              setActiveIndex,\n            )\n          }\n          aria-label=\"Open navigation palette\"\n        >\n          <SearchIcon className=\"size-4 shrink-0\" />\n          <span className=\"hidden min-[780px]:inline\">Go to…</span>\n          <KbdGroup>\n            <Kbd className=\"min-w-7 px-2\">{shortcutLabel}</Kbd>\n            <Kbd className=\"px-2\">K</Kbd>\n          </KbdGroup>\n        </button>\n      ) : null}\n      <Dialog open={open} onOpenChange={onOpenChange}>\n        <DialogContent\n          disableAnimation\n          className=\"overflow-hidden border-[var(--header-border)] bg-[color:color-mix(in_srgb,var(--body-background)_92%,white_8%)] p-0 sm:max-w-[760px]\"\n          showCloseButton={false}\n        >\n          <DialogTitle className=\"sr-only\">Navigation palette</DialogTitle>\n          <DialogDescription className=\"sr-only\">\n            Quickly jump between contributor tools, editor courses, and stories.\n          </DialogDescription>\n          <div className=\"border-b border-[var(--header-border)] bg-[color:color-mix(in_srgb,var(--body-background)_88%,white_12%)] px-4 py-4\">\n            <div className=\"mb-3 flex items-center justify-between gap-3\">\n              <div className=\"flex min-w-0 items-center gap-2 text-[13px] text-[var(--text-color-dim)]\">\n                {canGoBack ? (\n                  <button\n                    type=\"button\"\n                    className=\"inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--header-border)] bg-[var(--body-background)] text-[var(--text-color-dim)] transition-colors hover:text-[var(--text-color)]\"\n                    onClick={() => {\n                      if (selectedCourse) {\n                        setSelectedCourseKey(null);\n                      } else if (selectedPublicCourse) {\n                        setSelectedPublicCourseKey(null);\n                      } else if (selectedAdminList !== null) {\n                        setSelectedAdminList(null);\n                      } else {\n                        setSelectedSection(\"root\");\n                      }\n                      setActiveIndex(0);\n                    }}\n                    aria-label=\"Go back\"\n                  >\n                    <ArrowLeftIcon className=\"size-4\" />\n                  </button>\n                ) : (\n                  <span className=\"sr-only\">Navigate</span>\n                )}\n                <div className=\"flex min-w-0 items-center gap-1.5 overflow-hidden\">\n                  {breadcrumbs.map((breadcrumb, index) => {\n                    const isLast = index === breadcrumbs.length - 1;\n\n                    return (\n                      <React.Fragment key={breadcrumb.key}>\n                        {index > 0 ? (\n                          <ChevronRightIcon className=\"size-3.5 shrink-0 text-[var(--text-color-dim)]/70\" />\n                        ) : null}\n                        {breadcrumb.onClick && !isLast ? (\n                          <button\n                            type=\"button\"\n                            className=\"truncate rounded-full px-2 py-1 font-medium transition-colors hover:bg-[var(--body-background)] hover:text-[var(--text-color)]\"\n                            onClick={breadcrumb.onClick}\n                          >\n                            {breadcrumb.label}\n                          </button>\n                        ) : (\n                          <span\n                            className={cn(\n                              \"truncate px-2 py-1\",\n                              isLast\n                                ? \"font-medium text-[var(--text-color)]\"\n                                : \"\",\n                            )}\n                          >\n                            {breadcrumb.label}\n                          </span>\n                        )}\n                      </React.Fragment>\n                    );\n                  })}\n                </div>\n              </div>\n              <button\n                type=\"button\"\n                className=\"transition-colors hover:text-[var(--text-color)]\"\n                onClick={() => onOpenChange(false)}\n              >\n                <Kbd className=\"min-w-8 px-2\">Esc</Kbd>\n              </button>\n            </div>\n            <div className=\"relative\">\n              <SearchIcon className=\"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[var(--text-color-dim)]\" />\n              <Input\n                ref={inputRef}\n                type=\"search\"\n                value={query}\n                placeholder={placeholder}\n                aria-label={ariaLabel}\n                autoComplete=\"off\"\n                className=\"pr-20 pl-10\"\n                onChange={(event) => {\n                  setQuery(event.target.value);\n                  setActiveIndex(0);\n                }}\n                onKeyDown={onInputKeyDown}\n              />\n              <div className=\"pointer-events-none absolute top-1/2 right-3 flex -translate-y-1/2 items-center gap-1 text-[11px] uppercase tracking-[0.08em] text-[var(--text-color-dim)]\">\n                <Kbd className=\"h-7 gap-1 px-2 normal-case\">\n                  <CornerDownLeftIcon className=\"size-3.5\" />\n                  <span className=\"uppercase tracking-[0.08em]\">Open</span>\n                </Kbd>\n              </div>\n            </div>\n          </div>\n          <div className=\"h-[min(62vh,560px)] overflow-hidden px-2 py-2\">\n            {isLoadingResults ? (\n              <div className=\"flex h-full min-h-40 items-center justify-center\">\n                <Spinner />\n              </div>\n            ) : items.length === 0 ? (\n              <div className=\"px-4 py-12 text-center text-[var(--text-color-dim)]\">\n                {emptyState}\n              </div>\n            ) : (\n              <div className=\"flex h-full min-h-0 flex-col space-y-3\">\n                {overviewItem ? (\n                  <PaletteSectionHeading\n                    label=\"Press\"\n                    action={\n                      <Kbd className=\"h-6 min-w-0 px-2 text-[11px] normal-case\">\n                        <CornerDownLeftIcon className=\"size-3.5\" />\n                      </Kbd>\n                    }\n                    trailing=\"to open\"\n                  />\n                ) : null}\n                {overviewItem ? (\n                  <PaletteListItem\n                    item={overviewItem}\n                    index={0}\n                    isActive={activeIndex === 0}\n                    setActiveIndex={setActiveIndex}\n                    onSelectItem={onSelectItem}\n                    itemRefs={itemRefs}\n                  />\n                ) : null}\n                {overviewItem ? (\n                  <PaletteSectionHeading label=\"or type to search\" />\n                ) : null}\n                <div\n                  ref={scrollViewportRef}\n                  className=\"min-h-0 flex-1 overflow-y-auto\"\n                >\n                  {shouldVirtualize ? (\n                    <div\n                      className=\"relative w-full\"\n                      style={{ height: `${listVirtualizer.getTotalSize()}px` }}\n                    >\n                      {listVirtualizer.getVirtualItems().map((virtualItem) => {\n                        const index = virtualItem.index + searchableOffset;\n                        const item = items[index];\n                        const isActive = index === activeIndex;\n\n                        return (\n                          <div\n                            key={item.id}\n                            ref={listVirtualizer.measureElement}\n                            data-index={virtualItem.index}\n                            className=\"absolute top-0 left-0 w-full pb-1\"\n                            style={{\n                              transform: `translateY(${virtualItem.start}px)`,\n                            }}\n                          >\n                            <PaletteListItem\n                              item={item}\n                              index={index}\n                              isActive={isActive}\n                              setActiveIndex={setActiveIndex}\n                              onSelectItem={onSelectItem}\n                              itemRefs={itemRefs}\n                            />\n                          </div>\n                        );\n                      })}\n                    </div>\n                  ) : (\n                    <div className=\"space-y-1\">\n                      {searchableItems.map((item, searchIndex) => {\n                        const index = searchIndex + searchableOffset;\n                        const isActive = index === activeIndex;\n\n                        return (\n                          <PaletteListItem\n                            key={item.id}\n                            item={item}\n                            index={index}\n                            isActive={isActive}\n                            setActiveIndex={setActiveIndex}\n                            onSelectItem={onSelectItem}\n                            itemRefs={itemRefs}\n                          />\n                        );\n                      })}\n                    </div>\n                  )}\n                </div>\n              </div>\n            )}\n          </div>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n\nfunction openPaletteInstant(\n  canAdmin: boolean,\n  currentCourse: CourseProps | null,\n  currentPublicCourse: PublicCourseListItem | null,\n  currentAdminList: AdminListSection | null,\n  isAdminRoute: boolean,\n  isEditorRoute: boolean,\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>,\n  setQuery: React.Dispatch<React.SetStateAction<string>>,\n  setSelectedSection: React.Dispatch<React.SetStateAction<PaletteSection>>,\n  setSelectedAdminList: React.Dispatch<\n    React.SetStateAction<AdminListSection | null>\n  >,\n  setSelectedCourseKey: React.Dispatch<React.SetStateAction<string | null>>,\n  setSelectedPublicCourseKey: React.Dispatch<\n    React.SetStateAction<string | null>\n  >,\n  setActiveIndex: React.Dispatch<React.SetStateAction<number>>,\n) {\n  const initialSection = getInitialSection({\n    canAdmin,\n    currentCourse,\n    currentPublicCourse,\n    currentAdminList,\n    isAdminRoute,\n    isEditorRoute,\n  });\n\n  setOpen(true);\n  startTransition(() => {\n    setQuery(\"\");\n    setSelectedSection(initialSection);\n    setSelectedAdminList(null);\n    setSelectedCourseKey(\n      initialSection === \"editor\" && currentCourse\n        ? getCourseKey(currentCourse)\n        : null,\n    );\n    setSelectedPublicCourseKey(\n      initialSection === \"public\" && currentPublicCourse\n        ? getPublicCourseKey(currentPublicCourse)\n        : null,\n    );\n    setActiveIndex(0);\n  });\n}\n\nfunction resetPaletteState(\n  setQuery: React.Dispatch<React.SetStateAction<string>>,\n  setSelectedSection: React.Dispatch<React.SetStateAction<PaletteSection>>,\n  setSelectedAdminList: React.Dispatch<\n    React.SetStateAction<AdminListSection | null>\n  >,\n  setSelectedCourseKey: React.Dispatch<React.SetStateAction<string | null>>,\n  setSelectedPublicCourseKey: React.Dispatch<\n    React.SetStateAction<string | null>\n  >,\n  setActiveIndex: React.Dispatch<React.SetStateAction<number>>,\n) {\n  startTransition(() => {\n    setQuery(\"\");\n    setSelectedSection(\"root\");\n    setSelectedAdminList(null);\n    setSelectedCourseKey(null);\n    setSelectedPublicCourseKey(null);\n    setActiveIndex(0);\n  });\n}\n\nfunction getInitialSection({\n  canAdmin,\n  currentCourse,\n  currentPublicCourse,\n  currentAdminList: _currentAdminList,\n  isAdminRoute: _isAdminRoute,\n  isEditorRoute,\n}: {\n  canAdmin: boolean;\n  currentCourse: CourseProps | null;\n  currentPublicCourse: PublicCourseListItem | null;\n  currentAdminList: AdminListSection | null;\n  isAdminRoute: boolean;\n  isEditorRoute: boolean;\n}) {\n  if (currentCourse || isEditorRoute) return \"editor\";\n  if (currentPublicCourse) return \"public\";\n  if (!canAdmin) return \"root\";\n  return \"root\";\n}\n\nfunction getCourseIdentifier(course: CourseProps) {\n  return course.short ?? String(course.id);\n}\n\nfunction getCourseKey(course: CourseProps) {\n  return `${course.short ?? course.id}`;\n}\n\nfunction getPublicCourseKey(course: PublicCourseListItem) {\n  return course.short;\n}\n\nfunction matchesCourseSearch(course: CourseProps, normalizedQuery: string) {\n  if (!normalizedQuery) return true;\n\n  const fields = [\n    course.learning_language_name,\n    course.from_language_name,\n    course.from_language_short,\n    course.learning_language_short,\n    course.short ?? \"\",\n    String(course.id),\n  ];\n\n  for (const field of fields) {\n    if (field.toLocaleLowerCase().includes(normalizedQuery)) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\nfunction matchesPublicCourseSearch(\n  course: PublicCourseListItem,\n  normalizedQuery: string,\n) {\n  if (!normalizedQuery) return true;\n\n  const fields = [\n    course.learning_language_name,\n    course.from_language_name,\n    course.short,\n    String(course.id),\n  ];\n\n  for (const field of fields) {\n    if (field.toLocaleLowerCase().includes(normalizedQuery)) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\nfunction matchesPaletteSearch(item: PaletteItem, normalizedQuery: string) {\n  if (!normalizedQuery) return true;\n\n  const fields = [item.label, item.subtitle, item.meta ?? \"\"];\n  for (const field of fields) {\n    if (field.toLocaleLowerCase().includes(normalizedQuery)) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\nfunction appendRankedPaletteItems(\n  target: PaletteItem[],\n  source: PaletteItem[],\n  normalizedQuery: string,\n) {\n  const rankedItems = source\n    .map((item, index) => ({\n      item,\n      index,\n      score: getPaletteSearchScore(item, normalizedQuery),\n    }))\n    .filter((entry) => entry.score !== Number.POSITIVE_INFINITY)\n    .sort((a, b) => {\n      if (a.score !== b.score) return a.score - b.score;\n      return a.index - b.index;\n    });\n\n  for (const entry of rankedItems) {\n    target.push(entry.item);\n  }\n}\n\nfunction getPaletteSearchScore(item: PaletteItem, normalizedQuery: string) {\n  if (normalizedQuery && item.searchable === false) {\n    return Number.POSITIVE_INFINITY;\n  }\n\n  if (!normalizedQuery) return 0;\n\n  const label = item.label.toLocaleLowerCase();\n  const subtitle = item.subtitle.toLocaleLowerCase();\n  const meta = (item.meta ?? \"\").toLocaleLowerCase();\n  const labelWords = label.split(/\\s+/);\n  const subtitleWords = subtitle.split(/\\s+/);\n\n  if (label === normalizedQuery) return 0;\n  if (label.startsWith(normalizedQuery)) return 1;\n  if (labelWords.some((word) => word.startsWith(normalizedQuery))) return 2;\n  if (subtitle.startsWith(normalizedQuery)) return 3;\n  if (subtitleWords.some((word) => word.startsWith(normalizedQuery))) return 4;\n  if (label.includes(normalizedQuery)) return 5;\n  if (subtitle.includes(normalizedQuery)) return 6;\n  if (meta.includes(normalizedQuery)) return 7;\n  return Number.POSITIVE_INFINITY;\n}\n\nfunction getEmptyState(\n  selectedSection: PaletteSection,\n  hasSelectedCourse: boolean,\n  selectedAdminList: AdminListSection | null,\n  trimmedQuery: string,\n) {\n  if (hasSelectedCourse) {\n    return trimmedQuery\n      ? \"No stories match this search.\"\n      : \"No stories in this course yet.\";\n  }\n\n  if (selectedSection === \"editor\") {\n    return trimmedQuery\n      ? \"No courses match this search.\"\n      : \"No courses available.\";\n  }\n\n  if (selectedSection === \"admin\") {\n    if (selectedAdminList === \"languages\") {\n      return trimmedQuery\n        ? \"No admin languages match this search.\"\n        : \"No admin languages available.\";\n    }\n\n    if (selectedAdminList === \"courses\") {\n      return trimmedQuery\n        ? \"No admin courses match this search.\"\n        : \"No admin courses available.\";\n    }\n\n    return trimmedQuery\n      ? \"No admin destinations match this search.\"\n      : \"No admin destinations available.\";\n  }\n\n  if (selectedSection === \"public\") {\n    return trimmedQuery\n      ? \"No public courses match this search.\"\n      : \"No public courses available.\";\n  }\n\n  return trimmedQuery\n    ? \"No sections match this search.\"\n    : \"No navigation sections available.\";\n}\n\nfunction getEditorStoryState(\n  story: Pick<StoryListDataProps, \"status\" | \"public\">,\n): EditorStoryState {\n  if (story.public || story.status === \"published\") return \"published\";\n  if (story.status === \"feedback\") return \"feedback\";\n  if (story.status === \"finished\") return \"finished\";\n  return \"draft\";\n}\n\nfunction getEditorStoryStateLabel(state: EditorStoryState) {\n  if (state === \"draft\") return \"✍️ Draft\";\n  if (state === \"feedback\") return \"🗨️ Feedback\";\n  if (state === \"finished\") return \"✅ Finished\";\n  return \"📢 Published\";\n}\n\nfunction PaletteSectionHeading({\n  label,\n  action,\n  trailing,\n}: {\n  label: string;\n  action?: React.ReactNode;\n  trailing?: string;\n}) {\n  return (\n    <div className=\"px-2 pt-1\">\n      <div className=\"flex items-center gap-2 text-[12px] font-semibold tracking-[0.01em] text-[var(--text-color-dim)]/90\">\n        <span>{label}</span>\n        {action}\n        {trailing ? <span>{trailing}</span> : null}\n      </div>\n    </div>\n  );\n}\n\nfunction PaletteListItem({\n  item,\n  index,\n  isActive,\n  setActiveIndex,\n  onSelectItem,\n  itemRefs,\n}: {\n  item: PaletteItem;\n  index: number;\n  isActive: boolean;\n  setActiveIndex: React.Dispatch<React.SetStateAction<number>>;\n  onSelectItem: (item: PaletteItem) => void;\n  itemRefs: React.RefObject<Array<HTMLButtonElement | null>>;\n}) {\n  const Icon = getPaletteItemIcon(item.icon);\n  const isOverviewItem = item.presentation === \"overview\";\n  const storyState = item.story ? getEditorStoryState(item.story) : null;\n\n  return (\n    <button\n      ref={(element) => {\n        itemRefs.current[index] = element;\n      }}\n      type=\"button\"\n      className={cn(\n        \"flex w-full items-center gap-3 text-left transition-colors\",\n        isOverviewItem\n          ? \"min-h-[58px] rounded-[14px] border px-3 py-2.5\"\n          : \"min-h-[72px] rounded-[18px] px-3 py-3\",\n        isOverviewItem\n          ? isActive\n            ? \"border-[color:color-mix(in_srgb,var(--button-background)_35%,white_65%)] bg-[color:color-mix(in_srgb,var(--button-background)_10%,white_90%)] text-[var(--text-color)]\"\n            : \"border-[var(--header-border)] bg-[color:color-mix(in_srgb,var(--body-background)_80%,white_20%)] text-[var(--text-color)] hover:bg-[color:color-mix(in_srgb,var(--body-background)_68%,white_32%)]\"\n          : isActive\n            ? \"bg-[var(--button-background)] text-[var(--button-color)]\"\n            : \"text-[var(--text-color)] hover:bg-[var(--body-background-faint)]\",\n      )}\n      onMouseEnter={() => setActiveIndex(index)}\n      onClick={() => onSelectItem(item)}\n    >\n      <div\n        className={cn(\n          \"flex shrink-0 items-center justify-center\",\n          isOverviewItem ? \"h-[34px] w-[34px]\" : \"h-[42px] w-[42px]\",\n        )}\n      >\n        {item.story ? (\n          <img\n            alt={`${item.label} story icon`}\n            src={`https://stories-cdn.duolingo.com/image/${item.story.image}.svg`}\n            width=\"42\"\n            height=\"38\"\n            className=\"block h-[38px] w-[42px]\"\n          />\n        ) : item.publicStory ? (\n          <img\n            alt={`${item.label} story icon`}\n            src={`https://stories-cdn.duolingo.com/image/${item.publicStory.image}.svg`}\n            width=\"42\"\n            height=\"38\"\n            className=\"block h-[38px] w-[42px]\"\n          />\n        ) : item.kind === \"course-action\" && Icon ? (\n          <div\n            className={cn(\n              \"flex items-center justify-center border\",\n              isOverviewItem\n                ? \"h-[34px] w-[34px] rounded-[10px]\"\n                : \"h-[42px] w-[42px] rounded-[12px]\",\n              isActive\n                ? isOverviewItem\n                  ? \"border-[color:color-mix(in_srgb,var(--button-background)_30%,white_70%)] bg-white/70 text-[var(--text-color)]\"\n                  : \"border-white/30 bg-white/15 text-[var(--button-color)]\"\n                : \"border-[var(--header-border)] bg-[var(--body-background)] text-[var(--text-color-dim)]\",\n            )}\n          >\n            <Icon className={cn(isOverviewItem ? \"size-[18px]\" : \"size-5\")} />\n          </div>\n        ) : item.course ? (\n          <LanguageFlag\n            languageId={item.course.learningLanguageId}\n            width={isOverviewItem ? 34 : 42}\n          />\n        ) : item.publicCourse ? (\n          <LanguageFlag\n            languageId={item.publicCourse.learningLanguageId}\n            width={isOverviewItem ? 34 : 42}\n          />\n        ) : item.flagData ? (\n          <Flag\n            iso={item.flagData.short}\n            width={isOverviewItem ? 34 : 42}\n            flag={item.flagData.flag}\n            flag_file={item.flagData.flag_file}\n          />\n        ) : Icon ? (\n          <div\n            className={cn(\n              \"flex items-center justify-center border\",\n              isOverviewItem\n                ? \"h-[34px] w-[34px] rounded-[10px]\"\n                : \"h-[42px] w-[42px] rounded-[12px]\",\n              isActive\n                ? isOverviewItem\n                  ? \"border-[color:color-mix(in_srgb,var(--button-background)_30%,white_70%)] bg-white/70 text-[var(--text-color)]\"\n                  : \"border-white/30 bg-white/15 text-[var(--button-color)]\"\n                : \"border-[var(--header-border)] bg-[var(--body-background)] text-[var(--text-color-dim)]\",\n            )}\n          >\n            <Icon className={cn(isOverviewItem ? \"size-[18px]\" : \"size-5\")} />\n          </div>\n        ) : (\n          <div\n            className={cn(\n              \"flex items-center justify-center border text-[12px] font-semibold\",\n              isOverviewItem\n                ? \"h-[34px] w-[34px] rounded-[10px]\"\n                : \"h-[42px] w-[42px] rounded-[12px]\",\n              isActive\n                ? isOverviewItem\n                  ? \"border-[color:color-mix(in_srgb,var(--button-background)_30%,white_70%)] bg-white/70 text-[var(--text-color)]\"\n                  : \"border-white/30 bg-white/15 text-[var(--button-color)]\"\n                : \"border-[var(--header-border)] bg-[var(--body-background)] text-[var(--text-color-dim)]\",\n            )}\n          >\n            {item.story\n              ? formatStorySetLabel(item.story)\n              : item.publicStory\n                ? formatStorySetLabel(item.publicStory)\n                : \"Go\"}\n          </div>\n        )}\n      </div>\n      <div className=\"min-w-0 flex-1\">\n        <div\n          className={cn(\n            \"truncate font-semibold\",\n            isOverviewItem ? \"text-[14px]\" : \"text-[15px]\",\n          )}\n        >\n          {item.label}\n        </div>\n        {isOverviewItem ? null : (\n          <div\n            className={cn(\n              \"truncate text-[13px]\",\n              isActive\n                ? \"text-[color:rgba(255,255,255,0.82)]\"\n                : \"text-[var(--text-color-dim)]\",\n            )}\n          >\n            {item.subtitle}\n          </div>\n        )}\n      </div>\n      <div className=\"flex shrink-0 items-center gap-3\">\n        {storyState ? (\n          <span\n            className={cn(\n              \"rounded-full px-2 py-1 text-[11px] font-semibold whitespace-nowrap\",\n              isActive\n                ? \"bg-white/15 text-[var(--button-color)]\"\n                : storyState === \"draft\"\n                  ? \"bg-stone-100 text-stone-700\"\n                  : storyState === \"feedback\"\n                    ? \"bg-sky-100 text-sky-700\"\n                    : storyState === \"finished\"\n                      ? \"bg-emerald-100 text-emerald-700\"\n                      : \"bg-lime-100 text-lime-800\",\n            )}\n          >\n            {getEditorStoryStateLabel(storyState)}\n          </span>\n        ) : null}\n        {item.meta ? (\n          <span\n            className={cn(\n              \"rounded-full px-2 py-1 text-[11px] uppercase tracking-[0.08em]\",\n              isActive\n                ? isOverviewItem\n                  ? \"bg-white/70 text-[var(--text-color-dim)]\"\n                  : \"bg-white/15 text-[var(--button-color)]\"\n                : \"bg-[var(--body-background)] text-[var(--text-color-dim)]\",\n            )}\n          >\n            {item.meta}\n          </span>\n        ) : null}\n        <ChevronRightIcon\n          className={cn(\n            \"size-4\",\n            isActive\n              ? isOverviewItem\n                ? \"text-[var(--text-color-dim)]\"\n                : \"text-[var(--button-color)]\"\n              : \"text-[var(--text-color-dim)]\",\n          )}\n        />\n      </div>\n    </button>\n  );\n}\n\nfunction getPaletteItemIcon(icon: PaletteIcon | undefined) {\n  switch (icon) {\n    case \"add\":\n      return PlusIcon;\n    case \"admin\":\n      return ShieldIcon;\n    case \"courses\":\n      return FolderOpenIcon;\n    case \"docs\":\n      return BookTextIcon;\n    case \"editor\":\n      return BookOpenIcon;\n    case \"home\":\n      return HouseIcon;\n    case \"import\":\n      return DownloadIcon;\n    case \"languages\":\n      return LanguagesIcon;\n    case \"profile\":\n      return UserIcon;\n    case \"public\":\n      return HouseIcon;\n    case \"stories\":\n      return FileTextIcon;\n    case \"users\":\n      return UsersIcon;\n    case \"voices\":\n      return MicIcon;\n    default:\n      return null;\n  }\n}\n"
  },
  {
    "path": "src/app/editor/_components/header_context.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { createPortal } from \"react-dom\";\n\ntype EditorHeaderSlotName = \"breadcrumbs\" | \"actions\";\n\ntype EditorHeaderContextValue = {\n  slots: Record<EditorHeaderSlotName, HTMLDivElement | null>;\n  setSlot: (slot: EditorHeaderSlotName, element: HTMLDivElement | null) => void;\n};\n\nconst EditorHeaderContext =\n  React.createContext<EditorHeaderContextValue | null>(null);\n\nexport function EditorHeaderProvider({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const [slots, setSlots] = React.useState<\n    Record<EditorHeaderSlotName, HTMLDivElement | null>\n  >({\n    breadcrumbs: null,\n    actions: null,\n  });\n\n  const setSlot = React.useCallback(\n    (slot: EditorHeaderSlotName, element: HTMLDivElement | null) => {\n      setSlots((current) => {\n        if (current[slot] === element) return current;\n        return { ...current, [slot]: element };\n      });\n    },\n    [],\n  );\n\n  const value = React.useMemo(\n    () => ({\n      slots,\n      setSlot,\n    }),\n    [setSlot, slots],\n  );\n\n  return (\n    <EditorHeaderContext.Provider value={value}>\n      {children}\n    </EditorHeaderContext.Provider>\n  );\n}\n\nfunction useEditorHeaderContext() {\n  const context = React.useContext(EditorHeaderContext);\n  if (!context) {\n    throw new Error(\"Editor header context is missing.\");\n  }\n  return context;\n}\n\nexport function useEditorHeaderSlotRef(slot: EditorHeaderSlotName) {\n  const { setSlot } = useEditorHeaderContext();\n\n  return React.useCallback(\n    (element: HTMLDivElement | null) => {\n      setSlot(slot, element);\n    },\n    [setSlot, slot],\n  );\n}\n\nfunction EditorHeaderPortal({\n  children,\n  slot,\n}: {\n  children: React.ReactNode;\n  slot: EditorHeaderSlotName;\n}) {\n  const { slots } = useEditorHeaderContext();\n  const target = slots[slot];\n\n  if (!target) return null;\n\n  return createPortal(children, target);\n}\n\nexport function EditorHeaderBreadcrumbs({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return <EditorHeaderPortal slot=\"breadcrumbs\">{children}</EditorHeaderPortal>;\n}\n\nexport function EditorHeaderActions({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return <EditorHeaderPortal slot=\"actions\">{children}</EditorHeaderPortal>;\n}\n"
  },
  {
    "path": "src/app/editor/_components/header_shell.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport EditorCommandPalette from \"@/app/editor/_components/editor_command_palette\";\nimport { LoggedInButtonWrappedClient } from \"@/components/login/LoggedInButtonWrappedClient\";\nimport { useEditorHeaderSlotRef } from \"./header_context\";\n\nexport default function EditorHeaderShell() {\n  const breadcrumbsRef = useEditorHeaderSlotRef(\"breadcrumbs\");\n  const actionsRef = useEditorHeaderSlotRef(\"actions\");\n\n  return (\n    <nav className=\"flex h-[60px] min-w-0 items-center gap-4 border-b-2 border-[var(--header-border)] bg-[var(--body-background)] px-5\">\n      <div\n        ref={breadcrumbsRef}\n        className=\"flex min-w-0 flex-1 items-center overflow-hidden\"\n      />\n      <div className=\"ml-auto flex min-w-0 items-center gap-2\">\n        <EditorCommandPalette />\n        <div\n          ref={actionsRef}\n          className=\"flex min-w-0 items-center justify-end max-[975px]:overflow-x-auto\"\n        />\n        <LoggedInButtonWrappedClient page={\"editor\"} course_id={\"segment\"} />\n      </div>\n    </nav>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/_components/page_layout.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport EditorHeaderShell from \"./header_shell\";\n\nexport default function EditorPageLayout({\n  children,\n  contentClassName = \"min-h-0 min-w-0 flex-1 overflow-auto\",\n}: {\n  children: React.ReactNode;\n  contentClassName?: string;\n}) {\n  return (\n    <div className=\"flex h-[100dvh] min-h-0 flex-col\">\n      <EditorHeaderShell />\n      <div className={contentClassName}>{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/_components/story_editor_preferences.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\n\nconst EDITOR_STORY_PREFERENCES_STORAGE_KEY = \"editor_story_preferences\";\n\ntype StoryEditorPreferencesValue = {\n  showHints: boolean;\n  setShowHints: React.Dispatch<React.SetStateAction<boolean>>;\n  showAudio: boolean;\n  setShowAudio: React.Dispatch<React.SetStateAction<boolean>>;\n};\n\nconst StoryEditorPreferencesContext =\n  React.createContext<StoryEditorPreferencesValue | null>(null);\n\nfunction readInitialPreferences() {\n  if (typeof window === \"undefined\") {\n    return {\n      showHints: false,\n      showAudio: false,\n    };\n  }\n\n  const raw = window.localStorage.getItem(EDITOR_STORY_PREFERENCES_STORAGE_KEY);\n  if (!raw) {\n    return {\n      showHints: false,\n      showAudio: false,\n    };\n  }\n\n  try {\n    const parsed = JSON.parse(raw) as {\n      showHints?: boolean;\n      showAudio?: boolean;\n    };\n\n    return {\n      showHints: parsed.showHints === true,\n      showAudio: parsed.showAudio === true,\n    };\n  } catch {\n    window.localStorage.removeItem(EDITOR_STORY_PREFERENCES_STORAGE_KEY);\n    return {\n      showHints: false,\n      showAudio: false,\n    };\n  }\n}\n\nexport function StoryEditorPreferencesProvider({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const initialPreferences = React.useRef(readInitialPreferences());\n  const [showHints, setShowHints] = React.useState(\n    initialPreferences.current.showHints,\n  );\n  const [showAudio, setShowAudio] = React.useState(\n    initialPreferences.current.showAudio,\n  );\n\n  React.useEffect(() => {\n    if (typeof window === \"undefined\") return;\n\n    window.localStorage.setItem(\n      EDITOR_STORY_PREFERENCES_STORAGE_KEY,\n      JSON.stringify({ showHints, showAudio }),\n    );\n    window.editorShowTranslations = showHints;\n    window.editorShowSsml = showAudio;\n  }, [showAudio, showHints]);\n\n  const value = React.useMemo(\n    () => ({\n      showHints,\n      setShowHints,\n      showAudio,\n      setShowAudio,\n    }),\n    [showAudio, showHints],\n  );\n\n  return (\n    <StoryEditorPreferencesContext.Provider value={value}>\n      {children}\n    </StoryEditorPreferencesContext.Provider>\n  );\n}\n\nexport function useStoryEditorPreferences() {\n  const context = React.useContext(StoryEditorPreferencesContext);\n  if (!context) {\n    throw new Error(\"Story editor preferences context is missing.\");\n  }\n  return context;\n}\n"
  },
  {
    "path": "src/app/editor/editor_button.tsx",
    "content": "import React from \"react\";\nimport Link from \"next/link\";\n\nexport default function EditorButton({\n  style,\n  onClick,\n  id,\n  title,\n  alt,\n  img,\n  text,\n  href,\n  checked,\n  disabled,\n}: {\n  style?: React.CSSProperties;\n  onClick?: () => void;\n  id?: string;\n  title?: string;\n  alt?: string;\n  img?: string;\n  text?: string;\n  href?: string;\n  checked?: boolean;\n  disabled?: boolean;\n}) {\n  const baseClassName =\n    \"flex cursor-pointer items-center text-[var(--text-color-dim)] no-underline transition-all hover:text-[var(--text-color)] hover:brightness-[0.7] hover:contrast-[2.5] max-[800px]:px-[10px] max-[800px]:py-[14px] max-[1120px]:w-auto max-[1120px]:flex-col px-[34px] py-[14px]\";\n  const toggleClassName =\n    \"flex cursor-pointer items-center text-[var(--text-color-dim)] no-underline transition-all hover:text-[var(--text-color)] hover:brightness-[0.7] hover:contrast-[2.5] max-[800px]:px-[10px] max-[1120px]:w-auto max-[1120px]:flex-col px-[34px] py-0 max-[800px]:py-0\";\n  const disabledClassName =\n    \"cursor-default opacity-60 hover:text-[var(--text-color-dim)] hover:brightness-100 hover:contrast-100\";\n  const iconWrapClassName =\n    \"flex items-center max-[1120px]:h-8 max-[1120px]:p-0\";\n  const iconClassName = \"w-9 max-[1120px]:mr-0\";\n  const textClassName =\n    \"pl-[10px] no-underline max-[1120px]:mt-[-5px] max-[1120px]:p-0\";\n\n  if (checked !== undefined) {\n    if (onClick === undefined) throw new Error();\n    return (\n      <div\n        className={toggleClassName + (disabled ? ` ${disabledClassName}` : \"\")}\n        onClick={(e) => {\n          if (disabled) return;\n          e.preventDefault();\n          onClick();\n        }}\n      >\n        <label className=\"relative my-0 inline-block h-[17px] w-[30px]\">\n          <input\n            type=\"checkbox\"\n            checked={checked}\n            readOnly\n            className=\"peer sr-only\"\n          />\n          <span className=\"absolute inset-0 cursor-pointer rounded-[17px] bg-[var(--overview-hr)] transition-all peer-checked:bg-[var(--button-background)] peer-focus:shadow-[0_0_1px_var(--button-border)]\" />\n          <span className=\"pointer-events-none absolute bottom-[2px] left-[2px] h-[13px] w-[13px] rounded-full bg-[var(--body-background)] transition-transform peer-checked:translate-x-[13px]\" />\n        </label>\n        <span className={textClassName}>{text}</span>\n      </div>\n    );\n  }\n  if (href) {\n    if (disabled) {\n      return (\n        <div\n          style={style}\n          id={id}\n          title={title}\n          className={baseClassName + \" \" + disabledClassName}\n          aria-disabled=\"true\"\n        >\n          <div className={iconWrapClassName}>\n            <img\n              className={iconClassName}\n              alt={alt}\n              src={`/editor/icons/${img}`}\n            />\n          </div>\n          <span className={textClassName}>{text}</span>\n        </div>\n      );\n    }\n    return (\n      <Link\n        href={href}\n        style={style}\n        id={id}\n        title={title}\n        className={baseClassName}\n        onClick={onClick}\n      >\n        <div className={iconWrapClassName}>\n          <img\n            className={iconClassName}\n            alt={alt}\n            src={`/editor/icons/${img}`}\n          />\n        </div>\n        <span className={textClassName}>{text}</span>\n      </Link>\n    );\n  }\n  return (\n    <div\n      style={style}\n      id={id}\n      title={title}\n      className={baseClassName + (disabled ? ` ${disabledClassName}` : \"\")}\n      onClick={disabled ? undefined : onClick}\n      aria-disabled={disabled ? \"true\" : undefined}\n    >\n      <div className={iconWrapClassName}>\n        <img className={iconClassName} alt={alt} src={`/editor/icons/${img}`} />\n      </div>\n      <span className={textClassName}>{text}</span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/language/[language]/language_editor.tsx",
    "content": "\"use client\";\n\"use no memo\";\nimport React, { useState } from \"react\";\nimport { useMutation, useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport { Spinner, SpinnerBlue } from \"@/components/ui/spinner\";\nimport { fetch_post } from \"@/lib/fetch_post\";\n\nimport PlayAudio from \"@/components/PlayAudio\";\nimport StoryLineHints from \"@/components/StoryLineHints\";\nimport useAudio from \"@/components/StoryTextLine/use-audio.hook\";\nimport { Breadcrumbs } from \"../../_components/breadcrumbs\";\nimport {\n  EditorHeaderActions,\n  EditorHeaderBreadcrumbs,\n} from \"../../_components/header_context\";\nimport EditorButton from \"../../editor_button\";\nimport {\n  LanguageType,\n  SpeakersType,\n  AvatarNamesType,\n  CourseStudType,\n} from \"@/app/editor/language/[language]/types\";\nimport type { StoryElementLine } from \"@/components/editor/story/syntax_parser_types\";\ntype PlayFn = (\n  e: React.MouseEvent,\n  text: string,\n  name: string,\n) => Promise<void>;\n\nexport default function LanguageEditor({\n  identifier,\n  renderHeader = true,\n}: {\n  identifier: string;\n  renderHeader?: boolean;\n}) {\n  const resolved = useQuery(api.editorRead.resolveEditorLanguage, {\n    identifier,\n  });\n\n  const speakers = useQuery(\n    api.editorRead.getEditorSpeakersByLanguageLegacyId,\n    resolved?.language ? { languageLegacyId: resolved.language.id } : \"skip\",\n  );\n\n  const avatarNames = useQuery(\n    api.editorRead.getEditorAvatarNamesByLanguageLegacyId,\n    resolved?.language ? { languageLegacyId: resolved.language.id } : \"skip\",\n  );\n\n  if (\n    resolved === undefined ||\n    speakers === undefined ||\n    avatarNames === undefined\n  ) {\n    return <Spinner />;\n  }\n\n  if (!resolved?.language) {\n    return <p>Language not found.</p>;\n  }\n\n  const language = resolved.language as LanguageType;\n  const language2 = (resolved.language2 ?? undefined) as\n    | LanguageType\n    | undefined;\n  const course = (resolved.course ?? undefined) as CourseStudType | undefined;\n\n  // Render data...\n  return (\n    <>\n      <Layout\n        language_data={language}\n        language2={language2}\n        course={course}\n        use_edit={false}\n        renderHeader={renderHeader}\n      >\n        <div className=\"flex flex-col leading-normal min-[560px]:flex-row max-[600px]:block\">\n          <AvatarNames\n            language={language}\n            speakers={(speakers ?? []) as SpeakersType[]}\n            avatar_names={(avatarNames ?? []) as AvatarNamesType[]}\n          />\n        </div>\n      </Layout>\n    </>\n  );\n}\n\nexport function Layout({\n  children,\n  language_data,\n  language2,\n  course,\n  use_edit,\n  renderHeader = true,\n}: {\n  children: React.ReactNode;\n  language_data: LanguageType;\n  language2: LanguageType | undefined;\n  course: CourseStudType | undefined;\n  use_edit: boolean;\n  renderHeader?: boolean;\n}) {\n  /*\n    <CourseDropdown userdata={userdata} />\n    <Login userdata={userdata} />\n    */\n  //const { userdata, error } = useSWR('https://test.duostories.org/stories/backend_node_test/session', fetch)\n\n  //if (error) return <div>failed to load</div>\n  //if (!userdata) return <div>loading...</div>\n  let crumbs;\n  if (use_edit) {\n    crumbs = [\n      { type: \"Editor\", href: `/editor` },\n      { type: \"sep\" },\n      {\n        type: \"course\",\n        lang1: language_data,\n        lang2: language2,\n        href: course?.short ? `/editor/course/${course?.short}` : `/editor`,\n      },\n      { type: \"sep\" },\n      {\n        type: \"Voices\",\n        href: course?.short\n          ? `/editor/language/${course?.short}`\n          : `/editor/language/${language_data?.short}`,\n      },\n      { type: \"sep\" },\n      { type: \"Edit\" },\n    ];\n  } else {\n    crumbs = [\n      { type: \"Editor\", href: `/editor` },\n      { type: \"sep\" },\n      {\n        type: \"course\",\n        lang1: language_data,\n        lang2: language2,\n        href: course?.short ? `/editor/course/${course?.short}` : `/editor`,\n      },\n      { type: \"sep\" },\n      { type: \"Voices\" },\n    ];\n  }\n  return (\n    <>\n      {renderHeader ? (\n        <>\n          <EditorHeaderBreadcrumbs>\n            <Breadcrumbs path={crumbs} />\n          </EditorHeaderBreadcrumbs>\n          <EditorHeaderActions>\n            {use_edit ? null : (\n              <EditorButton\n                id=\"button_edit\"\n                href={`/editor/course/${\n                  course?.short || language_data.short\n                }/voices/edit`}\n                data-cy=\"button_edit\"\n                img={\"import.svg\"}\n                text={\"Edit\"}\n              />\n            )}\n          </EditorHeaderActions>\n        </>\n      ) : null}\n      <div>{children}</div>\n    </>\n  );\n} //                 <Login page={\"editor\"}/>\n\ninterface AvatarData {\n  name: string | null;\n  speaker: string | null;\n  language_id: number | null;\n  avatar_id: number;\n  link: string;\n}\n\nfunction Avatar(props: {\n  avatar: AvatarData;\n  language_id: LanguageType;\n  play: PlayFn;\n}) {\n  const avatar = props.avatar;\n  const [savedName, setSavedName] = useState(avatar.name || \"\");\n  const [savedSpeaker, setSavedSpeaker] = useState(avatar.speaker || \"\");\n  const [inputName, inputNameSetValue] = useState(savedName);\n  const [inputSpeaker, inputSpeakerSetValue] = useState(savedSpeaker);\n\n  const unsavedChanged =\n    inputName !== savedName || inputSpeaker !== savedSpeaker;\n\n  React.useEffect(() => {\n    // Keep UI in sync with reactive Convex updates while preserving local edits.\n    if (unsavedChanged) return;\n    const nextSavedName = avatar.name || \"\";\n    const nextSavedSpeaker = avatar.speaker || \"\";\n    setSavedName(nextSavedName);\n    setSavedSpeaker(nextSavedSpeaker);\n    inputNameSetValue(nextSavedName);\n    inputSpeakerSetValue(nextSavedSpeaker);\n  }, [avatar.name, avatar.speaker, unsavedChanged]);\n\n  const language_id = props.language_id;\n  const saveAvatarSpeakerMutation = useMutation(\n    api.languageWrite.setAvatarSpeaker,\n  );\n  async function save() {\n    const name = inputName;\n    const speaker = inputSpeaker;\n    const data = {\n      name: name,\n      speaker: speaker,\n      language_id: language_id.id,\n      avatar_id: avatar.avatar_id,\n    };\n    await saveAvatarSpeakerMutation({\n      legacyLanguageId: data.language_id,\n      legacyAvatarId: data.avatar_id,\n      name: data.name,\n      speaker: data.speaker,\n      operationKey: `avatar_mapping:${data.language_id}:${data.avatar_id}:client`,\n    });\n    setSavedName(name);\n    setSavedSpeaker(speaker);\n  }\n  if (avatar.avatar_id === -1) {\n    return (\n      <div className=\"m-[10px] flex flex-col items-center rounded-[5px] border border-[var(--header-border)] p-[5px] max-[600px]:m-0\">\n        <p className=\"m-0\">\n          {avatar.avatar_id}\n          <span>{unsavedChanged ? \"*\" : \"\"}</span>\n        </p>\n        <p className=\"m-0 h-[50px]\">\n          <img alt=\"avatar\" src={avatar.link} style={{ height: \"50px\" }} />\n        </p>\n\n        <p className=\"m-0\">{inputName}</p>\n        <p className=\"m-0\">\n          <input\n            className=\"w-[102px] rounded-[5px] border border-[var(--input-border)] bg-[var(--input-background)] p-[5px] text-[var(--text-color)]\"\n            value={inputSpeaker}\n            onChange={(e) => inputSpeakerSetValue(e.target.value)}\n            type=\"text\"\n            placeholder=\"Speaker\"\n          />\n        </p>\n        <span\n          className=\"inline-flex cursor-pointer items-center justify-center pr-[5px]\"\n          title=\"play audio\"\n          onClick={(e) => props.play(e, inputSpeaker, \"Duo\")}\n        >\n          <img\n            className=\"w-5\"\n            alt=\"play\"\n            src=\"https://d35aaqx5ub95lt.cloudfront.net/images/d636e9502812dfbb94a84e9dfa4e642d.svg\"\n          />\n        </span>\n        <p className=\"m-0\">\n          <input\n            className=\"mt-[6px] cursor-pointer rounded-[8px] border border-[var(--input-border)] bg-[var(--input-background)] px-[10px] py-[4px] text-[var(--text-color)] disabled:cursor-default disabled:opacity-70\"\n            value=\"save\"\n            onClick={save}\n            disabled={!unsavedChanged}\n            type=\"button\"\n          />\n        </p>\n      </div>\n    );\n  }\n  return (\n    <div className=\"m-[10px] flex flex-col items-center rounded-[5px] border border-[var(--header-border)] p-[5px] max-[600px]:m-0\">\n      <p className=\"m-0\">\n        {avatar.avatar_id}\n        <span>{unsavedChanged ? \"*\" : \"\"}</span>\n      </p>\n      <p className=\"m-0\">\n        <img alt=\"avatar\" src={avatar.link} style={{ height: \"50px\" }} />\n      </p>\n\n      <p className=\"m-0\">\n        <input\n          className=\"w-[102px] rounded-[5px] border border-[var(--input-border)] bg-[var(--input-background)] p-[5px] text-[var(--text-color)]\"\n          value={inputName}\n          disabled={avatar.avatar_id === 0}\n          onChange={(e) => inputNameSetValue(e.target.value)}\n          type=\"text\"\n          placeholder=\"Name\"\n        />\n      </p>\n      <p className=\"m-0\">\n        <input\n          className=\"w-[102px] rounded-[5px] border border-[var(--input-border)] bg-[var(--input-background)] p-[5px] text-[var(--text-color)]\"\n          value={inputSpeaker}\n          onChange={(e) => inputSpeakerSetValue(e.target.value)}\n          type=\"text\"\n          placeholder=\"Speaker\"\n        />\n      </p>\n\n      <PlayButton\n        play={props.play}\n        speaker={inputSpeaker}\n        name={avatar.avatar_id === 0 ? \"Duo\" : inputName}\n      />\n      <p className=\"m-0\">\n        <input\n          value=\"save\"\n          className=\"mt-[6px] cursor-pointer rounded-[8px] border border-[var(--input-border)] bg-[var(--input-background)] px-[10px] py-[4px] text-[var(--text-color)] disabled:cursor-default disabled:opacity-70\"\n          onClick={save}\n          disabled={!unsavedChanged}\n          type=\"button\"\n        />\n      </p>\n    </div>\n  );\n}\n\ninterface PlayButtonProps {\n  play: PlayFn;\n  speaker: string | null;\n  name: string;\n}\n\nexport function PlayButton(props: PlayButtonProps) {\n  let play = props.play;\n  let speaker = props.speaker;\n  let name = props.name;\n\n  let [loading, setLoading] = useState(0);\n\n  async function do_play(e: React.MouseEvent, text: string, name: string) {\n    e.preventDefault();\n    setLoading(1);\n    try {\n      await play(e, text, name);\n    } catch (e) {\n      console.error(e);\n      return setLoading(-1);\n    }\n    setLoading(0);\n  }\n\n  return (\n    <span\n      className=\"inline-flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center\"\n      title=\"play audio\"\n      onClick={(e) => do_play(e, speaker || \"\", name)}\n    >\n      {loading === 0 ? (\n        <img\n          className=\"h-5 w-5\"\n          alt=\"play\"\n          src=\"https://d35aaqx5ub95lt.cloudfront.net/images/d636e9502812dfbb94a84e9dfa4e642d.svg\"\n        />\n      ) : loading === 1 ? (\n        <SpinnerBlue />\n      ) : loading === -1 ? (\n        <img\n          title=\"an error occurred\"\n          alt=\"error\"\n          src=\"/editor/icons/error.svg\"\n        />\n      ) : (\n        <></>\n      )}\n    </span>\n  );\n}\n\nexport function SpeakerEntry(props: {\n  speaker: SpeakersType;\n  copyText: (e: React.MouseEvent, text: string) => void;\n  play: PlayFn;\n}) {\n  const speaker = props.speaker;\n  const copyText = props.copyText;\n\n  return (\n    <tr>\n      <td className=\"flex items-center gap-1.5 whitespace-nowrap\">\n        <PlayButton play={props.play} speaker={speaker.speaker} name=\"Duo\" />\n        <span className=\"mr-[3px] rounded bg-[var(--editor-ssml)] px-[5px] py-[2px] text-[0.8em]\">\n          {speaker.speaker}\n        </span>\n        <span\n          className=\"inline-flex cursor-pointer items-center justify-center\"\n          title=\"copy to clipboard\"\n          onClick={(e) => copyText(e, speaker.speaker)}\n        >\n          <img className=\"w-5\" alt=\"copy\" src=\"/editor/icons/copy.svg\" />\n        </span>\n      </td>\n      <td>{speaker.gender}</td>\n      <td>{speaker.type}</td>\n    </tr>\n  );\n}\n\nconst element_init: StoryElementLine = {\n  type: \"LINE\",\n  lang: \"\",\n  trackingProperties: {\n    line_index: 0,\n  },\n  editor: {},\n  line: {\n    type: \"CHARACTER\",\n    characterId: 0,\n    content: {\n      hintMap: [],\n      text: \"\",\n      audio: {\n        ssml: {\n          text: \"<speak>Jan is thuis met  zijn vrouw, Marian.</speak>\",\n          speaker: \"nl-NL-FennaNeural(pitch=x-low)\",\n          id: 43,\n          inser_index: 1,\n          plan_text: \"Jan is thuis met  zijn vrouw, Marian.\",\n          plan_text_speaker_name: \"nl-NL-FennaNeural(pitch=x-low)\",\n        },\n        url: \"audio/xx.mp3\",\n        keypoints: [],\n      },\n    },\n  },\n};\nfunction AvatarNames({\n  language,\n  speakers,\n  avatar_names,\n}: {\n  language: LanguageType;\n  speakers: SpeakersType[];\n  avatar_names: AvatarNamesType[];\n}) {\n  let [speakText, setSpeakText] = useState(\"\");\n  const [speakTextDefault, setSpeakTextDefault] = useState(\n    language.default_text,\n  );\n  const [stored, setStored] = useState<Record<string, HTMLAudioElement>>({});\n\n  const [pitch, setPitch] = useState(2);\n  const [speed, setSpeed] = useState(2);\n  const saveDefaultTextMutation = useMutation(api.languageWrite.setDefaultText);\n\n  let [element, setElement] = useState(element_init);\n\n  function copyText(e: React.MouseEvent, text: string) {\n    let p = [\"x-low\", \"low\", \"medium\", \"high\", \"x-high\"][pitch];\n    let s = [\"x-slow\", \"slow\", \"medium\", \"fast\", \"x-fast\"][speed];\n    if (pitch !== 2 && speed !== 2) text = `${text}(pitch=${p}, rate=${s})`;\n    else if (pitch !== 2 && speed === 2) text = `${text}(pitch=${p})`;\n    else if (pitch === 2 && speed !== 2) text = `${text}(rate=${s})`;\n\n    e.preventDefault();\n    return navigator.clipboard.writeText(text);\n  }\n\n  async function saveText() {\n    try {\n      await saveDefaultTextMutation({\n        legacyLanguageId: language.id,\n        default_text: speakText,\n        operationKey: `language:${language.id}:default_text:client`,\n      });\n      setSpeakTextDefault(speakText);\n    } catch (e) {\n      window.alert(\"could not be saved\");\n    }\n  }\n\n  if (speakText === \"\")\n    speakText = language?.default_text || \"My name is $name.\";\n\n  function doSetSpeakText(event: React.ChangeEvent<HTMLTextAreaElement>) {\n    setStored({});\n    setSpeakText(event.target.value);\n  }\n\n  let images = [];\n  let avatars_new = [];\n  let avatars_new_important = [];\n  if (avatar_names !== undefined)\n    for (let avatar of avatar_names) {\n      if (images.indexOf(avatar.link) === -1) {\n        if (\n          [0, 414, 415, 416, 418, 507, 508, 509, 592, 593].indexOf(\n            avatar.avatar_id,\n          ) !== -1\n        )\n          avatars_new_important.push(avatar);\n        else avatars_new.push(avatar);\n        images.push(avatar.link);\n      }\n    }\n\n  async function play2(e: React.MouseEvent, text: string, name: string) {\n    let speakText2 = `<prosody pitch=\"${\n      [\"x-low\", \"low\", \"medium\", \"high\", \"x-high\"][pitch]\n    }\" rate=\"${\n      [\"x-slow\", \"slow\", \"medium\", \"fast\", \"x-fast\"][speed]\n    }\">${speakText}</prosody>`;\n    let id = text + pitch + speed + name;\n    return play(e, id, text, name, speakText2);\n  }\n\n  async function play3(e: React.MouseEvent, text: string, name: string) {\n    text = text.trim();\n    let match = text.match(/([^(]*)\\((.*)\\)/);\n    let speakText2 = speakText;\n    if (match) {\n      text = match[1];\n      let attributes = \"\";\n      for (let part of match[2].matchAll(/(\\w*)=([\\w-]*)/g)) {\n        attributes += ` ${part[1]}=\"${part[2]}\"`;\n      }\n      speakText2 = `<prosody ${attributes}>${speakText}</prosody>`;\n    }\n\n    let id = text + pitch + speed + name;\n    return play(e, id, text, name, speakText2);\n  }\n\n  async function play(\n    e: React.MouseEvent,\n    id: string,\n    text: string,\n    name: string,\n    speakText: string,\n  ) {\n    if (stored[id] === undefined) {\n      //let response2 = await fetch_post(`https://carex.uber.space/stories/audio/set_audio2.php`,\n      //    {\"id\": 0, \"speaker\": text, \"text\": speakText.replace(\"$name\", name)});\n      let response2 = await fetch_post(`/audio/create`, {\n        id: 0,\n        speaker: text,\n        text: speakText.replace(\"$name\", name),\n      });\n      let ssml_response = await response2.json();\n\n      let binaryString = window.atob(ssml_response.content);\n      let binaryData = new Uint8Array(binaryString.length);\n      for (let i = 0; i < binaryString.length; i++) {\n        binaryData[i] = binaryString.charCodeAt(i);\n      }\n      let blob = new Blob([binaryData], { type: \"audio/mp3\" });\n      let url = URL.createObjectURL(blob);\n      let audio = new Audio();\n      audio.src = url;\n      stored[id] = audio;\n\n      let tt = speakText.replace(\"$name\", name).replace(/<.*?>/g, \"\");\n      element = { ...element };\n      element.line.content = { ...element.line.content };\n      element.line.content.text = tt;\n      element.line.content.audio.keypoints = [];\n      let audioObject = ref.current;\n      if (audioObject) audioObject.src = url;\n      //element.line.content.audio.url = url\n      // {audioStart: 50, rangeEnd: 3}\n      let last_pos = 0;\n      for (let marks of ssml_response.marks || []) {\n        last_pos += tt.substring(last_pos).indexOf(marks.value);\n        element.line.content.audio.keypoints.push({\n          audioStart: marks.time,\n          rangeEnd: last_pos,\n        });\n      }\n      setElement(element);\n\n      //stored[id] = new Audio(\"https://carex.uber.space/stories/audio/\" + ssml_response[\"output_file\"] + \"?\"+Math.random());\n      setStored(stored);\n    }\n    let audio = stored[id];\n    audio.play();\n\n    e.preventDefault();\n  }\n\n  let [audioRange, playAudio, ref, url] = useAudio(element, true);\n\n  //if(avatars === undefined || speakers === undefined || language === undefined)\n  //    return <Spinner/>\n  return (\n    <>\n      <div className=\"h-[calc(100vh-64px)] w-full overflow-y-scroll max-[600px]:h-auto min-[560px]:w-[400px]\">\n        <audio ref={ref}>\n          <source src={url} type=\"audio/mp3\" />\n        </audio>\n        <PlayAudio onClick={playAudio} />\n        <StoryLineHints\n          audioRange={audioRange}\n          content={element.line.content}\n        />\n        <div>\n          <textarea\n            className=\"w-full rounded-[5px] border border-[var(--input-border)] bg-[var(--input-background)] text-[var(--text-color)]\"\n            value={speakText}\n            onChange={doSetSpeakText}\n          />\n          <input\n            className=\"mt-[6px] cursor-pointer rounded-[8px] border border-[var(--input-border)] bg-[var(--input-background)] px-[10px] py-[4px] text-[var(--text-color)] disabled:cursor-default disabled:opacity-70\"\n            value={\"save\" + (speakText !== speakTextDefault ? \"*\" : \"\")}\n            onClick={saveText}\n            disabled={speakText === speakTextDefault}\n            type=\"button\"\n          />\n        </div>\n        <div className=\"mt-2\">\n          Pitch:{\" \"}\n          <input\n            type=\"range\"\n            min=\"0\"\n            max=\"4\"\n            value={pitch}\n            id=\"pitch\"\n            onChange={(e) => setPitch(parseInt(e.target.value))}\n          />\n        </div>\n        <div className=\"mt-2\">\n          Speed:{\" \"}\n          <input\n            type=\"range\"\n            min=\"0\"\n            max=\"4\"\n            value={speed}\n            id=\"speed\"\n            onChange={(e) => setSpeed(parseInt(e.target.value))}\n          />\n        </div>\n        <div className=\"h-[calc(100%-110px)] overflow-y-scroll max-[600px]:h-[calc(50vh-140px)]\">\n          <table\n            className=\"mt-4 w-full border-collapse [&_td]:px-[6px] [&_td]:py-[6px] [&_td]:leading-[1.25] [&_th]:sticky [&_th]:top-0 [&_th]:bg-[var(--button-background)] [&_th]:px-2 [&_th]:py-[5px] [&_th]:text-left [&_th]:font-bold [&_th]:leading-[1.25] [&_th]:text-[var(--button-color)] [&_tr:nth-child(2n)]:bg-[var(--body-background-faint)]\"\n            data-cy=\"voice_list\"\n            data-js-sort-table=\"true\"\n          >\n            <thead>\n              <tr>\n                <th\n                  style={{ borderRadius: \"10px 0 0 0\" }}\n                  data-js-sort-colnum=\"0\"\n                >\n                  Name\n                </th>\n                <th data-js-sort-colnum=\"1\">Gender</th>\n                <th\n                  style={{ borderRadius: \"0 10px 0 0\" }}\n                  data-js-sort-colnum=\"2\"\n                >\n                  Type\n                </th>\n              </tr>\n            </thead>\n            <tbody>\n              {speakers.map((speaker, index) => (\n                <SpeakerEntry\n                  key={index}\n                  copyText={copyText}\n                  speaker={speaker}\n                  play={play2}\n                />\n              ))}\n            </tbody>\n          </table>\n        </div>\n      </div>\n      <div className=\"ml-2 h-[calc(100vh-64px)] w-full overflow-y-scroll max-[600px]:m-0 max-[600px]:h-[calc(50vh-30px)] min-[560px]:w-[calc(100vw-400px)]\">\n        <p className=\"my-4\">\n          These characters are the default cast of duolingo. Their names should\n          be kept as close to the original as possible.\n        </p>\n        <div\n          className=\"flex flex-wrap gap-[5px] p-[5px] min-[601px]:gap-0 min-[601px]:p-0\"\n          data-cy=\"avatar_list1\"\n        >\n          {avatars_new_important.map((avatar, index) => (\n            <Avatar\n              key={index}\n              play={play3}\n              language_id={language}\n              avatar={avatar}\n            />\n          ))}\n        </div>\n        <p className=\"my-4\">\n          These characters just appear in a couple of stories.\n        </p>\n        <div\n          className=\"flex flex-wrap gap-[5px] p-[5px] min-[601px]:gap-0 min-[601px]:p-0\"\n          data-cy=\"avatar_list2\"\n        >\n          {avatars_new.map((avatar, index) => (\n            <Avatar\n              key={index}\n              play={play3}\n              language_id={language}\n              avatar={avatar}\n            />\n          ))}\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/language/[language]/layout.tsx",
    "content": "import React from \"react\";\nimport EditorPageLayout from \"../../_components/page_layout\";\n\nexport default function Layout({ children }: { children: React.ReactNode }) {\n  return <EditorPageLayout>{children}</EditorPageLayout>;\n}\n"
  },
  {
    "path": "src/app/editor/language/[language]/page.tsx",
    "content": "import React from \"react\";\nimport { notFound, redirect } from \"next/navigation\";\nimport { Metadata } from \"next\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\n\nfunction getCanonicalVoicesPath(courseShort: string) {\n  return `/editor/course/${courseShort}/voices`;\n}\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ language: string }>;\n}): Promise<Metadata> {\n  const resolved = await fetchQuery(api.editorRead.resolveEditorLanguage, {\n    identifier: (await params).language,\n  });\n  const language = resolved?.language;\n  const course = resolved?.course;\n  const language2 = resolved?.language2;\n\n  if (!language) notFound();\n\n  if (!language2) {\n    return {\n      title: `Voices | ${language.name} | Duostories Editor`,\n      alternates: {\n        canonical: `https://duostories.org${getCanonicalVoicesPath(language.short)}`,\n      },\n    };\n  }\n\n  if (!course) notFound();\n\n  return {\n    title: `Voices | ${language.name} (from ${language2.name}) | Duostories Editor`,\n    alternates: {\n      canonical: `https://duostories.org${getCanonicalVoicesPath(course.short)}`,\n    },\n  };\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ language: string }>;\n}) {\n  const resolved = await fetchQuery(api.editorRead.resolveEditorLanguage, {\n    identifier: (await params).language,\n  });\n  const language = resolved?.language;\n  const course = resolved?.course;\n  const language2 = resolved?.language2;\n\n  if (!language) notFound();\n\n  redirect(getCanonicalVoicesPath(course?.short ?? language.short));\n}\n"
  },
  {
    "path": "src/app/editor/language/[language]/page_client.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport LanguageEditor from \"./language_editor\";\n\nexport default function LanguageEditorPageClient({\n  identifier,\n}: {\n  identifier: string;\n}) {\n  return <LanguageEditor identifier={identifier} />;\n}\n"
  },
  {
    "path": "src/app/editor/language/[language]/tts_edit/page.tsx",
    "content": "import React from \"react\";\nimport { notFound, redirect } from \"next/navigation\";\nimport { getUser } from \"@/lib/userInterface\";\nimport { Metadata } from \"next\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\n\nfunction getCanonicalVoicesEditPath(courseShort: string) {\n  return `/editor/course/${courseShort}/voices/edit`;\n}\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ language: string }>;\n}): Promise<Metadata> {\n  const resolved = await fetchQuery(api.editorRead.resolveEditorLanguage, {\n    identifier: (await params).language,\n  });\n  const language = resolved?.language;\n  const course = resolved?.course;\n  const language2 = resolved?.language2;\n\n  if (!language) notFound();\n\n  if (!language2) {\n    return {\n      title: `Voices Edit | ${language.name} | Duostories Editor`,\n      alternates: {\n        canonical: `https://duostories.org${getCanonicalVoicesEditPath(language.short)}`,\n      },\n    };\n  }\n\n  if (!course) notFound();\n\n  return {\n    title: `Voices Edit | ${language.name} (from ${language2.name}) | Duostories Editor`,\n    alternates: {\n      canonical: `https://duostories.org${getCanonicalVoicesEditPath(course.short)}`,\n    },\n  };\n}\n\nexport default async function Page({\n  params,\n}: {\n  params: Promise<{ language: string }>;\n}) {\n  const user = await getUser();\n  void user;\n  const resolved = await fetchQuery(api.editorRead.resolveEditorLanguage, {\n    identifier: (await params).language,\n  });\n  const language = resolved?.language;\n  const course = resolved?.course;\n\n  if (!language) notFound();\n\n  redirect(getCanonicalVoicesEditPath(course?.short ?? language.short));\n}\n"
  },
  {
    "path": "src/app/editor/language/[language]/tts_edit/page_client.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport Tts_edit from \"./tts_edit\";\nimport type { CourseStudType, LanguageType, SpeakersType } from \"../types\";\n\nexport default function LanguageTtsEditorPageClient({\n  identifier,\n}: {\n  identifier: string;\n}) {\n  const resolved = useQuery(api.editorRead.resolveEditorLanguage, {\n    identifier,\n  });\n\n  const speakers = useQuery(\n    api.editorRead.getEditorSpeakersByLanguageLegacyId,\n    resolved?.language ? { languageLegacyId: resolved.language.id } : \"skip\",\n  );\n\n  if (resolved === undefined || speakers === undefined) {\n    return <Spinner />;\n  }\n\n  if (!resolved?.language) {\n    return <p>Language not found.</p>;\n  }\n\n  return (\n    <Tts_edit\n      language={resolved.language as LanguageType}\n      language2={(resolved.language2 ?? undefined) as LanguageType | undefined}\n      speakers={(speakers ?? []) as SpeakersType[]}\n      course={(resolved.course ?? undefined) as CourseStudType | undefined}\n    />\n  );\n}\n"
  },
  {
    "path": "src/app/editor/language/[language]/tts_edit/tts_edit.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport { useMutation } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport { useInput } from \"@/lib/hooks\";\nimport { PlayButton, SpeakerEntry } from \"../language_editor\";\nimport { Layout } from \"../language_editor\";\nimport { processStoryFile } from \"@/components/editor/story/syntax_parser_new\";\nimport {\n  generate_audio_line,\n  content_to_audio,\n} from \"@/lib/editor/audio/audio_edit_tools\";\nimport StoryTextLine from \"@/components/StoryTextLine\";\nimport { parse as parseYaml } from \"yaml\";\nimport {\n  LanguageType,\n  SpeakersType,\n  CourseStudType,\n} from \"@/app/editor/language/[language]/types\";\nimport {\n  StoryElement,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\n\nconst element_init: StoryElementLine = {\n  trackingProperties: {\n    line_index: 0,\n  },\n  type: \"LINE\",\n  lang: \"tok2\",\n  editor: {},\n  line: {\n    avatarUrl: \"https://design.duolingo.com/ee58f22644428b8182ae.svg\",\n    characterId: 0,\n    type: \"CHARACTER\",\n    content: {\n      hintMap: [],\n      text: \"\",\n      audio: {\n        ssml: {\n          text: \"<speak>Jan is thuis met  zijn vrouw, Marian.</speak>\",\n          speaker: \"nl-NL-FennaNeural(pitch=x-low)\",\n          id: 43,\n          inser_index: 1,\n          plan_text: \"Jan is thuis met  zijn vrouw, Marian.\",\n          plan_text_speaker_name: \"nl-NL-FennaNeural(pitch=x-low)\",\n        },\n        url: \"audio/xx.mp3\",\n        keypoints: [],\n      },\n    },\n  },\n};\n\nexport default function Tts_edit({\n  language,\n  language2,\n  speakers,\n  course,\n  renderHeader = true,\n}: {\n  language: LanguageType;\n  language2: LanguageType | undefined;\n  speakers: SpeakersType[];\n  course: CourseStudType | undefined;\n  renderHeader?: boolean;\n}) {\n  // Render data...                <AvatarNames language={language} speakers={speakers} avatar_names={avatar_names}/>\n  const [data, setData] = React.useState(\n    language.tts_replace ||\n      `\n# line with # are comments and are ignored\n      \n# here you can add single letters that should be replaced    \n#LETTERS:\n#    o: u\n#    e: i\n# here you can add parts of words to be replaced. You can use valid regular expressions (regex) here\nFRAGMENTS:\n#    ion\\\\b: flug\n#    sem: dem\n# whole words that should be replaced\n#WORDS:\n#    oh: uuuh\n#    Worcester: WOO-STER\n`,\n  );\n  function setDataValidated(e: React.ChangeEvent<HTMLTextAreaElement>) {\n    const v = e.target.value;\n    try {\n      parseYaml(v);\n      setData(v);\n      setYamlError(false);\n    } catch (err) {\n      setYamlError(true);\n    }\n  }\n  const [text, setText] = useInput(\"Enter a text to be spoken\");\n  const [text2, setText2] = React.useState(\"\");\n  const [customSpeaker, setCustomSpeaker] = useInput(\"\");\n  const [pitch, setPitch] = React.useState(2);\n  const [speed, setSpeed] = React.useState(2);\n\n  const [element, setElement] = React.useState(element_init);\n  const saveTtsReplaceMutation = useMutation(api.languageWrite.setTtsReplace);\n\n  const [yamlError, setYamlError] = React.useState(false);\n  const hasVoices = (speakers?.length ?? 0) > 0;\n\n  async function save() {\n    const d = {\n      id: language.id,\n      tts_replace: data,\n    };\n    // test to process the yaml content\n    try {\n      parseYaml(data);\n    } catch (e) {\n      return;\n    }\n    return await saveTtsReplaceMutation({\n      legacyLanguageId: d.id,\n      tts_replace: d.tts_replace,\n      operationKey: `language:${d.id}:tts_replace:client`,\n    });\n  }\n\n  async function play2(e: React.MouseEvent, speaker: string, name: string) {\n    //speaker = `${speaker}(pitch=${[\"x-low\", \"low\", \"medium\", \"high\", \"x-high\"][pitch]},rate=${[\"x-slow\", \"slow\", \"medium\", \"fast\", \"x-fast\"][speed]})`;\n\n    let [story, story_meta, audio_insert_lines] = processStoryFile(\n      `[DATA]\n        icon_0=https://design.duolingo.com/ee58f22644428b8182ae.svg\n        speaker_0=${speaker}\n        \n        [LINE]\n        Speaker0: ${text}\n        `,\n      0,\n      {},\n      {\n        learning_language: \"en\",\n        from_language: \"tok2\",\n      },\n      data,\n    );\n    // nl-NL-FennaNeural(pitch=x-low)\n    /*\n    let speakText2 = `<prosody pitch=\"${\n        [\"x-low\", \"low\", \"medium\", \"high\", \"x-high\"][pitch]\n    }\" rate=\"${\n        [\"x-slow\", \"slow\", \"medium\", \"fast\", \"x-fast\"][speed]\n    }\">${text2}</prosody>`;\n    */\n    //let [new_element, mapping, text_clear] = await process();\n    //let id = speaker + pitch + speed + name;\n    return play(story.elements[0]);\n  }\n\n  async function play(new_element: StoryElement) {\n    if (new_element.type !== \"LINE\") return;\n    //let response2 = await fetch_post(`https://carex.uber.space/stories/audio/set_audio2.php`,\n    //    {\"id\": 0, \"speaker\": text, \"text\": speakText.replace(\"$name\", name)});\n\n    //new_element.line.content.audio.ssml = generate_ssml_line(new_element.line.content.audio.ssml, data, new_element.hideRangesForChallenge)\n    if (!new_element.line.content.audio?.ssml?.text) return;\n    setText2(new_element.line.content.audio.ssml.text);\n    if (!new_element.line.content.audio?.ssml) return;\n    let { keypoints, content } = await generate_audio_line(\n      new_element.line.content.audio.ssml,\n    );\n\n    const audio = content_to_audio(content);\n\n    //let tt = speakText.replace(\"$name\", name).replace(/<.*?>/g, \"\");\n    const element = JSON.parse(JSON.stringify(new_element)) as StoryElementLine;\n    /*element.line.content = { ...element.line.content };\n    element.line.content.text = text_clear;\n    element.line.content.lang = language.short;\n    element.line.lang = language.short;\n     */\n    if (element.audio) element.audio.keypoints = keypoints;\n    if (element.line.content.audio)\n      element.line.content.audio.keypoints = keypoints;\n\n    //let audioObject = ref.current;\n    //audioObject.src = audio.src;\n    if (element.line.content.audio) element.line.content.audio.url = audio.src;\n    //element.line.content.audio.url = url\n    // {audioStart: 50, rangeEnd: 3}\n    setElement(element);\n\n    //audio.play();\n\n    //e.preventDefault();\n  }\n  async function process() {\n    await save();\n  }\n\n  //let [audioRange, playAudio, ref, url] = useAudio(element, 1);\n\n  return (\n    <>\n      <Layout\n        language_data={language}\n        language2={language2}\n        course={course}\n        use_edit={true}\n        renderHeader={renderHeader}\n      >\n        <div className=\"flex leading-normal max-[600px]:block\">\n          <div\n            className={\n              \"h-[calc(100vh-64px)] w-full overflow-y-auto max-[600px]:h-auto sm:w-[400px] \" +\n              (hasVoices ? \"\" : \"hidden\")\n            }\n          >\n            <div className=\"mt-2\">\n              Pitch:{\" \"}\n              <input\n                type=\"range\"\n                min=\"0\"\n                max=\"4\"\n                value={pitch}\n                id=\"pitch\"\n                onChange={(e) => setPitch(parseInt(e.target.value))}\n              />\n            </div>\n            <div className=\"mt-2\">\n              Speed:{\" \"}\n              <input\n                type=\"range\"\n                min=\"0\"\n                max=\"4\"\n                value={speed}\n                id=\"speed\"\n                onChange={(e) => setSpeed(parseInt(e.target.value))}\n              />\n            </div>\n            <div className=\"h-[calc(100%-110px)] overflow-y-auto max-[600px]:h-[calc(50vh-140px)]\">\n              <table\n                className=\"mt-4 w-full border-collapse [&_td]:px-[6px] [&_td]:py-[6px] [&_td]:leading-[1.25] [&_tr:nth-child(2n)]:bg-[var(--body-background-faint)]\"\n                data-cy=\"voice_list\"\n                data-js-sort-table=\"true\"\n              >\n                <thead>\n                  <tr>\n                    <th\n                      className=\"sticky top-0 rounded-tl-[10px] bg-[var(--button-background)] px-2 py-[5px] text-left font-bold leading-[1.25] text-[var(--button-color)]\"\n                      data-js-sort-colnum=\"0\"\n                    >\n                      Name\n                    </th>\n                    <th\n                      className=\"sticky top-0 bg-[var(--button-background)] px-2 py-[5px] text-left font-bold leading-[1.25] text-[var(--button-color)]\"\n                      data-js-sort-colnum=\"1\"\n                    >\n                      Gender\n                    </th>\n                    <th\n                      className=\"sticky top-0 rounded-tr-[10px] bg-[var(--button-background)] px-2 py-[5px] text-left font-bold leading-[1.25] text-[var(--button-color)]\"\n                      data-js-sort-colnum=\"2\"\n                    >\n                      Type\n                    </th>\n                  </tr>\n                </thead>\n                <tbody>\n                  {speakers?.map((speaker, index) => (\n                    <SpeakerEntry\n                      key={index}\n                      speaker={speaker}\n                      play={play2}\n                      copyText={() => {}}\n                    />\n                  ))}\n                  <tr>\n                    <td className=\"px-[6px] py-[6px] leading-[1.25]\">\n                      <PlayButton\n                        play={play2}\n                        speaker={customSpeaker}\n                        name=\"Duo\"\n                      />\n                      <input\n                        className=\"ml-2 rounded-md border border-[var(--input-border)] bg-[var(--input-background)] px-2 py-1 text-[var(--text-color)]\"\n                        value={customSpeaker}\n                        onChange={setCustomSpeaker}\n                      />\n                    </td>\n                    <td className=\"px-[6px] py-[6px] leading-[1.25]\"></td>\n                    <td className=\"px-[6px] py-[6px] leading-[1.25]\"></td>\n                  </tr>\n                </tbody>\n              </table>\n            </div>\n          </div>\n          <div\n            className={\n              \"h-[calc(100vh-64px)] w-full overflow-y-auto \" +\n              (hasVoices ? \"ml-2 sm:w-[calc(100vw-400px)]\" : \"sm:w-full\")\n            }\n          >\n            <h2 className=\"mb-4 text-[1.5em] font-bold\">Input Text</h2>\n            <textarea\n              className=\"min-h-[110px] w-full rounded border border-[var(--input-border)] bg-[var(--input-background)] p-1 text-[var(--text-color)]\"\n              defaultValue={text}\n              onChange={(e) =>\n                setText({\n                  target: { value: e.target.value },\n                } as React.ChangeEvent<HTMLInputElement>)\n              }\n            />\n            <h2 className=\"mb-4 mt-8 text-[1.5em] font-bold\">\n              Transcribed Text\n            </h2>\n            <span className=\"block\">{text2}</span>\n            <h2 className=\"mb-4 mt-8 text-[1.5em] font-bold\">Final Text</h2>\n            <span className={language.short}>\n              <StoryTextLine\n                active={true}\n                unhide={999999}\n                element={element}\n                settings={{\n                  hide_questions: false,\n                  show_all: true,\n                  show_names: false,\n                  rtl: false,\n                  highlight_name: [],\n                  hideNonHighlighted: false,\n                  setHighlightName: () => {},\n                  setHideNonHighlighted: () => {},\n                  show_hints: true,\n                  setShowHints: () => {},\n                  show_audio: true,\n                  setShowAudio: () => {},\n                  id: 0,\n                  show_title_page: false,\n                }}\n              />\n            </span>\n\n            <div className=\"h-6\" />\n            <textarea\n              className=\"w-full rounded border border-[var(--input-border)] p-1\"\n              defaultValue={data}\n              onChange={setDataValidated}\n              rows={20}\n              cols={40}\n              style={{\n                background: yamlError ? \"#ffd4d4\" : \"none\",\n              }}\n            />\n            <div className=\"mt-4\">\n              <button\n                className=\"rounded-lg border border-[var(--input-border)] bg-[var(--input-background)] px-[10px] py-1 text-[var(--text-color)] disabled:cursor-default disabled:opacity-70\"\n                onClick={process}\n                disabled={yamlError}\n              >\n                save\n              </button>\n            </div>\n            {yamlError ? (\n              <span className=\"mt-2 inline-block text-red-700\">\n                The text box does not contain valid yaml syntax.\n              </span>\n            ) : (\n              <></>\n            )}\n          </div>\n        </div>\n      </Layout>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/language/[language]/types.ts",
    "content": "export type AvatarNamesType = {\n  id: number | null;\n  avatar_id: number;\n  language_id: number;\n  name: string | null;\n  link: string;\n  speaker: string | null;\n};\n\nexport type SpeakersType = {\n  id: number;\n  language_id: number;\n  speaker: string;\n  gender: string;\n  type: string;\n  service: string;\n};\n\nexport type LanguageType = {\n  languageId: string;\n  id: number;\n  name: string;\n  short: string;\n  speaker: string | null;\n  default_text: string;\n  tts_replace: string | null;\n  public: boolean;\n  rtl: boolean;\n};\n\nexport type CourseStudType = {\n  learning_language: number;\n  from_language: number;\n  short: string;\n};\n"
  },
  {
    "path": "src/app/editor/layout.tsx",
    "content": "import React from \"react\";\nimport { getUser, isContributor } from \"@/lib/userInterface\";\nimport { redirect } from \"next/navigation\";\nimport { EditorHeaderProvider } from \"./_components/header_context\";\nimport { StoryEditorPreferencesProvider } from \"./_components/story_editor_preferences\";\n\nexport default async function Layout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const user = await getUser();\n\n  if (!isContributor(user)) redirect(\"/auth/editor\");\n\n  return (\n    <EditorHeaderProvider>\n      <StoryEditorPreferencesProvider>\n        {children}\n      </StoryEditorPreferencesProvider>\n    </EditorHeaderProvider>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/localization/[language]/layout.tsx",
    "content": "import React from \"react\";\nimport EditorPageLayout from \"../../_components/page_layout\";\n\nexport default function Layout({ children }: { children: React.ReactNode }) {\n  return <EditorPageLayout>{children}</EditorPageLayout>;\n}\n"
  },
  {
    "path": "src/app/editor/localization/[language]/localization_editor.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { useMutation, useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { Breadcrumbs } from \"../../_components/breadcrumbs\";\nimport { EditorHeaderBreadcrumbs } from \"../../_components/header_context\";\nimport TextEdit from \"./text_edit\";\n\ntype LanguageType = {\n  languageId: string;\n  id: number;\n  name: string;\n  short: string;\n};\n\ntype CourseType = {\n  short?: string;\n};\n\ntype LocalizationRow = {\n  tag: string;\n  text_en: string;\n  text: string | null;\n};\n\nexport default function LocalizationEditor({\n  identifier,\n  renderHeader = true,\n}: {\n  identifier: string;\n  renderHeader?: boolean;\n}) {\n  const resolved = useQuery(api.editorRead.resolveEditorLanguage, {\n    identifier,\n  });\n\n  const localizationRows = useQuery(\n    api.editorRead.getEditorLocalizationRowsByLanguageLegacyId,\n    resolved?.language\n      ? {\n          languageLegacyId: resolved.language2?.id ?? resolved.language.id,\n        }\n      : \"skip\",\n  );\n\n  if (resolved === undefined || localizationRows === undefined) {\n    return <Spinner />;\n  }\n\n  if (!resolved?.language) {\n    return <p>Language not found.</p>;\n  }\n\n  const language = resolved.language as LanguageType;\n  const language2 = (resolved.language2 ?? undefined) as\n    | LanguageType\n    | undefined;\n  const course = (resolved.course ?? undefined) as CourseType | undefined;\n\n  return (\n    <Layout\n      language_data={language}\n      language2={language2}\n      course={course}\n      renderHeader={renderHeader}\n    >\n      <ListLocalizations\n        language_id={language2?.id || language.id}\n        language_name={language2?.name || language.name}\n        rows={localizationRows as LocalizationRow[]}\n      />\n    </Layout>\n  );\n}\n\nfunction Layout({\n  children,\n  language_data,\n  language2,\n  course,\n  renderHeader = true,\n}: {\n  children: React.ReactNode;\n  language_data: LanguageType;\n  language2: LanguageType | undefined;\n  course: CourseType | undefined;\n  renderHeader?: boolean;\n}) {\n  const crumbs = [\n    { type: \"Editor\", href: `/editor` },\n    { type: \"sep\" },\n    {\n      type: \"course\",\n      lang1: language_data,\n      lang2: language2,\n      href: course?.short ? `/editor/course/${course?.short}` : `/editor`,\n    },\n    { type: \"sep\" },\n    { type: \"Localization\" },\n  ];\n  return (\n    <>\n      {renderHeader ? (\n        <EditorHeaderBreadcrumbs>\n          <Breadcrumbs path={crumbs} />\n        </EditorHeaderBreadcrumbs>\n      ) : null}\n      <div>{children}</div>\n    </>\n  );\n}\n\nfunction ListLocalizations({\n  language_id,\n  language_name,\n  rows,\n}: {\n  language_id: number;\n  language_name: string;\n  rows: LocalizationRow[];\n}) {\n  const setLocalizationMutation = useMutation(\n    api.localizationWrite.setLocalization,\n  );\n\n  async function set_localization(tag: string, text: string) {\n    await setLocalizationMutation({\n      legacyLanguageId: language_id,\n      tag,\n      text,\n      operationKey: `localization:${language_id}:${tag}:client`,\n    });\n  }\n\n  return (\n    <div>\n      <h1 className=\"mb-4 text-[2rem] leading-[1.15] font-bold\">\n        Localizations {language_name}\n      </h1>\n      <p className=\"mb-5 leading-[1.5]\">\n        If your course does not have English as a base language, you can adjust\n        the texts of the Duostories interface to match the base language of your\n        course. If multiple courses share the same base language, these texts\n        only need to be translated once.\n      </p>\n      <table className=\"w-full [&_td]:p-[5px] [&_td]:align-top [&_th]:bg-[var(--button-background)] [&_th]:px-2 [&_th]:py-[5px] [&_th]:text-left [&_th]:text-[var(--button-color)] [&_th:nth-child(2)]:min-w-[45%] [&_th:nth-child(3)]:min-w-[43%] [&_tr:nth-child(2n)]:bg-[var(--body-background-faint)]\">\n        <thead>\n          <tr>\n            <th>Tag</th>\n            <th>Text</th>\n            <th>Text EN</th>\n          </tr>\n        </thead>\n        <tbody>\n          {rows.map((row) => (\n            <tr key={row.tag}>\n              <td>\n                <span className=\"whitespace-nowrap rounded-[10px] bg-[var(--editor-ssml)] px-[5px] py-[2px]\">\n                  {row.tag}\n                </span>\n              </td>\n              <td className=\"hover:bg-[#eef8fc]\">\n                <TextEdit\n                  tag={row.tag}\n                  text={row.text}\n                  set_localization={set_localization}\n                />\n              </td>\n              <td>{row.text_en}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/localization/[language]/page.tsx",
    "content": "import React from \"react\";\nimport { notFound, redirect } from \"next/navigation\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\n\ninterface LanguageType {\n  languageId: string;\n  id: number;\n  name: string;\n  short: string;\n}\n\ninterface CourseType {\n  learning_language: number;\n  from_language: number;\n  short: string;\n}\n\ninterface PageProps {\n  params: Promise<{ language: string }>;\n}\n\nfunction getCanonicalLocalizationPath(courseShort: string) {\n  return `/editor/course/${courseShort}/localization`;\n}\n\nasync function get_language(id: string) {\n  const resolved = await fetchQuery(api.editorRead.resolveEditorLanguage, {\n    identifier: id,\n  });\n  if (!resolved?.language) return [undefined, undefined, undefined] as const;\n  return [\n    resolved.language as LanguageType,\n    (resolved.course ?? undefined) as CourseType | undefined,\n    (resolved.language2 ?? undefined) as LanguageType | undefined,\n  ] as const;\n}\n\nexport async function generateMetadata({ params }: PageProps) {\n  let [language, course, language2] = await get_language(\n    (await params).language,\n  );\n\n  if (!language) notFound();\n\n  if (!language2) {\n    return {\n      title: `Localization | ${language.name} | Duostories Editor`,\n      alternates: {\n        canonical: `https://duostories.org${getCanonicalLocalizationPath(language.short)}`,\n      },\n    };\n  }\n\n  return {\n    title: `Localization | ${language.name} (from ${language2.name}) | Duostories Editor`,\n    alternates: {\n      canonical: `https://duostories.org${getCanonicalLocalizationPath(course?.short ?? language.short)}`,\n    },\n  };\n}\n\nexport default async function Page({ params }: PageProps) {\n  let [language, course] = await get_language((await params).language);\n\n  if (!language) notFound();\n\n  redirect(getCanonicalLocalizationPath(course?.short ?? language.short));\n}\n"
  },
  {
    "path": "src/app/editor/localization/[language]/page_client.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport LocalizationEditor from \"./localization_editor\";\n\nexport default function LocalizationPageClient({\n  identifier,\n}: {\n  identifier: string;\n}) {\n  return <LocalizationEditor identifier={identifier} />;\n}\n"
  },
  {
    "path": "src/app/editor/localization/[language]/text_edit.tsx",
    "content": "\"use client\";\nimport React, { useState } from \"react\";\n\ninterface TextEditProps {\n  tag: string;\n  text: string | null;\n  set_localization: (tag: string, text: string) => Promise<unknown>;\n}\n\nexport default function TextEdit({\n  tag,\n  text,\n  set_localization,\n}: TextEditProps) {\n  let [current_text, setText] = useState(text || \"\");\n  return (\n    <>\n      <textarea\n        value={current_text}\n        onChange={(e) => setText(e.target.value)}\n        className=\"w-full rounded-[5px] border border-[var(--input-border)] bg-[var(--input-background)] p-[5px] text-[17px] text-[var(--text-color)]\"\n      ></textarea>\n      <button\n        onClick={() => set_localization(tag, current_text)}\n        className=\"mt-2 rounded-lg border border-[var(--input-border)] bg-[var(--input-background)] px-3 py-1\"\n      >\n        Save\n      </button>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/story/[story]/audio-cutter-dialog.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { zipSync } from \"fflate\";\nimport { useWavesurfer } from \"@wavesurfer/react\";\nimport {\n  DownloadIcon,\n  HelpCircleIcon,\n  PauseIcon,\n  PlayIcon,\n  ScissorsIcon,\n  SquareIcon,\n  Trash2Icon,\n  UploadIcon,\n  WandSparklesIcon,\n} from \"lucide-react\";\nimport Regions from \"wavesurfer.js/dist/plugins/regions.js\";\nimport Input from \"@/components/ui/input\";\nimport {\n  decodeAudioData,\n  normalizeAudioBufferPeak,\n} from \"@/lib/audio/client-audio-processing\";\nimport { getLamejsModule } from \"@/lib/lamejs-compat\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport type { AudioMark } from \"@/app/audio/_lib/audio/types\";\nimport type {\n  AudioCutterPreparedSegment,\n  AudioCutterTranscriptItem,\n} from \"@/app/editor/story/[story]/audio-cutter-storage\";\n\nconst DEFAULT_WAVEFORM_ZOOM = 180;\nconst MIN_WAVEFORM_ZOOM = 24;\nconst MAX_WAVEFORM_ZOOM = 420;\nconst WAVEFORM_ZOOM_STEP = 24;\nconst DEFAULT_SEGMENT_LENGTH_SECONDS = 1.8;\nconst MIN_SEGMENT_LENGTH_SECONDS = 0.25;\nconst MIN_PERSISTED_NEW_SEGMENT_SECONDS = 0.1;\nconst WAVEFORM_TO_TRANSCRIPT_SYNC_LOCK_MS = 700;\nconst SILENCE_WINDOW_SECONDS = 0.02;\nconst DEFAULT_DETECTION_MIN_SILENCE_SECONDS = 1;\nconst DEFAULT_DETECTION_START_BUFFER_SECONDS = 0.04;\nconst DEFAULT_DETECTION_END_BUFFER_SECONDS = 0.04;\nconst DEFAULT_MAX_INTERNAL_SILENCE_SECONDS = 0.3;\nconst DETECTION_SETTINGS_STORAGE_KEY = \"audio-cutter-detection-settings-v1\";\nconst SHRINK_WRAP_STABILITY_EPSILON_SECONDS = 0.01;\nconst MP3_BITRATE_KBPS = 128;\nconst MP3_SAMPLE_BLOCK_SIZE = 1152;\nconst MIN_WORD_MARK_GAP_MS = 20;\nconst SEGMENT_COLOR = \"rgba(28,176,246,0.2)\";\nconst SEGMENT_BORDER_COLOR = \"rgba(15,95,131,0.4)\";\nconst SEGMENT_ACTIVE_BORDER_COLOR = \"rgba(28,176,246,0.95)\";\nconst cachedAudioSegmentation = new WeakMap<\n  AudioBuffer,\n  Map<string, CachedAudioSegmentation>\n>();\n\ntype TimeRange = {\n  start: number;\n  end: number;\n};\n\ntype Segment = {\n  id: string;\n  start: number;\n  end: number;\n  label?: string;\n  skipRanges: TimeRange[];\n};\n\ntype MergePreview = {\n  activeId: string;\n  targetId: string;\n};\n\ntype SegmentDraft = {\n  start: number;\n  end: number;\n  skipRanges?: TimeRange[];\n};\n\ntype AudioSilenceAnalysis = {\n  duration: number;\n  levels: number[];\n  startPaddingSeconds: number;\n  endPaddingSeconds: number;\n  threshold: number;\n  windowSeconds: number;\n  minSilenceWindows: number;\n  minSpeechWindows: number;\n  minSilenceSeconds: number;\n};\n\ntype CachedAudioSegmentation = {\n  analysis: AudioSilenceAnalysis;\n  detectedSegments: SegmentDraft[];\n};\n\ntype DetectionSettings = {\n  minSilenceSeconds: number;\n  startBufferSeconds: number;\n  endBufferSeconds: number;\n  maxInternalSilenceSeconds: number;\n};\n\ntype SegmentRegion = {\n  id: string;\n  start: number;\n  end: number;\n  element?: HTMLElement | null;\n  content?: HTMLElement | null;\n  setOptions: (options: {\n    start?: number;\n    end?: number;\n    color?: string;\n    content?: string | HTMLElement;\n    drag?: boolean;\n    resize?: boolean;\n    minLength?: number;\n  }) => void;\n  remove: () => void;\n};\n\ntype RegionsPlugin = {\n  addRegion: (options: {\n    id?: string;\n    start: number;\n    end: number;\n    color: string;\n    content?: string | HTMLElement;\n    drag?: boolean;\n    resize?: boolean;\n    minLength?: number;\n  }) => SegmentRegion;\n  clearRegions: () => void;\n  enableDragSelection: (\n    options: {\n      color: string;\n      drag?: boolean;\n      resize?: boolean;\n      minLength?: number;\n    },\n    threshold?: number,\n  ) => () => void;\n  getRegions: () => SegmentRegion[];\n  on: (event: string, callback: (region: SegmentRegion) => void) => void;\n  un: (event: string, callback: (region: SegmentRegion) => void) => void;\n};\n\ntype SegmentedPlaybackState = {\n  currentRangeIndex: number;\n  didReachRangeEnd: boolean;\n  keepRanges: TimeRange[];\n};\n\nconst EMPTY_REGIONS_PLUGIN: RegionsPlugin = {\n  addRegion: () => ({\n    id: \"\",\n    start: 0,\n    end: 0,\n    setOptions: () => {},\n    remove: () => {},\n  }),\n  clearRegions: () => {},\n  enableDragSelection: () => () => {},\n  getRegions: () => [],\n  on: () => {},\n  un: () => {},\n};\n\nfunction sortSegments(segments: Segment[]) {\n  return [...segments].sort((left, right) => left.start - right.start);\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction formatSeconds(value: number) {\n  const totalMs = Math.max(0, Math.round(value * 1000));\n  const minutes = Math.floor(totalMs / 60_000);\n  const seconds = Math.floor((totalMs % 60_000) / 1000);\n  const milliseconds = totalMs % 1000;\n  return `${String(minutes).padStart(2, \"0\")}:${String(seconds).padStart(2, \"0\")}.${String(milliseconds).padStart(3, \"0\")}`;\n}\n\nfunction getFileBaseName(filename: string) {\n  const dotIndex = filename.lastIndexOf(\".\");\n  return dotIndex > 0 ? filename.slice(0, dotIndex) : filename;\n}\n\nfunction getErrorMessage(error: unknown, fallback: string) {\n  return error instanceof Error && error.message ? error.message : fallback;\n}\n\nfunction waitForNextAnimationFrame() {\n  return new Promise<void>((resolve) => {\n    requestAnimationFrame(() => resolve());\n  });\n}\n\nfunction createSegmentId() {\n  return `segment-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction getWaveformScrollElement(\n  wavesurfer: ReturnType<typeof useWavesurfer>[\"wavesurfer\"],\n) {\n  if (!wavesurfer) return null;\n\n  const wrapper = (\n    wavesurfer as unknown as {\n      getWrapper?: () => HTMLElement;\n    }\n  ).getWrapper?.();\n\n  if (!wrapper) return null;\n  return wrapper.parentElement;\n}\n\nfunction isEditableTarget(target: EventTarget | null) {\n  if (!(target instanceof HTMLElement)) return false;\n  const tagName = target.tagName;\n  return (\n    target.isContentEditable ||\n    tagName === \"INPUT\" ||\n    tagName === \"TEXTAREA\" ||\n    tagName === \"SELECT\" ||\n    Boolean(target.closest(\"[contenteditable='true']\"))\n  );\n}\n\nfunction renderTextWithHighlightedWord(\n  text: string,\n  marks: AudioMark[],\n  activeWordIndex: number,\n  onPlayWord?: (markIndex: number) => void,\n) {\n  if (marks.length === 0) return text;\n\n  const parts: React.ReactNode[] = [];\n  let cursor = 0;\n\n  marks.forEach((mark, index) => {\n    if (mark.start > cursor) {\n      parts.push(text.slice(cursor, mark.start));\n    }\n\n    parts.push(\n      <button\n        key={`${mark.start}-${mark.end}-${index}`}\n        type=\"button\"\n        className={\n          index === activeWordIndex\n            ? \"rounded-[8px] bg-[#0f5f83] px-1 py-0.5 font-semibold text-white ring-2 ring-[#d7e34f] shadow-[0_1px_0_rgba(255,255,255,0.2)]\"\n            : \"rounded-[8px] px-1 py-0.5 transition-colors hover:bg-[rgba(28,176,246,0.12)]\"\n        }\n        onClick={(event) => {\n          event.stopPropagation();\n          onPlayWord?.(index);\n        }}\n      >\n        {text.slice(mark.start, mark.end)}\n      </button>,\n    );\n    cursor = mark.end;\n  });\n\n  if (cursor < text.length) {\n    parts.push(text.slice(cursor));\n  }\n\n  return parts;\n}\n\nfunction getSegmentsFromPlugin(plugin: RegionsPlugin) {\n  return sortSegments(\n    plugin.getRegions().map((region) => ({\n      id: region.id,\n      start: region.start,\n      end: region.end,\n      skipRanges: [],\n    })),\n  );\n}\n\nfunction getOverlappingRegion(\n  plugin: RegionsPlugin,\n  activeRegion: SegmentRegion,\n): SegmentRegion | null {\n  let bestMatch: SegmentRegion | null = null;\n  let bestOverlap = 0;\n\n  for (const candidate of plugin.getRegions()) {\n    if (candidate.id === activeRegion.id) continue;\n    const overlap =\n      Math.min(activeRegion.end, candidate.end) -\n      Math.max(activeRegion.start, candidate.start);\n    if (overlap <= 0) continue;\n    if (overlap > bestOverlap) {\n      bestOverlap = overlap;\n      bestMatch = candidate;\n    }\n  }\n\n  return bestMatch;\n}\n\nfunction overlapsSegment(\n  segment: { start: number; end: number },\n  range: { start: number; end: number },\n) {\n  return (\n    Math.min(segment.end, range.end) - Math.max(segment.start, range.start) > 0\n  );\n}\n\nfunction sortRanges(ranges: TimeRange[]) {\n  return [...ranges].sort((left, right) => left.start - right.start);\n}\n\nfunction normalizeRanges(\n  ranges: TimeRange[] | undefined,\n  bounds: { start: number; end: number },\n) {\n  const normalized: TimeRange[] = [];\n\n  for (const range of sortRanges(ranges ?? [])) {\n    const start = clamp(range.start, bounds.start, bounds.end);\n    const end = clamp(range.end, start, bounds.end);\n    if (end - start <= 0.001) continue;\n\n    const previous = normalized[normalized.length - 1];\n    if (!previous || start > previous.end) {\n      normalized.push({ start, end });\n      continue;\n    }\n\n    previous.end = Math.max(previous.end, end);\n  }\n\n  return normalized;\n}\n\nfunction getTotalRangeDuration(ranges: TimeRange[] | undefined) {\n  return (ranges ?? []).reduce(\n    (total, range) => total + Math.max(0, range.end - range.start),\n    0,\n  );\n}\n\nfunction getKeepRangeEnd(\n  bounds: TimeRange,\n  skipRanges: TimeRange[] | undefined,\n) {\n  const keepRanges = getKeepRanges(bounds, skipRanges);\n  return keepRanges[keepRanges.length - 1]?.end ?? bounds.end;\n}\n\nfunction getEffectiveSegmentDuration(segment: {\n  start: number;\n  end: number;\n  skipRanges?: TimeRange[];\n}) {\n  return Math.max(\n    0,\n    segment.end - segment.start - getTotalRangeDuration(segment.skipRanges),\n  );\n}\n\nfunction getKeepRanges(bounds: TimeRange, skipRanges: TimeRange[] | undefined) {\n  const normalizedSkipRanges = normalizeRanges(skipRanges, bounds);\n  if (normalizedSkipRanges.length === 0) return [bounds];\n\n  const keepRanges: TimeRange[] = [];\n  let cursor = bounds.start;\n\n  for (const skipRange of normalizedSkipRanges) {\n    if (skipRange.start > cursor) {\n      keepRanges.push({\n        start: cursor,\n        end: skipRange.start,\n      });\n    }\n    cursor = Math.max(cursor, skipRange.end);\n  }\n\n  if (cursor < bounds.end) {\n    keepRanges.push({\n      start: cursor,\n      end: bounds.end,\n    });\n  }\n\n  return keepRanges.filter((range) => range.end - range.start > 0.001);\n}\n\nfunction mapPlayableOffsetToAbsoluteTime(\n  keepRanges: TimeRange[],\n  offsetSeconds: number,\n) {\n  if (keepRanges.length === 0) return 0;\n\n  let remaining = Math.max(0, offsetSeconds);\n  for (const range of keepRanges) {\n    const rangeDuration = Math.max(0, range.end - range.start);\n    if (remaining <= rangeDuration) {\n      return range.start + remaining;\n    }\n    remaining -= rangeDuration;\n  }\n\n  return keepRanges[keepRanges.length - 1]?.end ?? 0;\n}\n\nfunction clampTimeToKeepRanges(timeSeconds: number, keepRanges: TimeRange[]) {\n  if (keepRanges.length === 0) return timeSeconds;\n\n  if (timeSeconds <= keepRanges[0]?.start) {\n    return keepRanges[0]?.start ?? timeSeconds;\n  }\n\n  for (let index = 0; index < keepRanges.length; index += 1) {\n    const range = keepRanges[index];\n    if (!range) continue;\n    if (timeSeconds >= range.start && timeSeconds <= range.end) {\n      return timeSeconds;\n    }\n\n    const nextRange = keepRanges[index + 1];\n    if (!nextRange || timeSeconds >= nextRange.start) continue;\n\n    const previousDistance = Math.abs(timeSeconds - range.end);\n    const nextDistance = Math.abs(nextRange.start - timeSeconds);\n    return previousDistance <= nextDistance ? range.end : nextRange.start;\n  }\n\n  return keepRanges[keepRanges.length - 1]?.end ?? timeSeconds;\n}\n\nfunction getTranscriptWordTokens(text: string) {\n  return [...text.matchAll(/[\\p{L}\\p{N}]+(?:['’-][\\p{L}\\p{N}]+)*/gu)].map(\n    (match) => ({\n      text: match[0],\n      start: match.index ?? 0,\n      end: (match.index ?? 0) + match[0].length,\n    }),\n  );\n}\n\nfunction getApproximateWordMarks(text: string, segment: Segment): AudioMark[] {\n  const tokens = getTranscriptWordTokens(text);\n  if (tokens.length === 0) return [];\n\n  const keepRanges = getKeepRanges(\n    {\n      start: segment.start,\n      end: segment.end,\n    },\n    segment.skipRanges,\n  );\n  const totalKeepDuration = getTotalRangeDuration(keepRanges);\n  if (keepRanges.length === 0 || totalKeepDuration <= 0) return [];\n\n  const weights = tokens.map((token) =>\n    Math.max(1, Array.from(token.text).length),\n  );\n  const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);\n  if (totalWeight <= 0) return [];\n\n  let cumulativeWeight = 0;\n  return tokens.map((token, index) => {\n    const startOffsetSeconds =\n      (totalKeepDuration * cumulativeWeight) / totalWeight;\n    cumulativeWeight += weights[index] ?? 0;\n    const startSeconds = mapPlayableOffsetToAbsoluteTime(\n      keepRanges,\n      startOffsetSeconds,\n    );\n\n    return {\n      time: Math.round(startSeconds * 1000),\n      type: \"word\",\n      start: token.start,\n      end: token.end,\n      value: token.text,\n    };\n  });\n}\n\nfunction getApproximateWordPlaybackRange(\n  segment: Segment,\n  marks: AudioMark[],\n  markIndex: number,\n) {\n  const mark = marks[markIndex];\n  if (!mark) return null;\n\n  const startSeconds = mark.time / 1000;\n  const nextMark = marks[markIndex + 1];\n  const keepRangeEnd = getKeepRangeEnd(\n    {\n      start: segment.start,\n      end: segment.end,\n    },\n    segment.skipRanges,\n  );\n  const endSeconds = Math.max(\n    startSeconds + 0.06,\n    nextMark ? nextMark.time / 1000 : keepRangeEnd,\n  );\n\n  return {\n    startSeconds,\n    endSeconds,\n  };\n}\n\nfunction applyWordMarkTimeOverrides(\n  approximateMarks: AudioMark[],\n  timeOverridesMs: number[] | undefined,\n  segment: Segment,\n) {\n  if (approximateMarks.length === 0) return approximateMarks;\n\n  const keepRanges = getKeepRanges(\n    {\n      start: segment.start,\n      end: segment.end,\n    },\n    segment.skipRanges,\n  );\n  if (keepRanges.length === 0) return approximateMarks;\n\n  const keepStartMs = Math.round(\n    (keepRanges[0]?.start ?? segment.start) * 1000,\n  );\n  const keepEndMs = Math.round(\n    getKeepRangeEnd(\n      {\n        start: segment.start,\n        end: segment.end,\n      },\n      segment.skipRanges,\n    ) * 1000,\n  );\n\n  return approximateMarks.map((mark, index) => {\n    const previousTimeMs =\n      index > 0\n        ? (timeOverridesMs?.[index - 1] ??\n          approximateMarks[index - 1]?.time ??\n          keepStartMs)\n        : keepStartMs;\n    const nextTimeMs =\n      timeOverridesMs?.[index + 1] ??\n      approximateMarks[index + 1]?.time ??\n      keepEndMs;\n    const minTimeMs =\n      index === 0 ? keepStartMs : previousTimeMs + MIN_WORD_MARK_GAP_MS;\n    const maxTimeMs =\n      index === approximateMarks.length - 1\n        ? keepEndMs\n        : nextTimeMs - MIN_WORD_MARK_GAP_MS;\n    const boundedTimeMs = clamp(\n      timeOverridesMs?.[index] ?? mark.time,\n      minTimeMs,\n      Math.max(minTimeMs, maxTimeMs),\n    );\n    const clampedTimeSeconds = clampTimeToKeepRanges(\n      boundedTimeMs / 1000,\n      keepRanges,\n    );\n\n    return {\n      ...mark,\n      time: Math.round(clampedTimeSeconds * 1000),\n    };\n  });\n}\n\nfunction getActiveWordMarkIndex(\n  segment: Segment,\n  marks: AudioMark[],\n  currentTimeSeconds: number,\n) {\n  if (marks.length === 0) return -1;\n  if (currentTimeSeconds < segment.start || currentTimeSeconds > segment.end) {\n    return -1;\n  }\n\n  const keepRangeEnd = getKeepRangeEnd(\n    {\n      start: segment.start,\n      end: segment.end,\n    },\n    segment.skipRanges,\n  );\n\n  for (let index = 0; index < marks.length; index += 1) {\n    const markStartSeconds = (marks[index]?.time ?? 0) / 1000;\n    const nextMarkTime = marks[index + 1]?.time;\n    const nextStartSeconds =\n      nextMarkTime != null ? nextMarkTime / 1000 : keepRangeEnd;\n\n    if (\n      currentTimeSeconds >= markStartSeconds &&\n      currentTimeSeconds < nextStartSeconds\n    ) {\n      return index;\n    }\n  }\n\n  return currentTimeSeconds >= (marks[marks.length - 1]?.time ?? 0) / 1000\n    ? marks.length - 1\n    : -1;\n}\n\nfunction getKeypointsFromWordMarks(marks: AudioMark[]) {\n  return marks\n    .map((mark) => ({\n      rangeEnd: mark.end,\n      audioStart: mark.time,\n    }))\n    .filter(\n      (point, index, points) =>\n        Number.isFinite(point.rangeEnd) &&\n        Number.isFinite(point.audioStart) &&\n        point.rangeEnd > 0 &&\n        (index === 0 ||\n          point.rangeEnd !== points[index - 1]?.rangeEnd ||\n          point.audioStart !== points[index - 1]?.audioStart),\n    );\n}\n\nfunction sanitizeDetectionSettings(\n  settings: Partial<DetectionSettings> | undefined,\n): DetectionSettings {\n  return {\n    minSilenceSeconds: clamp(\n      settings?.minSilenceSeconds ?? DEFAULT_DETECTION_MIN_SILENCE_SECONDS,\n      0.1,\n      5,\n    ),\n    startBufferSeconds: clamp(\n      settings?.startBufferSeconds ?? DEFAULT_DETECTION_START_BUFFER_SECONDS,\n      0,\n      2,\n    ),\n    endBufferSeconds: clamp(\n      settings?.endBufferSeconds ?? DEFAULT_DETECTION_END_BUFFER_SECONDS,\n      0,\n      2,\n    ),\n    maxInternalSilenceSeconds: clamp(\n      settings?.maxInternalSilenceSeconds ??\n        DEFAULT_MAX_INTERNAL_SILENCE_SECONDS,\n      0,\n      3,\n    ),\n  };\n}\n\nfunction getDetectionSettingsCacheKey(settings: DetectionSettings) {\n  return [\n    settings.minSilenceSeconds.toFixed(3),\n    settings.startBufferSeconds.toFixed(3),\n    settings.endBufferSeconds.toFixed(3),\n    settings.maxInternalSilenceSeconds.toFixed(3),\n  ].join(\":\");\n}\n\nfunction loadPersistedDetectionSettings() {\n  try {\n    const rawSettings = window.localStorage.getItem(\n      DETECTION_SETTINGS_STORAGE_KEY,\n    );\n    if (!rawSettings) return null;\n    return sanitizeDetectionSettings(\n      JSON.parse(rawSettings) as Partial<DetectionSettings>,\n    );\n  } catch {\n    return null;\n  }\n}\n\nfunction analyzeAudioSilence(\n  buffer: AudioBuffer,\n  settings: DetectionSettings,\n): AudioSilenceAnalysis {\n  const sampleRate = buffer.sampleRate;\n  const channelCount = buffer.numberOfChannels;\n  const duration = buffer.duration;\n  const windowSeconds = SILENCE_WINDOW_SECONDS;\n  const windowSize = Math.max(128, Math.round(sampleRate * windowSeconds));\n  const windowCount = Math.max(1, Math.ceil(buffer.length / windowSize));\n  const levels: number[] = [];\n\n  for (let windowIndex = 0; windowIndex < windowCount; windowIndex += 1) {\n    const startSample = windowIndex * windowSize;\n    const endSample = Math.min(buffer.length, startSample + windowSize);\n    let peak = 0;\n\n    for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {\n      const channelData = buffer.getChannelData(channelIndex);\n      for (\n        let sampleIndex = startSample;\n        sampleIndex < endSample;\n        sampleIndex += 1\n      ) {\n        peak = Math.max(peak, Math.abs(channelData[sampleIndex] ?? 0));\n      }\n    }\n\n    levels.push(peak);\n  }\n\n  const sortedLevels = [...levels].sort((left, right) => left - right);\n  const peakLevel = sortedLevels[sortedLevels.length - 1] ?? 0;\n  const floorLevel = sortedLevels[Math.floor(sortedLevels.length * 0.2)] ?? 0;\n\n  return {\n    duration,\n    levels,\n    startPaddingSeconds: settings.startBufferSeconds,\n    endPaddingSeconds: settings.endBufferSeconds,\n    threshold: clamp(\n      Math.max(floorLevel * 3, peakLevel * 0.045, 0.008),\n      0.008,\n      Math.max(0.015, peakLevel * 0.5),\n    ),\n    windowSeconds,\n    minSilenceWindows: Math.max(\n      2,\n      Math.round(settings.minSilenceSeconds / windowSeconds),\n    ),\n    minSpeechWindows: Math.max(2, Math.round(0.18 / windowSeconds)),\n    minSilenceSeconds: settings.minSilenceSeconds,\n  };\n}\n\nfunction detectSpeechSegmentsFromAnalysis({\n  duration,\n  levels,\n  minSilenceWindows,\n  minSpeechWindows,\n  startPaddingSeconds,\n  endPaddingSeconds,\n  threshold,\n  windowSeconds,\n}: AudioSilenceAnalysis) {\n  if (!Number.isFinite(duration) || duration <= 0) return [];\n\n  const segments: SegmentDraft[] = [];\n  let speechStartWindow: number | null = null;\n  let lastLoudWindow = -1;\n\n  for (let windowIndex = 0; windowIndex < levels.length; windowIndex += 1) {\n    const isLoud = (levels[windowIndex] ?? 0) >= threshold;\n\n    if (isLoud) {\n      if (speechStartWindow === null) {\n        speechStartWindow = windowIndex;\n      }\n      lastLoudWindow = windowIndex;\n      continue;\n    }\n\n    if (speechStartWindow === null) continue;\n    const silentWindows = windowIndex - lastLoudWindow;\n    if (silentWindows < minSilenceWindows) continue;\n\n    if (lastLoudWindow - speechStartWindow + 1 >= minSpeechWindows) {\n      segments.push({\n        start: Math.max(\n          0,\n          speechStartWindow * windowSeconds - startPaddingSeconds,\n        ),\n        end: Math.min(\n          duration,\n          lastLoudWindow * windowSeconds + windowSeconds + endPaddingSeconds,\n        ),\n      });\n    }\n\n    speechStartWindow = null;\n    lastLoudWindow = -1;\n  }\n\n  if (\n    speechStartWindow !== null &&\n    lastLoudWindow - speechStartWindow + 1 >= minSpeechWindows\n  ) {\n    segments.push({\n      start: Math.max(\n        0,\n        speechStartWindow * windowSeconds - startPaddingSeconds,\n      ),\n      end: Math.min(\n        duration,\n        lastLoudWindow * windowSeconds + windowSeconds + endPaddingSeconds,\n      ),\n    });\n  }\n\n  return segments.reduce<SegmentDraft[]>((acc, segment) => {\n    const previous = acc[acc.length - 1];\n    if (!previous) {\n      acc.push(segment);\n      return acc;\n    }\n\n    if (segment.start - previous.end < minSilenceWindows * windowSeconds) {\n      previous.end = Math.max(previous.end, segment.end);\n      return acc;\n    }\n\n    acc.push(segment);\n    return acc;\n  }, []);\n}\n\nfunction getSegmentSkipRangesFromAnalysis(\n  analysis: AudioSilenceAnalysis,\n  segment: TimeRange,\n  maxInternalSilenceSeconds: number,\n) {\n  if (maxInternalSilenceSeconds <= 0) return [];\n\n  const { levels, threshold, windowSeconds } = analysis;\n  if (levels.length === 0) return [];\n\n  const startWindow = clamp(\n    Math.floor(segment.start / windowSeconds),\n    0,\n    levels.length - 1,\n  );\n  const endWindowExclusive = clamp(\n    Math.ceil(segment.end / windowSeconds),\n    startWindow + 1,\n    levels.length,\n  );\n\n  let firstLoudWindow = -1;\n  let lastLoudWindow = -1;\n  for (let index = startWindow; index < endWindowExclusive; index += 1) {\n    if ((levels[index] ?? 0) < threshold) continue;\n    if (firstLoudWindow === -1) firstLoudWindow = index;\n    lastLoudWindow = index;\n  }\n\n  if (firstLoudWindow === -1 || lastLoudWindow === -1) return [];\n\n  const skipRanges: TimeRange[] = [];\n  let silentRunStart = -1;\n\n  for (let index = firstLoudWindow; index <= lastLoudWindow + 1; index += 1) {\n    const isLoud = index <= lastLoudWindow && (levels[index] ?? 0) >= threshold;\n\n    if (!isLoud) {\n      if (silentRunStart === -1) {\n        silentRunStart = index;\n      }\n      continue;\n    }\n\n    if (silentRunStart === -1) continue;\n\n    const silentWindowCount = index - silentRunStart;\n    const silentDuration = silentWindowCount * windowSeconds;\n    if (silentDuration > maxInternalSilenceSeconds) {\n      const rawSilenceStart = silentRunStart * windowSeconds;\n      const rawSilenceEnd = index * windowSeconds;\n      const skipStart = clamp(\n        rawSilenceStart + maxInternalSilenceSeconds / 2,\n        segment.start,\n        segment.end,\n      );\n      const skipEnd = clamp(\n        rawSilenceEnd - maxInternalSilenceSeconds / 2,\n        skipStart,\n        segment.end,\n      );\n\n      if (skipEnd - skipStart > 0.001) {\n        skipRanges.push({\n          start: skipStart,\n          end: skipEnd,\n        });\n      }\n    }\n\n    silentRunStart = -1;\n  }\n\n  return normalizeRanges(skipRanges, segment);\n}\n\nfunction getCachedAudioSegmentation(\n  buffer: AudioBuffer,\n  settings: DetectionSettings,\n) {\n  let cachedBySettings = cachedAudioSegmentation.get(buffer);\n  if (!cachedBySettings) {\n    cachedBySettings = new Map<string, CachedAudioSegmentation>();\n    cachedAudioSegmentation.set(buffer, cachedBySettings);\n  }\n\n  const cacheKey = getDetectionSettingsCacheKey(settings);\n  const cached = cachedBySettings.get(cacheKey);\n  if (cached) return cached;\n\n  const analysis = analyzeAudioSilence(buffer, settings);\n  const next = {\n    analysis,\n    detectedSegments: detectSpeechSegmentsFromAnalysis(analysis),\n  };\n  cachedBySettings.set(cacheKey, next);\n  return next;\n}\n\nfunction getShrinkWrappedSegment(\n  buffer: AudioBuffer,\n  segment: { start: number; end: number },\n  settings: DetectionSettings,\n) {\n  const duration = segment.end - segment.start;\n  if (duration <= MIN_SEGMENT_LENGTH_SECONDS) {\n    return segment;\n  }\n\n  const { analysis, detectedSegments } = getCachedAudioSegmentation(\n    buffer,\n    settings,\n  );\n  const matchingDetectedSegment = detectedSegments.find(\n    (candidate) =>\n      Math.abs(candidate.start - segment.start) <=\n        SHRINK_WRAP_STABILITY_EPSILON_SECONDS &&\n      Math.abs(candidate.end - segment.end) <=\n        SHRINK_WRAP_STABILITY_EPSILON_SECONDS,\n  );\n  if (matchingDetectedSegment) {\n    return segment;\n  }\n\n  const {\n    levels,\n    startPaddingSeconds,\n    endPaddingSeconds,\n    threshold,\n    windowSeconds,\n  } = analysis;\n  if (levels.length === 0) return segment;\n\n  const startWindow = clamp(\n    Math.floor(segment.start / windowSeconds),\n    0,\n    levels.length - 1,\n  );\n  const endWindow = clamp(\n    Math.ceil(segment.end / windowSeconds),\n    startWindow + 1,\n    levels.length,\n  );\n\n  let firstActiveWindow = -1;\n  let lastActiveWindow = -1;\n  for (let index = startWindow; index < endWindow; index += 1) {\n    if ((levels[index] ?? 0) < threshold) continue;\n    if (firstActiveWindow === -1) firstActiveWindow = index;\n    lastActiveWindow = index;\n  }\n\n  if (firstActiveWindow === -1 || lastActiveWindow === -1) {\n    return segment;\n  }\n\n  const rawNextStart = Math.max(\n    segment.start,\n    firstActiveWindow * windowSeconds - startPaddingSeconds,\n  );\n  const rawNextEnd = Math.min(\n    segment.end,\n    (lastActiveWindow + 1) * windowSeconds + endPaddingSeconds,\n  );\n  const nextStart =\n    Math.abs(rawNextStart - segment.start) <=\n    SHRINK_WRAP_STABILITY_EPSILON_SECONDS\n      ? segment.start\n      : rawNextStart;\n  const nextEnd =\n    Math.abs(rawNextEnd - segment.end) <= SHRINK_WRAP_STABILITY_EPSILON_SECONDS\n      ? segment.end\n      : rawNextEnd;\n\n  if (nextEnd - nextStart < MIN_SEGMENT_LENGTH_SECONDS) {\n    return segment;\n  }\n\n  return {\n    start: nextStart,\n    end: nextEnd,\n  };\n}\n\nfunction syncRegionSkipMarkers(regionElement: HTMLElement, segment: Segment) {\n  const existingLayer = regionElement.querySelector<HTMLElement>(\n    \".audio-cutter-region-skip-layer\",\n  );\n  existingLayer?.remove();\n\n  if (segment.skipRanges.length === 0) return;\n\n  const segmentDuration = Math.max(segment.end - segment.start, 0.001);\n  const skipLayer = document.createElement(\"div\");\n  skipLayer.className = \"audio-cutter-region-skip-layer\";\n  skipLayer.style.position = \"absolute\";\n  skipLayer.style.inset = \"0\";\n  skipLayer.style.pointerEvents = \"none\";\n  skipLayer.style.overflow = \"hidden\";\n  skipLayer.style.borderRadius = \"inherit\";\n  skipLayer.style.zIndex = \"0\";\n\n  for (const skipRange of segment.skipRanges) {\n    const marker = document.createElement(\"div\");\n    const leftPercent =\n      ((skipRange.start - segment.start) / segmentDuration) * 100;\n    const widthPercent =\n      ((skipRange.end - skipRange.start) / segmentDuration) * 100;\n\n    marker.style.position = \"absolute\";\n    marker.style.left = `${leftPercent}%`;\n    marker.style.top = \"0\";\n    marker.style.bottom = \"0\";\n    marker.style.width = `${widthPercent}%`;\n    marker.style.background =\n      \"repeating-linear-gradient(135deg, rgba(255,255,255,0.08) 0 6px, rgba(15,95,131,0.28) 6px 12px)\";\n    marker.style.borderLeft = \"1px dashed rgba(15,95,131,0.75)\";\n    marker.style.borderRight = \"1px dashed rgba(15,95,131,0.75)\";\n    skipLayer.append(marker);\n  }\n\n  const existingOverflow = regionElement.style.overflow;\n  const computedOverflow = getComputedStyle(regionElement).overflow;\n  if (!existingOverflow || computedOverflow === \"visible\") {\n    regionElement.style.overflow = \"hidden\";\n  }\n  regionElement.append(skipLayer);\n}\n\nfunction syncRegionWordMarkers(\n  regionElement: HTMLElement,\n  segment: Segment,\n  wordMarks: AudioMark[],\n  activeWordIndex: number,\n) {\n  const existingLayer = regionElement.querySelector<HTMLElement>(\n    \".audio-cutter-region-word-layer\",\n  );\n  existingLayer?.remove();\n\n  if (wordMarks.length === 0) return;\n\n  const segmentDuration = Math.max(segment.end - segment.start, 0.001);\n  const wordLayer = document.createElement(\"div\");\n  wordLayer.className = \"audio-cutter-region-word-layer\";\n  wordLayer.style.position = \"absolute\";\n  wordLayer.style.inset = \"0\";\n  wordLayer.style.pointerEvents = \"none\";\n  wordLayer.style.overflow = \"hidden\";\n  wordLayer.style.borderRadius = \"inherit\";\n  wordLayer.style.zIndex = \"0\";\n\n  wordMarks.forEach((mark, markIndex) => {\n    const marker = document.createElement(\"div\");\n    const leftPercent =\n      ((mark.time / 1000 - segment.start) / segmentDuration) * 100;\n\n    marker.style.position = \"absolute\";\n    marker.style.left = `${leftPercent}%`;\n    marker.style.top = markIndex === activeWordIndex ? \"8%\" : \"14%\";\n    marker.style.bottom = markIndex === activeWordIndex ? \"8%\" : \"14%\";\n    marker.style.width = markIndex === activeWordIndex ? \"2px\" : \"1px\";\n    marker.style.background =\n      markIndex === activeWordIndex\n        ? \"rgba(215,227,79,0.95)\"\n        : \"rgba(15,95,131,0.38)\";\n    marker.style.boxShadow =\n      markIndex === activeWordIndex\n        ? \"0 0 0 1px rgba(70,81,0,0.28)\"\n        : \"0 0 0 1px rgba(255,255,255,0.18)\";\n    wordLayer.append(marker);\n  });\n\n  const existingOverflow = regionElement.style.overflow;\n  const computedOverflow = getComputedStyle(regionElement).overflow;\n  if (!existingOverflow || computedOverflow === \"visible\") {\n    regionElement.style.overflow = \"hidden\";\n  }\n  regionElement.append(wordLayer);\n}\n\nfunction createIconSvg(path: string) {\n  const namespace = \"http://www.w3.org/2000/svg\";\n  const svg = document.createElementNS(namespace, \"svg\");\n  svg.setAttribute(\"viewBox\", \"0 0 24 24\");\n  svg.setAttribute(\"width\", \"13\");\n  svg.setAttribute(\"height\", \"13\");\n  svg.setAttribute(\"fill\", \"none\");\n  svg.setAttribute(\"stroke\", \"currentColor\");\n  svg.setAttribute(\"stroke-width\", \"2\");\n  svg.setAttribute(\"stroke-linecap\", \"round\");\n  svg.setAttribute(\"stroke-linejoin\", \"round\");\n\n  const pathElement = document.createElementNS(namespace, \"path\");\n  pathElement.setAttribute(\"d\", path);\n  svg.append(pathElement);\n\n  return svg;\n}\n\nfunction createIconButton({\n  title,\n  iconPath,\n  danger = false,\n  onClick,\n}: {\n  title: string;\n  iconPath: string;\n  danger?: boolean;\n  onClick: () => void;\n}) {\n  const button = document.createElement(\"button\");\n  button.type = \"button\";\n  button.title = title;\n  button.setAttribute(\"aria-label\", title);\n  button.className = danger\n    ? \"audio-cutter-region-icon-button audio-cutter-region-icon-button--danger\"\n    : \"audio-cutter-region-icon-button\";\n\n  const stop = (event: Event) => {\n    event.preventDefault();\n    event.stopPropagation();\n  };\n\n  button.addEventListener(\"pointerdown\", stop);\n  button.addEventListener(\"mousedown\", stop);\n  button.addEventListener(\"click\", (event) => {\n    stop(event);\n    onClick();\n  });\n  button.append(createIconSvg(iconPath));\n\n  return button;\n}\n\nfunction createRegionContent({\n  index,\n  label,\n  showControls,\n  showJoinHint,\n  onPlay,\n  onShrinkWrap,\n  onDelete,\n  onEditLabel,\n}: {\n  index: number;\n  label: string;\n  showControls: boolean;\n  showJoinHint: boolean;\n  onPlay: () => void;\n  onShrinkWrap: () => void;\n  onDelete: () => void;\n  onEditLabel: () => void;\n}) {\n  const wrapper = document.createElement(\"div\");\n  wrapper.className = \"audio-cutter-region-content\";\n\n  const topRow = document.createElement(\"div\");\n  topRow.className = \"audio-cutter-region-content__top-row\";\n  wrapper.append(topRow);\n\n  const badge = document.createElement(\"div\");\n  badge.textContent = `${index + 1}`;\n  badge.className = \"audio-cutter-region-content__badge\";\n  topRow.append(badge);\n\n  if (showControls) {\n    const controls = document.createElement(\"div\");\n    controls.className = \"audio-cutter-region-content__controls\";\n\n    controls.append(\n      createIconButton({\n        title: \"Play segment\",\n        iconPath: \"M5 3l14 9-14 9V3z\",\n        onClick: onPlay,\n      }),\n    );\n    controls.append(\n      createIconButton({\n        title: \"Shrink-wrap segment\",\n        iconPath:\n          \"M4 7h5 M4 12h8 M4 17h5 M20 7h-5 M20 12h-8 M20 17h-5 M10 7l2-2 2 2 M10 17l2 2 2-2\",\n        onClick: onShrinkWrap,\n      }),\n    );\n    controls.append(\n      createIconButton({\n        title: label ? \"Edit label\" : \"Add label\",\n        iconPath:\n          \"M12 20h9 M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z\",\n        onClick: onEditLabel,\n      }),\n    );\n    controls.append(\n      createIconButton({\n        title: \"Delete segment\",\n        iconPath: \"M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6 M10 11v6 M14 11v6\",\n        danger: true,\n        onClick: onDelete,\n      }),\n    );\n    topRow.append(controls);\n  }\n\n  if (showControls && label) {\n    const labelElement = document.createElement(\"div\");\n    labelElement.textContent = label;\n    labelElement.title = label;\n    labelElement.className = \"audio-cutter-region-content__label\";\n    topRow.append(labelElement);\n  }\n\n  if (showJoinHint) {\n    const joinHint = document.createElement(\"div\");\n    joinHint.textContent = \"join segments\";\n    joinHint.className = \"audio-cutter-region-content__join-hint\";\n    wrapper.append(joinHint);\n  }\n\n  return wrapper;\n}\n\nfunction detectSpeechSegments(\n  buffer: AudioBuffer,\n  settings: DetectionSettings,\n): SegmentDraft[] {\n  return getCachedAudioSegmentation(buffer, settings).detectedSegments;\n}\n\nfunction float32ToInt16Sample(sample: number) {\n  const clampedSample = clamp(sample, -1, 1);\n  return clampedSample < 0\n    ? Math.round(clampedSample * 0x8000)\n    : Math.round(clampedSample * 0x7fff);\n}\n\nfunction toPlainArrayBuffer(view: Uint8Array | Int8Array) {\n  const arrayBuffer = new ArrayBuffer(view.byteLength);\n  new Uint8Array(arrayBuffer).set(\n    new Uint8Array(view.buffer, view.byteOffset, view.byteLength),\n  );\n  return arrayBuffer;\n}\n\nfunction audioBufferToWavBlob(buffer: AudioBuffer) {\n  const channels = buffer.numberOfChannels;\n  const sampleRate = buffer.sampleRate;\n  const totalFrames = buffer.length;\n  const bytesPerSample = 2;\n  const blockAlign = channels * bytesPerSample;\n  const byteRate = sampleRate * blockAlign;\n  const dataSize = totalFrames * blockAlign;\n  const output = new ArrayBuffer(44 + dataSize);\n  const view = new DataView(output);\n\n  let offset = 0;\n  const writeString = (value: string) => {\n    for (let index = 0; index < value.length; index += 1) {\n      view.setUint8(offset, value.charCodeAt(index));\n      offset += 1;\n    }\n  };\n\n  writeString(\"RIFF\");\n  view.setUint32(offset, 36 + dataSize, true);\n  offset += 4;\n  writeString(\"WAVE\");\n  writeString(\"fmt \");\n  view.setUint32(offset, 16, true);\n  offset += 4;\n  view.setUint16(offset, 1, true);\n  offset += 2;\n  view.setUint16(offset, channels, true);\n  offset += 2;\n  view.setUint32(offset, sampleRate, true);\n  offset += 4;\n  view.setUint32(offset, byteRate, true);\n  offset += 4;\n  view.setUint16(offset, blockAlign, true);\n  offset += 2;\n  view.setUint16(offset, 16, true);\n  offset += 2;\n  writeString(\"data\");\n  view.setUint32(offset, dataSize, true);\n  offset += 4;\n\n  for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) {\n    for (let channelIndex = 0; channelIndex < channels; channelIndex += 1) {\n      const sample = Math.max(\n        -1,\n        Math.min(1, buffer.getChannelData(channelIndex)?.[frameIndex] ?? 0),\n      );\n      view.setInt16(\n        offset,\n        sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff),\n        true,\n      );\n      offset += 2;\n    }\n  }\n\n  return new Blob([output], { type: \"audio/wav\" });\n}\n\nasync function encodeSegmentAsMp3(\n  buffer: AudioBuffer,\n  startSeconds: number,\n  endSeconds: number,\n  skipRanges: TimeRange[] = [],\n) {\n  const { Mp3Encoder } = await getLamejsModule();\n  const sampleRate = buffer.sampleRate;\n  const channelCount = Math.min(2, Math.max(1, buffer.numberOfChannels));\n  const encoder = new Mp3Encoder(channelCount, sampleRate, MP3_BITRATE_KBPS);\n  const channelData = Array.from({ length: channelCount }, (_, index) =>\n    buffer.getChannelData(index),\n  );\n  const mp3Chunks: Uint8Array[] = [];\n  const keepRanges = getKeepRanges(\n    {\n      start: startSeconds,\n      end: endSeconds,\n    },\n    skipRanges,\n  );\n\n  for (const keepRange of keepRanges.length > 0\n    ? keepRanges\n    : [{ start: startSeconds, end: endSeconds }]) {\n    const startFrame = clamp(\n      Math.floor(keepRange.start * sampleRate),\n      0,\n      buffer.length,\n    );\n    const endFrame = clamp(\n      Math.ceil(keepRange.end * sampleRate),\n      startFrame + 1,\n      buffer.length,\n    );\n    const frameCount = Math.max(1, endFrame - startFrame);\n\n    for (\n      let frameOffset = 0;\n      frameOffset < frameCount;\n      frameOffset += MP3_SAMPLE_BLOCK_SIZE\n    ) {\n      const chunkFrameCount = Math.min(\n        MP3_SAMPLE_BLOCK_SIZE,\n        frameCount - frameOffset,\n      );\n      const leftChunk = new Int16Array(chunkFrameCount);\n      const rightChunk =\n        channelCount > 1 ? new Int16Array(chunkFrameCount) : null;\n\n      for (let chunkIndex = 0; chunkIndex < chunkFrameCount; chunkIndex += 1) {\n        const sourceFrameIndex = startFrame + frameOffset + chunkIndex;\n        leftChunk[chunkIndex] = float32ToInt16Sample(\n          channelData[0]?.[sourceFrameIndex] ?? 0,\n        );\n        if (rightChunk) {\n          rightChunk[chunkIndex] = float32ToInt16Sample(\n            channelData[1]?.[sourceFrameIndex] ?? 0,\n          );\n        }\n      }\n\n      const encodedChunk = rightChunk\n        ? encoder.encodeBuffer(leftChunk, rightChunk)\n        : encoder.encodeBuffer(leftChunk);\n      if (encodedChunk.length > 0) {\n        mp3Chunks.push(Uint8Array.from(encodedChunk));\n      }\n    }\n  }\n\n  const flushChunk = encoder.flush();\n  if (flushChunk.length > 0) {\n    mp3Chunks.push(Uint8Array.from(flushChunk));\n  }\n\n  return new Blob(\n    mp3Chunks.map((chunk) => toPlainArrayBuffer(chunk)),\n    {\n      type: \"audio/mpeg\",\n    },\n  );\n}\n\nexport default function AudioCutterDialog({\n  open,\n  onOpenChange,\n  renderInDialog = true,\n  expectedSegmentCount,\n  transcriptItems,\n  onUseSegments,\n  primaryActionLabel = \"Use segments in bulk editor\",\n  primaryActionPendingLabel,\n  footerStatusText,\n}: {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  renderInDialog?: boolean;\n  expectedSegmentCount: number;\n  transcriptItems: AudioCutterTranscriptItem[];\n  onUseSegments: (\n    segments: AudioCutterPreparedSegment[],\n  ) => Promise<boolean | void> | boolean | void;\n  primaryActionLabel?: string;\n  primaryActionPendingLabel?: string;\n  footerStatusText?: string | null;\n}) {\n  const fileInputRef = React.useRef<HTMLInputElement | null>(null);\n  const containerRef = React.useRef<HTMLDivElement | null>(null);\n  const scrollContainerRef = React.useRef<HTMLDivElement | null>(null);\n  const transcriptScrollRef = React.useRef<HTMLDivElement | null>(null);\n  const wordTimelineRefs = React.useRef<Record<string, HTMLDivElement | null>>(\n    {},\n  );\n  const transcriptRowRefs = React.useRef<Record<number, HTMLDivElement | null>>(\n    {},\n  );\n  const suppressTranscriptAutoScrollRef = React.useRef(false);\n  const transcriptAutoScrollLockTimeoutRef = React.useRef<number | null>(null);\n  const transcriptAutoScrollLockContainerRef = React.useRef<HTMLElement | null>(\n    null,\n  );\n  const transcriptAutoScrollLockScrollListenerRef = React.useRef<\n    (() => void) | null\n  >(null);\n  const isSyncingRegionsRef = React.useRef(false);\n  const activeDraftRegionIdRef = React.useRef<string | null>(null);\n  const pendingRegionIdsRef = React.useRef<Set<string>>(new Set());\n  const autoDetectRequestRef = React.useRef(0);\n  const normalizeOperationRef = React.useRef(0);\n  const lastHandledAutoDetectRequestRef = React.useRef(0);\n  const segmentedPlaybackRef = React.useRef<SegmentedPlaybackState | null>(\n    null,\n  );\n  const [audioFile, setAudioFile] = React.useState<File | null>(null);\n  const [audioUrl, setAudioUrl] = React.useState(\"\");\n  const [audioBuffer, setAudioBuffer] = React.useState<AudioBuffer | null>(\n    null,\n  );\n  const [audioError, setAudioError] = React.useState<string | null>(null);\n  const [exportError, setExportError] = React.useState<string | null>(null);\n  const [isLoadingAudio, setIsLoadingAudio] = React.useState(false);\n  const [isNormalizingAudio, setIsNormalizingAudio] = React.useState(false);\n  const [isExportingSegments, setIsExportingSegments] = React.useState(false);\n  const [detectDialogOpen, setDetectDialogOpen] = React.useState(false);\n  const [shortcutDialogOpen, setShortcutDialogOpen] = React.useState(false);\n  const [showIntroHelp, setShowIntroHelp] = React.useState(false);\n  const [isPlaying, setIsPlaying] = React.useState(false);\n  const [isDragOverAudioDropzone, setIsDragOverAudioDropzone] =\n    React.useState(false);\n  const [detectionSettings, setDetectionSettings] =\n    React.useState<DetectionSettings>(() =>\n      sanitizeDetectionSettings(undefined),\n    );\n  const [detectionForm, setDetectionForm] = React.useState<DetectionSettings>(\n    () => sanitizeDetectionSettings(undefined),\n  );\n  const [segments, setSegments] = React.useState<Segment[]>([]);\n  const [labelsById, setLabelsById] = React.useState<Record<string, string>>(\n    {},\n  );\n  const [mergePreview, setMergePreview] = React.useState<MergePreview | null>(\n    null,\n  );\n  const [zoomPxPerSec, setZoomPxPerSec] = React.useState(DEFAULT_WAVEFORM_ZOOM);\n  const [waveformReady, setWaveformReady] = React.useState(false);\n  const [duration, setDuration] = React.useState(0);\n  const [viewportRange, setViewportRange] = React.useState({\n    start: 0,\n    end: 0,\n  });\n  const [hoveredSegmentId, setHoveredSegmentId] = React.useState<string | null>(\n    null,\n  );\n  const [playbackTimeSeconds, setPlaybackTimeSeconds] = React.useState(0);\n  const [selectedSegmentId, setSelectedSegmentId] = React.useState<\n    string | null\n  >(null);\n  const [\n    wordMarkTimeOverridesBySegmentId,\n    setWordMarkTimeOverridesBySegmentId,\n  ] = React.useState<Record<string, number[]>>({});\n  const [draggingWordMarker, setDraggingWordMarker] = React.useState<{\n    markIndex: number;\n    segmentId: string;\n  } | null>(null);\n  const [regionsPlugin, setRegionsPlugin] = React.useState<ReturnType<\n    typeof Regions.create\n  > | null>(null);\n  const typedRegionsPlugin =\n    (regionsPlugin as unknown as RegionsPlugin | null) ?? EMPTY_REGIONS_PLUGIN;\n  const sortedSegments = React.useMemo(\n    () => sortSegments(segments),\n    [segments],\n  );\n  const approximateWordMarksBySegmentId = React.useMemo(() => {\n    const next: Record<string, AudioMark[]> = {};\n    sortedSegments.forEach((segment, index) => {\n      next[segment.id] = getApproximateWordMarks(\n        transcriptItems[index]?.content.text ?? \"\",\n        segment,\n      );\n    });\n    return next;\n  }, [sortedSegments, transcriptItems]);\n  const wordMarksBySegmentId = React.useMemo(() => {\n    const next: Record<string, AudioMark[]> = {};\n    sortedSegments.forEach((segment) => {\n      next[segment.id] = applyWordMarkTimeOverrides(\n        approximateWordMarksBySegmentId[segment.id] ?? [],\n        wordMarkTimeOverridesBySegmentId[segment.id],\n        segment,\n      );\n    });\n    return next;\n  }, [\n    approximateWordMarksBySegmentId,\n    sortedSegments,\n    wordMarkTimeOverridesBySegmentId,\n  ]);\n  const activeWordIndexBySegmentId = React.useMemo(() => {\n    const next: Record<string, number> = {};\n    sortedSegments.forEach((segment) => {\n      next[segment.id] = getActiveWordMarkIndex(\n        segment,\n        wordMarksBySegmentId[segment.id] ?? [],\n        playbackTimeSeconds,\n      );\n    });\n    return next;\n  }, [playbackTimeSeconds, sortedSegments, wordMarksBySegmentId]);\n\n  React.useEffect(() => {\n    if (regionsPlugin) return;\n    setRegionsPlugin(Regions.create());\n  }, [regionsPlugin]);\n\n  React.useEffect(() => {\n    const persistedSettings = loadPersistedDetectionSettings();\n    if (!persistedSettings) return;\n    setDetectionSettings(persistedSettings);\n    setDetectionForm(persistedSettings);\n  }, []);\n\n  React.useEffect(() => {\n    try {\n      window.localStorage.setItem(\n        DETECTION_SETTINGS_STORAGE_KEY,\n        JSON.stringify(detectionSettings),\n      );\n    } catch {}\n  }, [detectionSettings]);\n\n  const { wavesurfer } = useWavesurfer({\n    container: containerRef,\n    height: 168,\n    waveColor: \"#1cb0f6\",\n    progressColor: \"rgba(28,176,246,0.62)\",\n    cursorColor: \"#0f5f83\",\n    normalize: true,\n    barWidth: 3,\n    barGap: 2,\n    barRadius: 999,\n    minPxPerSec: DEFAULT_WAVEFORM_ZOOM,\n    fillParent: false,\n    autoScroll: false,\n    hideScrollbar: false,\n    url: audioUrl || undefined,\n    plugins: React.useMemo(\n      () => (regionsPlugin ? [regionsPlugin] : []),\n      [regionsPlugin],\n    ),\n  });\n\n  const applySegmentSkipRanges = React.useCallback(\n    (\n      segment: { start: number; end: number },\n      settingsOverride?: DetectionSettings,\n    ) => {\n      if (!audioBuffer) return [];\n      const effectiveSettings = settingsOverride ?? detectionSettings;\n\n      const { analysis } = getCachedAudioSegmentation(\n        audioBuffer,\n        effectiveSettings,\n      );\n      return getSegmentSkipRangesFromAnalysis(\n        analysis,\n        segment,\n        effectiveSettings.maxInternalSilenceSeconds,\n      );\n    },\n    [audioBuffer, detectionSettings],\n  );\n\n  const buildSegment = React.useCallback(\n    (\n      segment: { id?: string; start: number; end: number; label?: string },\n      settingsOverride?: DetectionSettings,\n    ) => ({\n      id: segment.id ?? createSegmentId(),\n      start: segment.start,\n      end: segment.end,\n      label: segment.label,\n      skipRanges: applySegmentSkipRanges(segment, settingsOverride),\n    }),\n    [applySegmentSkipRanges],\n  );\n\n  const releaseTranscriptAutoScrollLock = React.useCallback(() => {\n    const lockContainer = transcriptAutoScrollLockContainerRef.current;\n    const lockScrollListener =\n      transcriptAutoScrollLockScrollListenerRef.current;\n\n    if (lockContainer && lockScrollListener) {\n      lockContainer.removeEventListener(\"scroll\", lockScrollListener);\n    }\n\n    transcriptAutoScrollLockContainerRef.current = null;\n    transcriptAutoScrollLockScrollListenerRef.current = null;\n    suppressTranscriptAutoScrollRef.current = false;\n  }, []);\n\n  const scheduleTranscriptAutoScrollUnlock = React.useCallback(() => {\n    if (transcriptAutoScrollLockTimeoutRef.current !== null) {\n      window.clearTimeout(transcriptAutoScrollLockTimeoutRef.current);\n    }\n    transcriptAutoScrollLockTimeoutRef.current = window.setTimeout(() => {\n      transcriptAutoScrollLockTimeoutRef.current = null;\n      releaseTranscriptAutoScrollLock();\n    }, WAVEFORM_TO_TRANSCRIPT_SYNC_LOCK_MS);\n  }, [releaseTranscriptAutoScrollLock]);\n\n  const clearTranscriptAutoScrollLock = React.useCallback(() => {\n    if (transcriptAutoScrollLockTimeoutRef.current !== null) {\n      window.clearTimeout(transcriptAutoScrollLockTimeoutRef.current);\n      transcriptAutoScrollLockTimeoutRef.current = null;\n    }\n    releaseTranscriptAutoScrollLock();\n  }, [releaseTranscriptAutoScrollLock]);\n\n  const cancelSegmentedPlayback = React.useCallback(() => {\n    segmentedPlaybackRef.current = null;\n  }, []);\n\n  const lockTranscriptAutoScroll = React.useCallback(\n    (scrollContainer: HTMLElement | null) => {\n      clearTranscriptAutoScrollLock();\n      suppressTranscriptAutoScrollRef.current = true;\n\n      if (scrollContainer) {\n        const extendTranscriptAutoScrollLock = () => {\n          scheduleTranscriptAutoScrollUnlock();\n        };\n\n        transcriptAutoScrollLockContainerRef.current = scrollContainer;\n        transcriptAutoScrollLockScrollListenerRef.current =\n          extendTranscriptAutoScrollLock;\n        scrollContainer.addEventListener(\n          \"scroll\",\n          extendTranscriptAutoScrollLock,\n          {\n            passive: true,\n          },\n        );\n      }\n\n      scheduleTranscriptAutoScrollUnlock();\n    },\n    [clearTranscriptAutoScrollLock, scheduleTranscriptAutoScrollUnlock],\n  );\n\n  const scrollTranscriptRowIntoView = React.useCallback(\n    (rowIndex: number, behavior: ScrollBehavior = \"smooth\") => {\n      const transcriptScroll = transcriptScrollRef.current;\n      const transcriptRow = transcriptRowRefs.current[rowIndex];\n      if (!transcriptScroll || !transcriptRow) return;\n\n      const scrollRect = transcriptScroll.getBoundingClientRect();\n      const rowRect = transcriptRow.getBoundingClientRect();\n      const viewportTop = transcriptScroll.scrollTop;\n      const viewportBottom = viewportTop + transcriptScroll.clientHeight;\n      const rowTop = viewportTop + (rowRect.top - scrollRect.top);\n      const rowBottom = rowTop + rowRect.height;\n\n      const topAligned =\n        Math.abs(transcriptScroll.scrollTop - Math.max(0, rowTop)) <= 1;\n      if (topAligned && rowTop >= viewportTop && rowBottom <= viewportBottom) {\n        return;\n      }\n\n      transcriptScroll.scrollTo({\n        top: Math.max(0, rowTop),\n        behavior,\n      });\n    },\n    [],\n  );\n\n  const playSegmentAudio = React.useCallback(\n    (segment: Segment) => {\n      if (!wavesurfer) return;\n      cancelSegmentedPlayback();\n\n      const keepRanges = getKeepRanges(\n        {\n          start: segment.start,\n          end: segment.end,\n        },\n        segment.skipRanges,\n      );\n      const firstRange = keepRanges[0];\n      if (!firstRange) return;\n\n      segmentedPlaybackRef.current = {\n        currentRangeIndex: 0,\n        didReachRangeEnd: false,\n        keepRanges,\n      };\n      void wavesurfer.play(firstRange.start, firstRange.end);\n    },\n    [cancelSegmentedPlayback, wavesurfer],\n  );\n\n  const resetState = React.useCallback(() => {\n    cancelSegmentedPlayback();\n    clearTranscriptAutoScrollLock();\n    normalizeOperationRef.current += 1;\n    activeDraftRegionIdRef.current = null;\n    pendingRegionIdsRef.current.clear();\n    typedRegionsPlugin.clearRegions();\n    setAudioFile(null);\n    setAudioError(null);\n    setExportError(null);\n    setIsLoadingAudio(false);\n    setIsNormalizingAudio(false);\n    setIsExportingSegments(false);\n    setDetectDialogOpen(false);\n    setShortcutDialogOpen(false);\n    setShowIntroHelp(false);\n    setIsPlaying(false);\n    setAudioBuffer(null);\n    setSegments([]);\n    setLabelsById({});\n    setMergePreview(null);\n    setDuration(0);\n    setViewportRange({ start: 0, end: 0 });\n    setWaveformReady(false);\n    setHoveredSegmentId(null);\n    setPlaybackTimeSeconds(0);\n    setWordMarkTimeOverridesBySegmentId({});\n    setDraggingWordMarker(null);\n    setZoomPxPerSec(DEFAULT_WAVEFORM_ZOOM);\n    setSelectedSegmentId(null);\n    autoDetectRequestRef.current = 0;\n    lastHandledAutoDetectRequestRef.current = 0;\n    setAudioUrl((currentUrl) => {\n      if (currentUrl) URL.revokeObjectURL(currentUrl);\n      return \"\";\n    });\n  }, [\n    cancelSegmentedPlayback,\n    clearTranscriptAutoScrollLock,\n    typedRegionsPlugin,\n  ]);\n\n  React.useEffect(() => {\n    if (!open) {\n      resetState();\n    }\n  }, [open, resetState]);\n\n  React.useEffect(() => {\n    return () => {\n      cancelSegmentedPlayback();\n      clearTranscriptAutoScrollLock();\n      if (audioUrl) URL.revokeObjectURL(audioUrl);\n    };\n  }, [audioUrl, cancelSegmentedPlayback, clearTranscriptAutoScrollLock]);\n\n  React.useEffect(() => {\n    setLabelsById((current) => {\n      const next = Object.fromEntries(\n        segments.map((segment) => [segment.id, current[segment.id] ?? \"\"]),\n      );\n      const currentKeys = Object.keys(current);\n      const nextKeys = Object.keys(next);\n      if (\n        currentKeys.length === nextKeys.length &&\n        nextKeys.every((key) => current[key] === next[key])\n      ) {\n        return current;\n      }\n      return next;\n    });\n  }, [segments]);\n\n  const onEditSegmentLabel = React.useCallback(\n    (segmentId: string) => {\n      const currentLabel = labelsById[segmentId] ?? \"\";\n      const nextLabel = window.prompt(\"Segment label\", currentLabel);\n      if (nextLabel === null) return;\n      setLabelsById((current) => ({\n        ...current,\n        [segmentId]: nextLabel.trim(),\n      }));\n      setSelectedSegmentId(segmentId);\n    },\n    [labelsById],\n  );\n\n  const onRemoveSegment = React.useCallback((segmentId: string) => {\n    if (activeDraftRegionIdRef.current === segmentId) {\n      activeDraftRegionIdRef.current = null;\n    }\n    pendingRegionIdsRef.current.delete(segmentId);\n    setSegments((current) =>\n      current.filter((segment) => segment.id !== segmentId),\n    );\n    setLabelsById((current) => {\n      if (!(segmentId in current)) return current;\n      const next = { ...current };\n      delete next[segmentId];\n      return next;\n    });\n    setSelectedSegmentId((currentId) =>\n      currentId === segmentId ? null : currentId,\n    );\n    setHoveredSegmentId((currentId) =>\n      currentId === segmentId ? null : currentId,\n    );\n    setMergePreview((current) => {\n      if (current?.activeId === segmentId || current?.targetId === segmentId) {\n        return null;\n      }\n      return current;\n    });\n    setWordMarkTimeOverridesBySegmentId((current) => {\n      if (!(segmentId in current)) return current;\n      const next = { ...current };\n      delete next[segmentId];\n      return next;\n    });\n    setDraggingWordMarker((current) =>\n      current?.segmentId === segmentId ? null : current,\n    );\n  }, []);\n\n  const onShrinkWrapSegment = React.useCallback(\n    (segmentId: string) => {\n      if (!audioBuffer) return;\n      setSegments((current) =>\n        sortSegments(\n          current.map((segment) => {\n            if (segment.id !== segmentId) return segment;\n            const nextBounds = getShrinkWrappedSegment(\n              audioBuffer,\n              segment,\n              detectionSettings,\n            );\n            return buildSegment({\n              ...segment,\n              start: nextBounds.start,\n              end: nextBounds.end,\n            });\n          }),\n        ),\n      );\n      setHoveredSegmentId(segmentId);\n      setSelectedSegmentId(segmentId);\n    },\n    [audioBuffer, buildSegment, detectionSettings],\n  );\n\n  const onShrinkWrapAll = React.useCallback(() => {\n    if (!audioBuffer) return;\n    setSegments((current) =>\n      sortSegments(\n        current.map((segment) => {\n          const nextBounds = getShrinkWrappedSegment(\n            audioBuffer,\n            segment,\n            detectionSettings,\n          );\n          return buildSegment({\n            ...segment,\n            start: nextBounds.start,\n            end: nextBounds.end,\n          });\n        }),\n      ),\n    );\n  }, [audioBuffer, buildSegment, detectionSettings]);\n\n  const mergeOverlappingRegions = React.useCallback(\n    (plugin: RegionsPlugin, activeId: string, targetId: string) => {\n      const activeRegion = plugin\n        .getRegions()\n        .find((candidate) => candidate.id === activeId);\n      const targetRegion = plugin\n        .getRegions()\n        .find((candidate) => candidate.id === targetId);\n      if (!activeRegion || !targetRegion) return;\n\n      const mergedStart = Math.min(activeRegion.start, targetRegion.start);\n      const mergedEnd = Math.max(activeRegion.end, targetRegion.end);\n      const activeExists = segments.some((segment) => segment.id === activeId);\n      const targetExists = segments.some((segment) => segment.id === targetId);\n      const survivingId = activeExists\n        ? activeId\n        : targetExists\n          ? targetId\n          : activeId;\n      const removedId = survivingId === activeId ? targetId : activeId;\n      const preservedLabel =\n        labelsById[survivingId] || labelsById[removedId] || \"\";\n\n      activeDraftRegionIdRef.current = null;\n      pendingRegionIdsRef.current.delete(activeId);\n      pendingRegionIdsRef.current.delete(targetId);\n      setMergePreview(null);\n      const removedRegion = plugin\n        .getRegions()\n        .find((candidate) => candidate.id === removedId);\n      removedRegion?.remove();\n      activeRegion.setOptions({\n        drag: false,\n      });\n      setLabelsById((current) => {\n        const next = { ...current };\n        next[survivingId] = preservedLabel;\n        delete next[removedId];\n        return next;\n      });\n      setSegments((current) => {\n        const next = current.filter(\n          (segment) => segment.id !== activeId && segment.id !== targetId,\n        );\n        next.push(\n          buildSegment({\n            id: survivingId,\n            start: mergedStart,\n            end: mergedEnd,\n          }),\n        );\n        return sortSegments(next);\n      });\n      setHoveredSegmentId(survivingId);\n      setSelectedSegmentId(survivingId);\n    },\n    [buildSegment, labelsById, segments],\n  );\n\n  const pendingRegionTouchesCommittedSegment = React.useCallback(\n    (region: SegmentRegion) =>\n      segments.some((segment) =>\n        overlapsSegment(segment, {\n          start: region.start,\n          end: region.end,\n        }),\n      ),\n    [segments],\n  );\n\n  const syncRegionAppearance = React.useCallback(\n    (plugin: RegionsPlugin) => {\n      sortSegments(segments).forEach((segment, index) => {\n        const region = plugin\n          .getRegions()\n          .find((candidate) => candidate.id === segment.id);\n        if (!region) return;\n        const wordMarks = wordMarksBySegmentId[segment.id] ?? [];\n        const activeWordIndex = activeWordIndexBySegmentId[segment.id] ?? -1;\n\n        region.setOptions({\n          color: SEGMENT_COLOR,\n          drag: false,\n          resize: true,\n          minLength: MIN_SEGMENT_LENGTH_SECONDS,\n          content: createRegionContent({\n            index,\n            label: labelsById[segment.id] ?? \"\",\n            showControls: hoveredSegmentId === segment.id,\n            showJoinHint:\n              mergePreview?.activeId === segment.id ||\n              mergePreview?.targetId === segment.id,\n            onPlay: () => {\n              setSelectedSegmentId(segment.id);\n              playSegmentAudio(segment);\n            },\n            onShrinkWrap: () => {\n              onShrinkWrapSegment(segment.id);\n            },\n            onDelete: () => {\n              onRemoveSegment(segment.id);\n            },\n            onEditLabel: () => onEditSegmentLabel(segment.id),\n          }),\n        });\n\n        if (region.element) {\n          region.element.onmouseenter = () => {\n            setHoveredSegmentId(segment.id);\n          };\n          if (region.content instanceof HTMLElement) {\n            region.content.style.position = \"relative\";\n            region.content.style.zIndex = \"1\";\n          }\n          region.element.style.border = `1px solid ${\n            selectedSegmentId === segment.id\n              ? SEGMENT_ACTIVE_BORDER_COLOR\n              : SEGMENT_BORDER_COLOR\n          }`;\n          region.element.style.borderRadius = \"10px\";\n          region.element.style.boxShadow =\n            selectedSegmentId === segment.id\n              ? \"inset 0 0 0 1px rgba(255,255,255,0.26), 0 0 0 1px rgba(28,176,246,0.2)\"\n              : \"inset 0 0 0 1px rgba(255,255,255,0.2)\";\n          syncRegionSkipMarkers(region.element, segment);\n          syncRegionWordMarkers(\n            region.element,\n            segment,\n            wordMarks,\n            activeWordIndex,\n          );\n        }\n      });\n    },\n    [\n      activeWordIndexBySegmentId,\n      labelsById,\n      mergePreview?.activeId,\n      mergePreview?.targetId,\n      onEditSegmentLabel,\n      onRemoveSegment,\n      playSegmentAudio,\n      onShrinkWrapSegment,\n      hoveredSegmentId,\n      selectedSegmentId,\n      segments,\n      wordMarksBySegmentId,\n    ],\n  );\n\n  const syncRegionsFromState = React.useCallback(\n    (plugin: RegionsPlugin) => {\n      isSyncingRegionsRef.current = true;\n      try {\n        const stateIds = new Set(segments.map((segment) => segment.id));\n        for (const region of plugin.getRegions()) {\n          const isActiveDraft = activeDraftRegionIdRef.current === region.id;\n          if (!stateIds.has(region.id) && !isActiveDraft) {\n            pendingRegionIdsRef.current.delete(region.id);\n            region.remove();\n          }\n        }\n\n        for (const segment of segments) {\n          const existingRegion = plugin\n            .getRegions()\n            .find((candidate) => candidate.id === segment.id);\n          if (existingRegion) {\n            existingRegion.setOptions({\n              start: segment.start,\n              end: segment.end,\n              drag: false,\n              resize: true,\n              minLength: MIN_SEGMENT_LENGTH_SECONDS,\n            });\n            continue;\n          }\n\n          plugin.addRegion({\n            id: segment.id,\n            start: segment.start,\n            end: segment.end,\n            color: SEGMENT_COLOR,\n            drag: false,\n            resize: true,\n            minLength: MIN_SEGMENT_LENGTH_SECONDS,\n          });\n        }\n      } finally {\n        isSyncingRegionsRef.current = false;\n      }\n    },\n    [segments],\n  );\n\n  React.useEffect(() => {\n    if (!wavesurfer) return;\n    wavesurfer.setOptions({\n      minPxPerSec: zoomPxPerSec,\n      fillParent: false,\n      autoScroll: false,\n      hideScrollbar: false,\n    });\n  }, [wavesurfer, zoomPxPerSec]);\n\n  React.useEffect(() => {\n    if (!wavesurfer) return;\n\n    const updatePlaybackTime = (timeSeconds?: number) => {\n      setPlaybackTimeSeconds(\n        typeof timeSeconds === \"number\"\n          ? timeSeconds\n          : wavesurfer.getCurrentTime(),\n      );\n    };\n    const onPlay = () => setIsPlaying(true);\n    const onPause = () => {\n      updatePlaybackTime();\n      setIsPlaying(false);\n    };\n    const onFinish = () => {\n      updatePlaybackTime();\n      setIsPlaying(false);\n    };\n\n    wavesurfer.on(\"timeupdate\", updatePlaybackTime);\n    wavesurfer.on(\"play\", onPlay);\n    wavesurfer.on(\"pause\", onPause);\n    wavesurfer.on(\"finish\", onFinish);\n\n    return () => {\n      wavesurfer.un(\"timeupdate\", updatePlaybackTime);\n      wavesurfer.un(\"play\", onPlay);\n      wavesurfer.un(\"pause\", onPause);\n      wavesurfer.un(\"finish\", onFinish);\n    };\n  }, [wavesurfer]);\n\n  const updateViewportRange = React.useCallback(() => {\n    const scrollContainer = getWaveformScrollElement(wavesurfer);\n    if (\n      !scrollContainer ||\n      !waveformReady ||\n      zoomPxPerSec <= 0 ||\n      duration <= 0\n    ) {\n      setViewportRange((current) =>\n        current.start === 0 && current.end === 0\n          ? current\n          : { start: 0, end: 0 },\n      );\n      return;\n    }\n\n    const nextStart = clamp(\n      scrollContainer.scrollLeft / zoomPxPerSec,\n      0,\n      duration,\n    );\n    const nextEnd = clamp(\n      (scrollContainer.scrollLeft + scrollContainer.clientWidth) / zoomPxPerSec,\n      nextStart,\n      duration,\n    );\n\n    setViewportRange((current) => {\n      if (\n        Math.abs(current.start - nextStart) < 0.01 &&\n        Math.abs(current.end - nextEnd) < 0.01\n      ) {\n        return current;\n      }\n      return {\n        start: nextStart,\n        end: nextEnd,\n      };\n    });\n  }, [duration, waveformReady, wavesurfer, zoomPxPerSec]);\n\n  React.useEffect(() => {\n    if (!wavesurfer) return;\n    const scrollContainer = getWaveformScrollElement(wavesurfer);\n    if (!scrollContainer) return;\n\n    const onScroll = () => {\n      updateViewportRange();\n    };\n\n    const resizeObserver = new ResizeObserver(() => {\n      updateViewportRange();\n    });\n    const onUserWaveformInteraction = () => {\n      clearTranscriptAutoScrollLock();\n    };\n\n    updateViewportRange();\n    scrollContainer.addEventListener(\"scroll\", onScroll, { passive: true });\n    scrollContainer.addEventListener(\"pointerdown\", onUserWaveformInteraction, {\n      passive: true,\n    });\n    scrollContainer.addEventListener(\"wheel\", onUserWaveformInteraction, {\n      passive: true,\n    });\n    resizeObserver.observe(scrollContainer);\n\n    return () => {\n      scrollContainer.removeEventListener(\"scroll\", onScroll);\n      scrollContainer.removeEventListener(\n        \"pointerdown\",\n        onUserWaveformInteraction,\n      );\n      scrollContainer.removeEventListener(\"wheel\", onUserWaveformInteraction);\n      resizeObserver.disconnect();\n    };\n  }, [clearTranscriptAutoScrollLock, updateViewportRange, wavesurfer]);\n\n  const selectedSegmentIndex = React.useMemo(\n    () =>\n      sortedSegments.findIndex((segment) => segment.id === selectedSegmentId),\n    [selectedSegmentId, sortedSegments],\n  );\n\n  const visibleSegmentIndexes = React.useMemo(() => {\n    const indexes = new Set<number>();\n    if (viewportRange.end <= viewportRange.start) return indexes;\n\n    sortedSegments.forEach((segment, index) => {\n      if (\n        Math.min(segment.end, viewportRange.end) -\n          Math.max(segment.start, viewportRange.start) >\n        0\n      ) {\n        indexes.add(index);\n      }\n    });\n\n    return indexes;\n  }, [sortedSegments, viewportRange.end, viewportRange.start]);\n\n  React.useEffect(() => {\n    if (suppressTranscriptAutoScrollRef.current) return;\n\n    const activeIndexes = [...visibleSegmentIndexes].sort(\n      (left, right) => left - right,\n    );\n    const firstActiveIndex = activeIndexes[0];\n    if (firstActiveIndex === undefined) return;\n\n    const transcriptScroll = transcriptScrollRef.current;\n    const transcriptRow = transcriptRowRefs.current[firstActiveIndex];\n    if (!transcriptScroll || !transcriptRow) return;\n    scrollTranscriptRowIntoView(firstActiveIndex, \"smooth\");\n  }, [scrollTranscriptRowIntoView, visibleSegmentIndexes]);\n\n  React.useEffect(() => {\n    syncRegionsFromState(typedRegionsPlugin);\n  }, [syncRegionsFromState, typedRegionsPlugin]);\n\n  React.useEffect(() => {\n    syncRegionAppearance(typedRegionsPlugin);\n  }, [syncRegionAppearance, typedRegionsPlugin]);\n\n  React.useEffect(() => {\n    if (!wavesurfer) return;\n\n    const markSegmentedRangeEndReached = () => {\n      const playbackState = segmentedPlaybackRef.current;\n      if (!playbackState) return;\n\n      const currentRange =\n        playbackState.keepRanges[playbackState.currentRangeIndex];\n      if (!currentRange) {\n        segmentedPlaybackRef.current = null;\n        return;\n      }\n\n      if (playbackState.didReachRangeEnd) return;\n      if (wavesurfer.getCurrentTime() < currentRange.end) return;\n\n      segmentedPlaybackRef.current = {\n        ...playbackState,\n        didReachRangeEnd: true,\n      };\n    };\n\n    const continueSegmentedPlayback = () => {\n      const playbackState = segmentedPlaybackRef.current;\n      if (!playbackState) return;\n      if (!playbackState.didReachRangeEnd) return;\n\n      const currentRange =\n        playbackState.keepRanges[playbackState.currentRangeIndex];\n      if (!currentRange) {\n        segmentedPlaybackRef.current = null;\n        return;\n      }\n\n      const nextRangeIndex = playbackState.currentRangeIndex + 1;\n      const nextRange = playbackState.keepRanges[nextRangeIndex];\n      if (!nextRange) {\n        segmentedPlaybackRef.current = null;\n        return;\n      }\n\n      segmentedPlaybackRef.current = {\n        ...playbackState,\n        currentRangeIndex: nextRangeIndex,\n        didReachRangeEnd: false,\n      };\n      void wavesurfer.play(nextRange.start, nextRange.end);\n    };\n\n    wavesurfer.on(\"audioprocess\", markSegmentedRangeEndReached);\n    wavesurfer.on(\"pause\", continueSegmentedPlayback);\n    wavesurfer.on(\"finish\", continueSegmentedPlayback);\n\n    return () => {\n      wavesurfer.un(\"audioprocess\", markSegmentedRangeEndReached);\n      wavesurfer.un(\"pause\", continueSegmentedPlayback);\n      wavesurfer.un(\"finish\", continueSegmentedPlayback);\n    };\n  }, [wavesurfer]);\n\n  React.useEffect(() => {\n    if (!wavesurfer) return;\n    const plugin = typedRegionsPlugin;\n\n    const refreshRegionUi = () => {\n      syncRegionAppearance(plugin);\n    };\n\n    const disableDragSelection = plugin.enableDragSelection(\n      {\n        color: SEGMENT_COLOR,\n        drag: false,\n        resize: true,\n        minLength: 0.05,\n      },\n      2,\n    );\n\n    const onRegionCreated = (region: SegmentRegion) => {\n      if (isSyncingRegionsRef.current) return;\n      const isCommittedRegion = segments.some(\n        (segment) => segment.id === region.id,\n      );\n      if (!isCommittedRegion) {\n        for (const candidate of plugin.getRegions()) {\n          if (\n            candidate.id !== region.id &&\n            !segments.some((segment) => segment.id === candidate.id)\n          ) {\n            pendingRegionIdsRef.current.delete(candidate.id);\n            candidate.remove();\n          }\n        }\n        activeDraftRegionIdRef.current = region.id;\n      }\n      if (!isCommittedRegion && pendingRegionTouchesCommittedSegment(region)) {\n        activeDraftRegionIdRef.current = null;\n        region.remove();\n        return;\n      }\n      region.setOptions({\n        drag: false,\n        resize: true,\n        minLength: 0.05,\n      });\n      if (!isCommittedRegion) {\n        const draftDuration = region.end - region.start;\n        activeDraftRegionIdRef.current = null;\n        pendingRegionIdsRef.current.clear();\n        if (draftDuration < MIN_PERSISTED_NEW_SEGMENT_SECONDS) {\n          region.remove();\n          setMergePreview(null);\n          return;\n        }\n        setSegments((current) =>\n          sortSegments([\n            ...current,\n            buildSegment({\n              id: region.id,\n              start: region.start,\n              end: region.end,\n            }),\n          ]),\n        );\n        setHoveredSegmentId(region.id);\n      }\n      setSelectedSegmentId(region.id);\n      refreshRegionUi();\n    };\n    const onRegionUpdate = (region: SegmentRegion) => {\n      if (isSyncingRegionsRef.current) return;\n      if (\n        pendingRegionIdsRef.current.has(region.id) &&\n        activeDraftRegionIdRef.current !== region.id\n      ) {\n        pendingRegionIdsRef.current.delete(region.id);\n        region.remove();\n        return;\n      }\n      if (\n        pendingRegionIdsRef.current.has(region.id) &&\n        pendingRegionTouchesCommittedSegment(region)\n      ) {\n        activeDraftRegionIdRef.current = null;\n        pendingRegionIdsRef.current.delete(region.id);\n        setMergePreview(null);\n        region.remove();\n        return;\n      }\n      const overlappingRegion = getOverlappingRegion(plugin, region);\n      setMergePreview(\n        overlappingRegion\n          ? { activeId: region.id, targetId: overlappingRegion.id }\n          : null,\n      );\n      refreshRegionUi();\n    };\n    const onRegionUpdated = (region: SegmentRegion) => {\n      if (isSyncingRegionsRef.current) return;\n      if (\n        pendingRegionIdsRef.current.has(region.id) &&\n        pendingRegionTouchesCommittedSegment(region)\n      ) {\n        activeDraftRegionIdRef.current = null;\n        pendingRegionIdsRef.current.delete(region.id);\n        setMergePreview(null);\n        region.remove();\n        return;\n      }\n      const overlappingRegion = getOverlappingRegion(plugin, region);\n      if (overlappingRegion) {\n        mergeOverlappingRegions(plugin, region.id, overlappingRegion.id);\n        return;\n      }\n\n      const isPending = pendingRegionIdsRef.current.has(region.id);\n      if (isPending) {\n        activeDraftRegionIdRef.current = null;\n        pendingRegionIdsRef.current.delete(region.id);\n        setMergePreview(null);\n        region.remove();\n        return;\n      } else {\n        setSegments((current) =>\n          sortSegments(\n            current.map((segment) =>\n              segment.id === region.id\n                ? buildSegment({\n                    ...segment,\n                    start: region.start,\n                    end: region.end,\n                  })\n                : segment,\n            ),\n          ),\n        );\n        setHoveredSegmentId(region.id);\n      }\n      setMergePreview(null);\n      refreshRegionUi();\n    };\n    const onRegionRemoved = (region: SegmentRegion) => {\n      if (isSyncingRegionsRef.current) return;\n      if (activeDraftRegionIdRef.current === region.id) {\n        activeDraftRegionIdRef.current = null;\n      }\n      pendingRegionIdsRef.current.delete(region.id);\n      setMergePreview((current) => {\n        if (\n          current?.activeId === region.id ||\n          current?.targetId === region.id\n        ) {\n          return null;\n        }\n        return current;\n      });\n      refreshRegionUi();\n    };\n    const onReady = () => {\n      setWaveformReady(true);\n      setDuration(wavesurfer.getDuration());\n      syncRegionsFromState(plugin);\n      refreshRegionUi();\n    };\n\n    plugin.on(\"region-created\", onRegionCreated);\n    plugin.on(\"region-update\", onRegionUpdate);\n    plugin.on(\"region-updated\", onRegionUpdated);\n    plugin.on(\"region-removed\", onRegionRemoved);\n    wavesurfer.on(\"ready\", onReady);\n\n    return () => {\n      plugin.un(\"region-created\", onRegionCreated);\n      plugin.un(\"region-update\", onRegionUpdate);\n      plugin.un(\"region-updated\", onRegionUpdated);\n      plugin.un(\"region-removed\", onRegionRemoved);\n      wavesurfer.un(\"ready\", onReady);\n      disableDragSelection();\n    };\n  }, [\n    buildSegment,\n    mergeOverlappingRegions,\n    pendingRegionTouchesCommittedSegment,\n    segments,\n    syncRegionAppearance,\n    syncRegionsFromState,\n    typedRegionsPlugin,\n    wavesurfer,\n  ]);\n\n  const replaceSegments = React.useCallback(\n    (nextSegments: SegmentDraft[], settingsOverride?: DetectionSettings) => {\n      activeDraftRegionIdRef.current = null;\n      pendingRegionIdsRef.current.clear();\n      setMergePreview(null);\n      const next = sortSegments(\n        nextSegments.map((segment) =>\n          buildSegment(\n            {\n              id: createSegmentId(),\n              start: segment.start,\n              end: segment.end,\n            },\n            settingsOverride,\n          ),\n        ),\n      );\n      setSegments(next);\n      setSelectedSegmentId(next[0]?.id ?? null);\n    },\n    [buildSegment],\n  );\n\n  const runAutoDetect = React.useCallback(() => {\n    if (!audioBuffer) return;\n    replaceSegments(detectSpeechSegments(audioBuffer, detectionSettings));\n  }, [audioBuffer, detectionSettings, replaceSegments]);\n\n  React.useEffect(() => {\n    if (!audioBuffer || !waveformReady) return;\n    if (\n      lastHandledAutoDetectRequestRef.current >= autoDetectRequestRef.current\n    ) {\n      return;\n    }\n\n    lastHandledAutoDetectRequestRef.current = autoDetectRequestRef.current;\n    runAutoDetect();\n  }, [audioBuffer, runAutoDetect, waveformReady]);\n\n  const updateLoadedAudio = React.useCallback(\n    (nextBuffer: AudioBuffer) => {\n      cancelSegmentedPlayback();\n      setWaveformReady(false);\n      setDuration(0);\n      setPlaybackTimeSeconds(0);\n      setIsPlaying(false);\n      setAudioBuffer(nextBuffer);\n      setAudioUrl((currentUrl) => {\n        if (currentUrl) URL.revokeObjectURL(currentUrl);\n        return URL.createObjectURL(audioBufferToWavBlob(nextBuffer));\n      });\n    },\n    [cancelSegmentedPlayback],\n  );\n\n  const onFileChange = React.useCallback(\n    async (file: File | null) => {\n      if (!file) return;\n\n      const requestToken = autoDetectRequestRef.current + 1;\n      autoDetectRequestRef.current = requestToken;\n      normalizeOperationRef.current += 1;\n      setIsLoadingAudio(true);\n      setIsNormalizingAudio(false);\n      setAudioError(null);\n      setExportError(null);\n      setAudioFile(file);\n      setAudioBuffer(null);\n      setWaveformReady(false);\n      activeDraftRegionIdRef.current = null;\n      pendingRegionIdsRef.current.clear();\n      setSelectedSegmentId(null);\n      setSegments([]);\n      setLabelsById({});\n      setMergePreview(null);\n      setPlaybackTimeSeconds(0);\n      setWordMarkTimeOverridesBySegmentId({});\n      setDraggingWordMarker(null);\n      setDuration(0);\n      typedRegionsPlugin.clearRegions();\n\n      setAudioUrl((currentUrl) => {\n        if (currentUrl) URL.revokeObjectURL(currentUrl);\n        return \"\";\n      });\n\n      try {\n        const decoded = await decodeAudioData(await file.arrayBuffer());\n        if (requestToken !== autoDetectRequestRef.current) return;\n        const normalized = normalizeAudioBufferPeak(decoded);\n        updateLoadedAudio(normalized.buffer);\n      } catch (error) {\n        if (requestToken !== autoDetectRequestRef.current) return;\n        setAudioBuffer(null);\n        setAudioError(\n          getErrorMessage(error, \"Could not read the selected audio file.\"),\n        );\n      } finally {\n        if (requestToken === autoDetectRequestRef.current) {\n          setIsLoadingAudio(false);\n        }\n      }\n    },\n    [typedRegionsPlugin, updateLoadedAudio],\n  );\n\n  const onFileInputChange = React.useCallback(\n    async (event: React.ChangeEvent<HTMLInputElement>) => {\n      const file = event.target.files?.[0] ?? null;\n      event.target.value = \"\";\n      await onFileChange(file);\n    },\n    [onFileChange],\n  );\n\n  const onAudioDropzoneDragEnter = React.useCallback(\n    (event: React.DragEvent<HTMLDivElement>) => {\n      if (!event.dataTransfer.types.includes(\"Files\")) return;\n      event.preventDefault();\n      setIsDragOverAudioDropzone(true);\n    },\n    [],\n  );\n\n  const onAudioDropzoneDragOver = React.useCallback(\n    (event: React.DragEvent<HTMLDivElement>) => {\n      if (!event.dataTransfer.types.includes(\"Files\")) return;\n      event.preventDefault();\n      event.dataTransfer.dropEffect = \"copy\";\n      setIsDragOverAudioDropzone(true);\n    },\n    [],\n  );\n\n  const onAudioDropzoneDragLeave = React.useCallback(\n    (event: React.DragEvent<HTMLDivElement>) => {\n      if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n        setIsDragOverAudioDropzone(false);\n      }\n    },\n    [],\n  );\n\n  const onAudioDropzoneDrop = React.useCallback(\n    async (event: React.DragEvent<HTMLDivElement>) => {\n      event.preventDefault();\n      setIsDragOverAudioDropzone(false);\n      const file = event.dataTransfer.files?.[0] ?? null;\n      await onFileChange(file);\n    },\n    [onFileChange],\n  );\n\n  const onNormalizeAudio = React.useCallback(async () => {\n    if (\n      !audioBuffer ||\n      isNormalizingAudio ||\n      isExportingSegments ||\n      isLoadingAudio\n    ) {\n      return;\n    }\n\n    const normalizeToken = normalizeOperationRef.current + 1;\n    normalizeOperationRef.current = normalizeToken;\n    setIsNormalizingAudio(true);\n    setAudioError(null);\n    setExportError(null);\n\n    try {\n      await waitForNextAnimationFrame();\n      if (normalizeToken !== normalizeOperationRef.current) return;\n      const normalized = normalizeAudioBufferPeak(audioBuffer);\n      if (\n        normalized.changed &&\n        normalizeToken === normalizeOperationRef.current\n      ) {\n        updateLoadedAudio(normalized.buffer);\n      }\n    } catch (error) {\n      if (normalizeToken !== normalizeOperationRef.current) return;\n      setAudioError(\n        getErrorMessage(error, \"Could not normalize the loaded audio.\"),\n      );\n    } finally {\n      if (normalizeToken === normalizeOperationRef.current) {\n        setIsNormalizingAudio(false);\n      }\n    }\n  }, [\n    audioBuffer,\n    isExportingSegments,\n    isLoadingAudio,\n    isNormalizingAudio,\n    updateLoadedAudio,\n  ]);\n\n  const onPausePlayback = React.useCallback(() => {\n    cancelSegmentedPlayback();\n    wavesurfer?.pause();\n  }, [cancelSegmentedPlayback, wavesurfer]);\n\n  const onStopPlayback = React.useCallback(() => {\n    cancelSegmentedPlayback();\n    wavesurfer?.pause();\n\n    const selectedSegment = sortedSegments.find(\n      (segment) => segment.id === selectedSegmentId,\n    );\n    wavesurfer?.setTime(selectedSegment?.start ?? 0);\n    setPlaybackTimeSeconds(selectedSegment?.start ?? 0);\n    setIsPlaying(false);\n  }, [cancelSegmentedPlayback, selectedSegmentId, sortedSegments, wavesurfer]);\n\n  const onPlayPause = React.useCallback(() => {\n    if (isPlaying) {\n      onPausePlayback();\n      return;\n    }\n\n    const selectedSegment = sortedSegments.find(\n      (segment) => segment.id === selectedSegmentId,\n    );\n    if (selectedSegment) {\n      playSegmentAudio(selectedSegment);\n      return;\n    }\n\n    cancelSegmentedPlayback();\n    void wavesurfer?.play();\n  }, [\n    cancelSegmentedPlayback,\n    isPlaying,\n    onPausePlayback,\n    playSegmentAudio,\n    selectedSegmentId,\n    sortedSegments,\n    wavesurfer,\n  ]);\n\n  const scrollWaveformToSegment = React.useCallback(\n    (segment: Segment) => {\n      if (!waveformReady || zoomPxPerSec <= 0) return;\n\n      const scrollContainer =\n        getWaveformScrollElement(wavesurfer) ?? scrollContainerRef.current;\n      if (!scrollContainer) return;\n\n      const segmentStartPx = segment.start * zoomPxPerSec;\n      const segmentEndPx = segment.end * zoomPxPerSec;\n      const viewportStartPx = scrollContainer.scrollLeft;\n      const viewportEndPx = viewportStartPx + scrollContainer.clientWidth;\n\n      if (segmentStartPx >= viewportStartPx && segmentEndPx <= viewportEndPx) {\n        return;\n      }\n\n      const segmentWidthPx = segmentEndPx - segmentStartPx;\n      const centeredLeftPx =\n        segmentStartPx -\n        Math.max(0, (scrollContainer.clientWidth - segmentWidthPx) / 2);\n      const maxScrollLeft = Math.max(\n        0,\n        scrollContainer.scrollWidth - scrollContainer.clientWidth,\n      );\n\n      lockTranscriptAutoScroll(scrollContainer);\n      scrollContainer.scrollTo({\n        left: clamp(centeredLeftPx, 0, maxScrollLeft),\n        behavior: \"smooth\",\n      });\n    },\n    [lockTranscriptAutoScroll, waveformReady, wavesurfer, zoomPxPerSec],\n  );\n\n  const onPlayApproximateWord = React.useCallback(\n    (segment: Segment, marks: AudioMark[], markIndex: number) => {\n      if (!wavesurfer) return;\n\n      const playbackRange = getApproximateWordPlaybackRange(\n        segment,\n        marks,\n        markIndex,\n      );\n      if (!playbackRange) return;\n\n      const keepRanges = getKeepRanges(\n        {\n          start: playbackRange.startSeconds,\n          end: playbackRange.endSeconds,\n        },\n        segment.skipRanges,\n      );\n      const firstRange = keepRanges[0];\n      if (!firstRange) return;\n\n      cancelSegmentedPlayback();\n      segmentedPlaybackRef.current = {\n        currentRangeIndex: 0,\n        didReachRangeEnd: false,\n        keepRanges,\n      };\n      setSelectedSegmentId(segment.id);\n      scrollWaveformToSegment(segment);\n      void wavesurfer.play(firstRange.start, firstRange.end);\n    },\n    [cancelSegmentedPlayback, scrollWaveformToSegment, wavesurfer],\n  );\n\n  const updateWordMarkTimeOverride = React.useCallback(\n    (\n      segment: Segment,\n      marks: AudioMark[],\n      markIndex: number,\n      nextTimeMs: number,\n    ) => {\n      const keepRanges = getKeepRanges(\n        {\n          start: segment.start,\n          end: segment.end,\n        },\n        segment.skipRanges,\n      );\n      if (keepRanges.length === 0) return;\n\n      const keepStartMs = Math.round(\n        (keepRanges[0]?.start ?? segment.start) * 1000,\n      );\n      const keepEndMs = Math.round(\n        getKeepRangeEnd(\n          {\n            start: segment.start,\n            end: segment.end,\n          },\n          segment.skipRanges,\n        ) * 1000,\n      );\n      const previousTimeMs =\n        markIndex > 0\n          ? (marks[markIndex - 1]?.time ?? keepStartMs)\n          : keepStartMs;\n      const nextMarkTimeMs =\n        markIndex < marks.length - 1\n          ? (marks[markIndex + 1]?.time ?? keepEndMs)\n          : keepEndMs;\n      const minTimeMs =\n        markIndex === 0 ? keepStartMs : previousTimeMs + MIN_WORD_MARK_GAP_MS;\n      const maxTimeMs =\n        markIndex === marks.length - 1\n          ? keepEndMs\n          : nextMarkTimeMs - MIN_WORD_MARK_GAP_MS;\n      const boundedTimeSeconds = clampTimeToKeepRanges(\n        clamp(nextTimeMs, minTimeMs, Math.max(minTimeMs, maxTimeMs)) / 1000,\n        keepRanges,\n      );\n\n      setWordMarkTimeOverridesBySegmentId((current) => {\n        const segmentOverrides = [...(current[segment.id] ?? [])];\n        segmentOverrides[markIndex] = Math.round(boundedTimeSeconds * 1000);\n        return {\n          ...current,\n          [segment.id]: segmentOverrides,\n        };\n      });\n    },\n    [],\n  );\n\n  const updateDraggedWordMarker = React.useCallback(\n    (segmentId: string, markIndex: number, clientX: number) => {\n      const segment = sortedSegments.find(\n        (candidate) => candidate.id === segmentId,\n      );\n      const marks = wordMarksBySegmentId[segmentId] ?? [];\n      const timeline = wordTimelineRefs.current[segmentId];\n      if (!segment || !timeline || marks.length === 0) return;\n\n      const rect = timeline.getBoundingClientRect();\n      if (rect.width <= 0) return;\n\n      const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);\n      const rawTimeMs = Math.round(\n        (segment.start + (segment.end - segment.start) * ratio) * 1000,\n      );\n      updateWordMarkTimeOverride(segment, marks, markIndex, rawTimeMs);\n    },\n    [sortedSegments, updateWordMarkTimeOverride, wordMarksBySegmentId],\n  );\n\n  const onStartWordMarkerDrag = React.useCallback(\n    (\n      event: React.PointerEvent<HTMLButtonElement>,\n      segmentId: string,\n      markIndex: number,\n    ) => {\n      if (event.button !== 0) return;\n      event.preventDefault();\n      event.stopPropagation();\n      setHoveredSegmentId(segmentId);\n      setSelectedSegmentId(segmentId);\n      setDraggingWordMarker({ markIndex, segmentId });\n      updateDraggedWordMarker(segmentId, markIndex, event.clientX);\n    },\n    [updateDraggedWordMarker],\n  );\n\n  React.useEffect(() => {\n    if (!draggingWordMarker) return;\n\n    const onPointerMove = (event: PointerEvent) => {\n      updateDraggedWordMarker(\n        draggingWordMarker.segmentId,\n        draggingWordMarker.markIndex,\n        event.clientX,\n      );\n    };\n    const onPointerUp = () => {\n      setDraggingWordMarker(null);\n    };\n\n    window.addEventListener(\"pointermove\", onPointerMove);\n    window.addEventListener(\"pointerup\", onPointerUp);\n\n    return () => {\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      window.removeEventListener(\"pointerup\", onPointerUp);\n    };\n  }, [draggingWordMarker, updateDraggedWordMarker]);\n\n  const onPlaySegment = React.useCallback(\n    (segment: Segment) => {\n      setSelectedSegmentId(segment.id);\n      scrollWaveformToSegment(segment);\n      playSegmentAudio(segment);\n    },\n    [playSegmentAudio, scrollWaveformToSegment],\n  );\n\n  const selectSegmentByIndex = React.useCallback(\n    (index: number) => {\n      const segment = sortedSegments[index];\n      if (!segment) return;\n\n      setHoveredSegmentId(segment.id);\n      setSelectedSegmentId(segment.id);\n      scrollWaveformToSegment(segment);\n      scrollTranscriptRowIntoView(index, \"smooth\");\n    },\n    [scrollTranscriptRowIntoView, scrollWaveformToSegment, sortedSegments],\n  );\n\n  React.useEffect(() => {\n    if (!open) return;\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (detectDialogOpen || shortcutDialogOpen) return;\n      if (event.altKey || event.ctrlKey || event.metaKey) return;\n      if (isEditableTarget(event.target)) return;\n\n      if (event.code === \"Space\") {\n        event.preventDefault();\n        onPlayPause();\n        return;\n      }\n\n      if (sortedSegments.length === 0) return;\n\n      if (event.code === \"Enter\") {\n        event.preventDefault();\n        const activeSegment =\n          sortedSegments[selectedSegmentIndex] ?? sortedSegments[0];\n        if (activeSegment) {\n          onPlaySegment(activeSegment);\n        }\n        return;\n      }\n\n      if (event.code === \"ArrowLeft\" || event.code === \"ArrowRight\") {\n        event.preventDefault();\n        if (event.repeat) return;\n        const delta = event.code === \"ArrowRight\" ? 1 : -1;\n        const currentIndex =\n          selectedSegmentIndex >= 0 ? selectedSegmentIndex : 0;\n        const nextIndex = clamp(\n          currentIndex + delta,\n          0,\n          sortedSegments.length - 1,\n        );\n        selectSegmentByIndex(nextIndex);\n      }\n    };\n\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      window.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [\n    detectDialogOpen,\n    shortcutDialogOpen,\n    onPlaySegment,\n    onPlayPause,\n    open,\n    selectSegmentByIndex,\n    selectedSegmentIndex,\n    sortedSegments,\n  ]);\n\n  const onZoomIn = React.useCallback(() => {\n    setZoomPxPerSec((current) =>\n      Math.min(MAX_WAVEFORM_ZOOM, current + WAVEFORM_ZOOM_STEP),\n    );\n  }, []);\n\n  const onZoomOut = React.useCallback(() => {\n    setZoomPxPerSec((current) =>\n      Math.max(MIN_WAVEFORM_ZOOM, current - WAVEFORM_ZOOM_STEP),\n    );\n  }, []);\n\n  const onZoomFit = React.useCallback(() => {\n    if (!wavesurfer || !waveformReady || !scrollContainerRef.current) return;\n    const nextDuration = wavesurfer.getDuration();\n    if (!nextDuration || !Number.isFinite(nextDuration)) return;\n    setZoomPxPerSec(\n      clamp(\n        Math.round(scrollContainerRef.current.clientWidth / nextDuration),\n        MIN_WAVEFORM_ZOOM,\n        MAX_WAVEFORM_ZOOM,\n      ),\n    );\n  }, [waveformReady, wavesurfer]);\n\n  const onClearSegments = React.useCallback(() => {\n    activeDraftRegionIdRef.current = null;\n    pendingRegionIdsRef.current.clear();\n    typedRegionsPlugin.clearRegions();\n    setSegments([]);\n    setLabelsById({});\n    setMergePreview(null);\n    setWordMarkTimeOverridesBySegmentId({});\n    setDraggingWordMarker(null);\n    setSelectedSegmentId(null);\n  }, [typedRegionsPlugin]);\n\n  const createSegmentFiles = React.useCallback(async () => {\n    if (!audioBuffer || !audioFile || segments.length === 0) return;\n\n    const baseName = getFileBaseName(audioFile.name) || \"segment\";\n    const files: File[] = [];\n    for (const [index, segment] of sortSegments(segments).entries()) {\n      const blob = await encodeSegmentAsMp3(\n        audioBuffer,\n        segment.start,\n        segment.end,\n        segment.skipRanges,\n      );\n      files.push(\n        new File(\n          [blob],\n          `${baseName}-${String(index + 1).padStart(3, \"0\")}.mp3`,\n          {\n            type: \"audio/mpeg\",\n          },\n        ),\n      );\n    }\n    return files;\n  }, [audioBuffer, audioFile, segments]);\n\n  const onDownloadSegmentZip = React.useCallback(async () => {\n    if (isExportingSegments) return;\n\n    setIsExportingSegments(true);\n    setExportError(null);\n    try {\n      const files = await createSegmentFiles();\n      if (!files || files.length === 0) return;\n\n      const entries = await Promise.all(\n        files.map(\n          async (file) =>\n            [file.name, new Uint8Array(await file.arrayBuffer())] as const,\n        ),\n      );\n      const zipBytes = zipSync(Object.fromEntries(entries), { level: 0 });\n      const zipArrayBuffer = new ArrayBuffer(zipBytes.byteLength);\n      new Uint8Array(zipArrayBuffer).set(zipBytes);\n      const zipBlob = new Blob([zipArrayBuffer], {\n        type: \"application/zip\",\n      });\n      const zipName = `${getFileBaseName(audioFile?.name || \"segments\")}.zip`;\n      const downloadUrl = URL.createObjectURL(zipBlob);\n      const anchor = document.createElement(\"a\");\n      anchor.href = downloadUrl;\n      anchor.download = zipName;\n      anchor.click();\n      URL.revokeObjectURL(downloadUrl);\n    } catch (error) {\n      console.error(\"Failed to export segment zip\", error);\n      setExportError(\n        `Could not export the segment zip: ${getErrorMessage(\n          error,\n          \"Unknown error.\",\n        )}`,\n      );\n    } finally {\n      setIsExportingSegments(false);\n    }\n  }, [audioFile?.name, createSegmentFiles, isExportingSegments]);\n\n  const onUseSegmentCuts = React.useCallback(async () => {\n    if (isExportingSegments) return;\n\n    setIsExportingSegments(true);\n    setExportError(null);\n    try {\n      const files = await createSegmentFiles();\n      if (!files || files.length === 0) return;\n\n      const preparedSegments = files.flatMap((file, index) => {\n        const item = transcriptItems[index];\n        const segment = sortedSegments[index];\n        if (!item || !segment) return [];\n\n        return [\n          {\n            file,\n            itemId: item.id,\n            lineIndex: item.lineIndex,\n            ssml: item.ssml,\n            keypoints: getKeypointsFromWordMarks(\n              wordMarksBySegmentId[segment.id] ?? [],\n            ),\n          },\n        ];\n      });\n      if (preparedSegments.length === 0) return;\n\n      const shouldClose = await onUseSegments(preparedSegments);\n      if (shouldClose !== false) {\n        onOpenChange(false);\n      }\n    } catch (error) {\n      console.error(\"Failed to prepare segment cuts\", error);\n      setExportError(\n        `Could not prepare segment audio: ${getErrorMessage(\n          error,\n          \"Unknown error.\",\n        )}`,\n      );\n    } finally {\n      setIsExportingSegments(false);\n    }\n  }, [\n    createSegmentFiles,\n    isExportingSegments,\n    onOpenChange,\n    onUseSegments,\n    sortedSegments,\n    transcriptItems,\n    wordMarksBySegmentId,\n  ]);\n\n  const segmentCountMismatch =\n    expectedSegmentCount > 0 && segments.length !== expectedSegmentCount;\n\n  const openDetectDialog = React.useCallback(() => {\n    setDetectionForm(detectionSettings);\n    setDetectDialogOpen(true);\n  }, [detectionSettings]);\n\n  const updateDetectionFormValue = React.useCallback(\n    (key: keyof DetectionSettings, value: string) => {\n      const numericValue = Number(value);\n      setDetectionForm((current) => ({\n        ...current,\n        [key]: Number.isFinite(numericValue) ? numericValue : 0,\n      }));\n    },\n    [],\n  );\n\n  const applyDetectionSettings = React.useCallback(() => {\n    const nextSettings = sanitizeDetectionSettings(detectionForm);\n    setDetectionSettings(nextSettings);\n    setDetectionForm(nextSettings);\n    setDetectDialogOpen(false);\n    if (!audioBuffer) return;\n    replaceSegments(\n      detectSpeechSegments(audioBuffer, nextSettings),\n      nextSettings,\n    );\n  }, [audioBuffer, detectionForm, replaceSegments]);\n\n  const content = (\n    <div\n      className={\n        renderInDialog\n          ? \"flex h-full min-w-[1080px] flex-col bg-[var(--body-background)]\"\n          : \"flex min-h-full w-full flex-col bg-[var(--body-background)]\"\n      }\n    >\n      {renderInDialog ? (\n        <div className=\"border-b border-[var(--color_base_border)] px-5 py-3\">\n          <DialogTitle className=\"flex items-center gap-2 text-lg font-semibold text-[var(--text-color)]\">\n            <ScissorsIcon className=\"h-5 w-5 text-[#0f5f83]\" />\n            Audio cutter\n          </DialogTitle>\n          <div className=\"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--text-color-dim)]\">\n            <DialogDescription className=\"m-0 max-w-[85ch] text-sm text-[var(--text-color-dim)]\">\n              Load one recording, detect cuts, then fine-tune them in the\n              waveform and transcript.\n            </DialogDescription>\n            <button\n              type=\"button\"\n              className=\"inline-flex h-6 items-center justify-center rounded-full border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-2.5 text-xs font-medium leading-none text-[var(--text-color-dim)] transition-colors hover:bg-[var(--color_base_background)]\"\n              onClick={() => setShowIntroHelp((current) => !current)}\n            >\n              {showIntroHelp ? \"Hide help\" : \"Show help\"}\n            </button>\n          </div>\n          {showIntroHelp ? (\n            <DialogDescription className=\"mt-2 max-w-[85ch] text-sm text-[var(--text-color-dim)]\">\n              Drag the start and end edges until the cuts line up. Drag on the\n              waveform to add extra segments manually.\n            </DialogDescription>\n          ) : null}\n        </div>\n      ) : null}\n\n      <div className=\"flex flex-wrap items-center gap-3 border-b border-[var(--color_base_border)] px-5 py-3\">\n        <button\n          type=\"button\"\n          className=\"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)] disabled:cursor-default disabled:opacity-70\"\n          onClick={() => fileInputRef.current?.click()}\n          disabled={isLoadingAudio}\n        >\n          <UploadIcon className=\"h-4 w-4\" />\n          {isLoadingAudio ? \"Reading audio...\" : \"Upload long audio\"}\n        </button>\n        <button\n          type=\"button\"\n          className=\"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)] disabled:cursor-default disabled:opacity-70\"\n          onClick={() => {\n            void onNormalizeAudio();\n          }}\n          disabled={\n            !audioBuffer ||\n            isNormalizingAudio ||\n            isExportingSegments ||\n            isLoadingAudio\n          }\n          title={\n            audioBuffer\n              ? \"Normalize the loaded source audio\"\n              : \"Load audio first\"\n          }\n        >\n          {isNormalizingAudio ? \"Normalizing...\" : \"Normalize audio\"}\n        </button>\n        <button\n          type=\"button\"\n          className=\"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)] disabled:cursor-default disabled:opacity-70\"\n          onClick={openDetectDialog}\n          disabled={!audioBuffer}\n          title={\n            audioBuffer ? \"Tune silence detection settings\" : \"Load audio first\"\n          }\n        >\n          <WandSparklesIcon className=\"h-4 w-4\" />\n          Detect silence\n        </button>\n        <button\n          type=\"button\"\n          className=\"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)] disabled:cursor-default disabled:opacity-70\"\n          onClick={onShrinkWrapAll}\n          disabled={!audioBuffer || segments.length === 0}\n        >\n          Shrink-wrap all\n        </button>\n        <button\n          type=\"button\"\n          className=\"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)] disabled:cursor-default disabled:opacity-70\"\n          onClick={() => {\n            void onDownloadSegmentZip();\n          }}\n          disabled={\n            !audioBuffer || segments.length === 0 || isExportingSegments\n          }\n        >\n          <DownloadIcon className=\"h-4 w-4\" />\n          {isExportingSegments ? \"Encoding MP3...\" : \"Download zip\"}\n        </button>\n        <div className=\"flex items-center gap-1 rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] p-1\">\n          <button\n            type=\"button\"\n            className=\"inline-flex h-7 w-7 items-center justify-center rounded-[6px] text-[var(--text-color)] transition-colors hover:bg-[var(--color_base_background)] disabled:cursor-default disabled:opacity-50\"\n            onClick={onPlayPause}\n            disabled={!audioUrl}\n            title={isPlaying ? \"Pause playback\" : \"Play selected sentence\"}\n          >\n            {isPlaying ? (\n              <PauseIcon className=\"h-4 w-4\" />\n            ) : (\n              <PlayIcon className=\"h-4 w-4\" />\n            )}\n          </button>\n          <button\n            type=\"button\"\n            className=\"inline-flex h-7 w-7 items-center justify-center rounded-[6px] text-[var(--text-color)] transition-colors hover:bg-[var(--color_base_background)] disabled:cursor-default disabled:opacity-50\"\n            onClick={onStopPlayback}\n            disabled={!audioUrl}\n            title=\"Stop playback\"\n          >\n            <SquareIcon className=\"h-3.5 w-3.5\" />\n          </button>\n        </div>\n        <div className=\"flex items-center gap-2 text-xs\">\n          <button\n            type=\"button\"\n            className=\"inline-flex h-7 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-2 leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n            onClick={onZoomOut}\n          >\n            -\n          </button>\n          <button\n            type=\"button\"\n            className=\"inline-flex h-7 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-2 leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n            onClick={onZoomIn}\n          >\n            +\n          </button>\n          <button\n            type=\"button\"\n            className=\"inline-flex h-7 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-2 leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n            onClick={onZoomFit}\n          >\n            Fit\n          </button>\n        </div>\n        <button\n          type=\"button\"\n          className=\"inline-flex h-9 w-9 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] text-[var(--text-color)] transition-colors hover:bg-[var(--color_base_background)]\"\n          onClick={() => setShortcutDialogOpen(true)}\n          title=\"Show keyboard shortcuts\"\n        >\n          <HelpCircleIcon className=\"h-4 w-4\" />\n        </button>\n        <input\n          ref={fileInputRef}\n          className=\"sr-only\"\n          type=\"file\"\n          accept=\"audio/*\"\n          onChange={onFileInputChange}\n        />\n        <div className=\"min-w-0 flex-1 truncate text-sm text-[var(--text-color-dim)]\">\n          {audioFile?.name || \"No source audio selected.\"}\n        </div>\n      </div>\n\n      <Dialog open={detectDialogOpen} onOpenChange={setDetectDialogOpen}>\n        <DialogContent className=\"max-w-[540px]\">\n          <DialogTitle className=\"flex items-center gap-2 text-lg font-semibold text-[var(--text-color)]\">\n            <WandSparklesIcon className=\"h-5 w-5 text-[#0f5f83]\" />\n            Silence detection\n          </DialogTitle>\n          <DialogDescription className=\"max-w-[60ch] text-sm text-[var(--text-color-dim)]\">\n            Tune how aggressively the cutter splits on silence. Short pauses can\n            stay inside the same segment, and you can add lead-in or tail buffer\n            around each detected block. Longer pauses inside a segment can also\n            be capped with export skip markers.\n          </DialogDescription>\n\n          <div className=\"grid gap-4 pt-2\">\n            <div className=\"rounded-[20px] border border-[var(--color_base_border)] bg-[var(--body-background-faint)] p-4\">\n              <div className=\"mb-1 text-sm font-semibold text-[var(--text-color)]\">\n                Minimum silence to split\n              </div>\n              <div className=\"mb-3 text-xs text-[var(--text-color-dim)]\">\n                Silences shorter than this stay in the same segment.\n              </div>\n              <Input\n                type=\"number\"\n                min=\"0.1\"\n                max=\"5\"\n                step=\"0.1\"\n                value={detectionForm.minSilenceSeconds}\n                onChange={(event) => {\n                  updateDetectionFormValue(\n                    \"minSilenceSeconds\",\n                    event.target.value,\n                  );\n                }}\n              />\n            </div>\n\n            <div className=\"grid gap-4 md:grid-cols-2\">\n              <div className=\"rounded-[20px] border border-[var(--color_base_border)] bg-[var(--body-background-faint)] p-4\">\n                <div className=\"mb-1 text-sm font-semibold text-[var(--text-color)]\">\n                  Start buffer\n                </div>\n                <div className=\"mb-3 text-xs text-[var(--text-color-dim)]\">\n                  Extra audio kept before the detected speech starts.\n                </div>\n                <Input\n                  type=\"number\"\n                  min=\"0\"\n                  max=\"2\"\n                  step=\"0.01\"\n                  value={detectionForm.startBufferSeconds}\n                  onChange={(event) => {\n                    updateDetectionFormValue(\n                      \"startBufferSeconds\",\n                      event.target.value,\n                    );\n                  }}\n                />\n              </div>\n\n              <div className=\"rounded-[20px] border border-[var(--color_base_border)] bg-[var(--body-background-faint)] p-4\">\n                <div className=\"mb-1 text-sm font-semibold text-[var(--text-color)]\">\n                  End buffer\n                </div>\n                <div className=\"mb-3 text-xs text-[var(--text-color-dim)]\">\n                  Extra audio kept after the detected speech ends.\n                </div>\n                <Input\n                  type=\"number\"\n                  min=\"0\"\n                  max=\"2\"\n                  step=\"0.01\"\n                  value={detectionForm.endBufferSeconds}\n                  onChange={(event) => {\n                    updateDetectionFormValue(\n                      \"endBufferSeconds\",\n                      event.target.value,\n                    );\n                  }}\n                />\n              </div>\n            </div>\n\n            <div className=\"rounded-[20px] border border-[var(--color_base_border)] bg-[var(--body-background-faint)] p-4\">\n              <div className=\"mb-1 text-sm font-semibold text-[var(--text-color)]\">\n                Max silence kept inside segment\n              </div>\n              <div className=\"mb-3 text-xs text-[var(--text-color-dim)]\">\n                Longer pauses stay in the segment but are trimmed from export as\n                internal skip markers. Set to 0 to keep internal pauses as-is.\n              </div>\n              <Input\n                type=\"number\"\n                min=\"0\"\n                max=\"3\"\n                step=\"0.05\"\n                value={detectionForm.maxInternalSilenceSeconds}\n                onChange={(event) => {\n                  updateDetectionFormValue(\n                    \"maxInternalSilenceSeconds\",\n                    event.target.value,\n                  );\n                }}\n              />\n            </div>\n          </div>\n\n          <div className=\"mt-2 flex items-center justify-between gap-3\">\n            <div className=\"text-xs text-[var(--text-color-dim)]\">\n              Current: split after {detectionSettings.minSilenceSeconds}s of\n              silence, with +{detectionSettings.startBufferSeconds}s / +\n              {detectionSettings.endBufferSeconds}s buffer\n              {detectionSettings.maxInternalSilenceSeconds > 0\n                ? `, and cap internal pauses at ${detectionSettings.maxInternalSilenceSeconds}s.`\n                : \", and keep internal pauses untouched.\"}\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <button\n                type=\"button\"\n                className=\"inline-flex h-9 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n                onClick={() => setDetectDialogOpen(false)}\n              >\n                Cancel\n              </button>\n              <button\n                type=\"button\"\n                className=\"inline-flex h-9 items-center justify-center rounded-md border border-[#0f5f83] bg-[#1cb0f6] px-3 text-sm font-semibold leading-none text-white transition-colors hover:bg-[#1598d7]\"\n                onClick={applyDetectionSettings}\n              >\n                Detect\n              </button>\n            </div>\n          </div>\n        </DialogContent>\n      </Dialog>\n\n      <Dialog open={shortcutDialogOpen} onOpenChange={setShortcutDialogOpen}>\n        <DialogContent className=\"max-w-[460px]\">\n          <DialogTitle className=\"flex items-center gap-2 text-lg font-semibold text-[var(--text-color)]\">\n            <HelpCircleIcon className=\"h-5 w-5 text-[#0f5f83]\" />\n            Keyboard shortcuts\n          </DialogTitle>\n          <DialogDescription className=\"max-w-[58ch] text-sm text-[var(--text-color-dim)]\">\n            Use these while focus is outside text fields.\n          </DialogDescription>\n\n          <div className=\"grid gap-2 pt-2 text-sm\">\n            {[\n              [\"Left arrow\", \"Select previous sentence\"],\n              [\"Right arrow\", \"Select next sentence\"],\n              [\"Space\", \"Play or pause the selected sentence\"],\n              [\"Enter\", \"Replay the selected sentence\"],\n              [\"Mouse click\", \"Select and play a transcript row\"],\n            ].map(([keys, description]) => (\n              <div\n                key={keys}\n                className=\"grid grid-cols-[130px_1fr] items-center gap-3 rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 py-2\"\n              >\n                <kbd className=\"rounded-[6px] border border-[var(--color_base_border)] bg-[var(--body-background)] px-2 py-1 text-center font-mono text-xs font-semibold text-[var(--text-color)]\">\n                  {keys}\n                </kbd>\n                <span className=\"text-[var(--text-color-dim)]\">\n                  {description}\n                </span>\n              </div>\n            ))}\n          </div>\n        </DialogContent>\n      </Dialog>\n\n      <div className=\"flex min-h-0 flex-1 flex-col\">\n        <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden px-5 py-4\">\n          <div className=\"mb-3 flex items-center justify-between gap-3\">\n            <div>\n              <div className=\"text-sm font-semibold text-[var(--text-color)]\">\n                Waveform\n              </div>\n              <div className=\"text-xs text-[var(--text-color-dim)]\">\n                Drag across the waveform to add a segment. Drag a segment body\n                to move it. Drag either edge to resize it.\n              </div>\n            </div>\n            <div className=\"flex items-center gap-3 text-right text-xs text-[var(--text-color-dim)]\">\n              {segments.length > 0 ? (\n                <button\n                  type=\"button\"\n                  className=\"inline-flex h-7 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-2 leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n                  onClick={onClearSegments}\n                >\n                  Clear segments\n                </button>\n              ) : null}\n              <div>\n                <div>\n                  {expectedSegmentCount > 0\n                    ? `${segments.length} segments / ${expectedSegmentCount} expected`\n                    : `${segments.length} segments ready`}\n                </div>\n                <div>\n                  {duration > 0 ? formatSeconds(duration) : \"00:00.000\"}\n                </div>\n              </div>\n            </div>\n          </div>\n          <div\n            className={`rounded-xl border transition-colors ${\n              isDragOverAudioDropzone\n                ? \"border-[#1cb0f6] bg-[rgba(28,176,246,0.08)]\"\n                : \"border-[var(--color_base_border)] bg-[linear-gradient(180deg,rgba(28,176,246,0.05),rgba(15,95,131,0.02))]\"\n            }`}\n            onDragEnter={onAudioDropzoneDragEnter}\n            onDragLeave={onAudioDropzoneDragLeave}\n            onDragOver={onAudioDropzoneDragOver}\n            onDrop={onAudioDropzoneDrop}\n          >\n            <div\n              ref={scrollContainerRef}\n              className=\"h-fit overflow-x-auto overflow-y-hidden rounded-xl p-4\"\n            >\n              <div ref={containerRef} />\n            </div>\n            <div className=\"border-t border-[var(--color_base_border)] px-4 py-2 text-xs text-[var(--text-color-dim)]\">\n              {isDragOverAudioDropzone\n                ? \"Drop audio here to replace the current source file.\"\n                : \"Drop an audio file here, or use Upload long audio.\"}\n            </div>\n          </div>\n          {audioError ? (\n            <p className=\"mt-3 text-sm text-[#b33b3b]\">{audioError}</p>\n          ) : null}\n          {exportError ? (\n            <p className=\"mt-3 text-sm text-[#b33b3b]\">{exportError}</p>\n          ) : null}\n          <div className=\"mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-[22px] border border-[var(--color_base_border)] bg-[var(--body-background-faint)] p-4\">\n            <div className=\"mb-3 flex items-center justify-between gap-3\">\n              <div>\n                <div className=\"text-sm font-semibold text-[var(--text-color)]\">\n                  Transcript\n                </div>\n                <div className=\"text-xs text-[var(--text-color-dim)]\">\n                  Highlighted rows are the segments currently visible in the\n                  waveform viewport.\n                </div>\n              </div>\n              <div className=\"text-right text-xs text-[var(--text-color-dim)]\">\n                {viewportRange.end > viewportRange.start ? (\n                  <>\n                    <div>{formatSeconds(viewportRange.start)}</div>\n                    <div>{formatSeconds(viewportRange.end)}</div>\n                  </>\n                ) : (\n                  <div>Scroll the waveform</div>\n                )}\n              </div>\n            </div>\n\n            <div\n              ref={transcriptScrollRef}\n              className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1\"\n            >\n              {transcriptItems.length === 0 ? (\n                <div className=\"rounded-xl border border-dashed border-[var(--color_base_border)] bg-[var(--body-background)] p-4 text-sm text-[var(--text-color-dim)]\">\n                  Story rows will appear here once the bulk audio editor has\n                  audio-capable lines.\n                </div>\n              ) : (\n                <div className=\"space-y-3\">\n                  {transcriptItems.map((item, index) => {\n                    const matchedSegment = sortedSegments[index];\n                    const isVisible = visibleSegmentIndexes.has(index);\n                    const isSelected = selectedSegmentIndex === index;\n                    const wordMarks = matchedSegment\n                      ? (wordMarksBySegmentId[matchedSegment.id] ?? [])\n                      : [];\n                    const activeWordIndex = matchedSegment\n                      ? (activeWordIndexBySegmentId[matchedSegment.id] ?? -1)\n                      : -1;\n                    const skippedDuration = matchedSegment\n                      ? getTotalRangeDuration(matchedSegment.skipRanges)\n                      : 0;\n                    const segmentDuration = matchedSegment\n                      ? Math.max(\n                          matchedSegment.end - matchedSegment.start,\n                          0.001,\n                        )\n                      : 0.001;\n                    const cardClassName = `rounded-[20px] border px-4 py-3 text-left transition-colors ${\n                      isVisible\n                        ? \"border-[#d7e34f] bg-[#f8fbcf]\"\n                        : isSelected\n                          ? \"border-[#1cb0f6] bg-[rgba(28,176,246,0.08)]\"\n                          : \"border-[var(--color_base_border)] bg-[var(--body-background)]\"\n                    }`;\n\n                    return (\n                      <div\n                        key={item.id}\n                        ref={(node) => {\n                          transcriptRowRefs.current[index] = node;\n                        }}\n                        className={cardClassName}\n                        onMouseEnter={() => {\n                          if (!matchedSegment) return;\n                          setHoveredSegmentId(matchedSegment.id);\n                          setSelectedSegmentId(matchedSegment.id);\n                        }}\n                      >\n                        <div\n                          className=\"block w-full text-left\"\n                          role=\"button\"\n                          tabIndex={matchedSegment ? 0 : -1}\n                          onClick={() => {\n                            if (!matchedSegment) return;\n                            onPlaySegment(matchedSegment);\n                          }}\n                          onKeyDown={(event) => {\n                            if (!matchedSegment) return;\n                            if (event.key !== \"Enter\" && event.key !== \" \") {\n                              return;\n                            }\n                            event.preventDefault();\n                            onPlaySegment(matchedSegment);\n                          }}\n                        >\n                          <div className=\"flex items-start gap-4\">\n                            <div\n                              className={`inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border text-sm font-bold ${\n                                isVisible\n                                  ? \"border-[#c8d339] bg-[#ffffff] text-[#465100]\"\n                                  : \"border-[var(--color_base_border)] bg-[var(--body-background-faint)] text-[var(--text-color)]\"\n                              }`}\n                            >\n                              {index + 1}\n                            </div>\n                            <div className=\"min-w-0 flex-1\">\n                              <div className=\"flex flex-col gap-1 md:flex-row md:items-baseline md:gap-4\">\n                                <span className=\"shrink-0 text-[1.05rem] font-bold text-[var(--text-color)] md:min-w-[120px] md:text-right\">\n                                  {item.speaker || \"Narrator\"}:\n                                </span>\n                                <span className=\"text-[1.05rem] leading-8 text-[var(--text-color)]\">\n                                  {renderTextWithHighlightedWord(\n                                    item.content.text,\n                                    wordMarks,\n                                    activeWordIndex,\n                                    matchedSegment\n                                      ? (markIndex) => {\n                                          onPlayApproximateWord(\n                                            matchedSegment,\n                                            wordMarks,\n                                            markIndex,\n                                          );\n                                        }\n                                      : undefined,\n                                  )}\n                                </span>\n                              </div>\n                              <div className=\"mt-2 flex flex-wrap items-center gap-2 text-xs text-[var(--text-color-dim)]\">\n                                <span>Story line {item.lineIndex}</span>\n                                {matchedSegment ? (\n                                  <span>\n                                    {formatSeconds(matchedSegment.start)} to{\" \"}\n                                    {formatSeconds(matchedSegment.end)}\n                                  </span>\n                                ) : (\n                                  <span>No matching segment yet</span>\n                                )}\n                                {matchedSegment && skippedDuration > 0 ? (\n                                  <span>\n                                    trims {formatSeconds(skippedDuration)}{\" \"}\n                                    across {matchedSegment.skipRanges.length}{\" \"}\n                                    pause\n                                    {matchedSegment.skipRanges.length === 1\n                                      ? \"\"\n                                      : \"s\"}\n                                  </span>\n                                ) : null}\n                              </div>\n                            </div>\n                            <div className=\"shrink-0 text-right font-mono text-sm font-bold text-[var(--text-color)]\">\n                              #{index + 1}\n                            </div>\n                          </div>\n                        </div>\n                        {matchedSegment && wordMarks.length > 0 ? (\n                          <>\n                            <div className=\"mt-3 flex items-center justify-between gap-3 text-[11px] text-[var(--text-color-dim)]\">\n                              <span>Word timing</span>\n                              <span>\n                                Drag markers or click a word above to play it.\n                              </span>\n                            </div>\n                            <div\n                              ref={(node) => {\n                                wordTimelineRefs.current[matchedSegment.id] =\n                                  node;\n                              }}\n                              className=\"relative mt-2 h-9 rounded-full border border-[var(--color_base_border)] bg-[var(--body-background)]\"\n                            >\n                              {matchedSegment.skipRanges.map(\n                                (skipRange, skipIndex) => {\n                                  const leftPercent =\n                                    ((skipRange.start - matchedSegment.start) /\n                                      segmentDuration) *\n                                    100;\n                                  const widthPercent =\n                                    ((skipRange.end - skipRange.start) /\n                                      segmentDuration) *\n                                    100;\n\n                                  return (\n                                    <div\n                                      key={`${matchedSegment.id}-skip-${skipIndex}`}\n                                      className=\"pointer-events-none absolute top-0 bottom-0 rounded-full border-x border-dashed border-[rgba(15,95,131,0.65)] bg-[repeating-linear-gradient(135deg,rgba(255,255,255,0.08)_0_6px,rgba(15,95,131,0.18)_6px_12px)]\"\n                                      style={{\n                                        left: `${leftPercent}%`,\n                                        width: `${widthPercent}%`,\n                                      }}\n                                    />\n                                  );\n                                },\n                              )}\n                              {wordMarks.map((mark, markIndex) => {\n                                const leftPercent =\n                                  ((mark.time / 1000 - matchedSegment.start) /\n                                    segmentDuration) *\n                                  100;\n\n                                return (\n                                  <button\n                                    key={`${matchedSegment.id}-marker-${mark.start}-${mark.time}`}\n                                    type=\"button\"\n                                    className=\"absolute top-1/2 h-full w-4 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize touch-none\"\n                                    style={{\n                                      left: `${leftPercent}%`,\n                                    }}\n                                    onPointerDown={(event) => {\n                                      onStartWordMarkerDrag(\n                                        event,\n                                        matchedSegment.id,\n                                        markIndex,\n                                      );\n                                    }}\n                                    title={`Drag timing marker for \"${mark.value}\"`}\n                                  >\n                                    <span\n                                      className={`absolute top-[15%] bottom-[15%] left-1/2 -translate-x-1/2 rounded-full ${\n                                        markIndex === activeWordIndex\n                                          ? \"w-1 bg-[#d7e34f] shadow-[0_0_0_1px_rgba(70,81,0,0.28)]\"\n                                          : \"w-px bg-[rgba(15,95,131,0.45)] shadow-[0_0_0_1px_rgba(255,255,255,0.18)]\"\n                                      }`}\n                                    />\n                                  </button>\n                                );\n                              })}\n                            </div>\n                          </>\n                        ) : null}\n                      </div>\n                    );\n                  })}\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-between gap-3 border-t border-[var(--color_base_border)] px-5 py-3\">\n        <div className=\"text-sm text-[var(--text-color-dim)]\">\n          {footerStatusText ??\n            (segmentCountMismatch\n              ? \"Adjust the cuts until the segment count matches the number of bulk audio rows.\"\n              : \"Stage the generated clips into the bulk editor in top-to-bottom order.\")}\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <button\n            type=\"button\"\n            className=\"inline-flex h-9 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n            onClick={() => onOpenChange(false)}\n          >\n            Close\n          </button>\n          <button\n            type=\"button\"\n            className=\"inline-flex h-9 items-center justify-center rounded-md border border-[#0f5f83] bg-[#1cb0f6] px-3 text-sm font-semibold leading-none text-white transition-colors hover:bg-[#1598d7] disabled:cursor-default disabled:opacity-70\"\n            onClick={onUseSegmentCuts}\n            disabled={\n              !audioBuffer ||\n              segments.length === 0 ||\n              segmentCountMismatch ||\n              isExportingSegments\n            }\n          >\n            {isExportingSegments\n              ? (primaryActionPendingLabel ?? \"Preparing MP3 clips...\")\n              : primaryActionLabel}\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n\n  if (!renderInDialog) {\n    return (\n      <div className=\"h-[100dvh] overflow-hidden bg-[var(--body-background)]\">\n        <div className=\"mx-auto flex h-full w-full max-w-[1800px] flex-col\">\n          {content}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent\n        showCloseButton={true}\n        className=\"inset-3 h-[calc(100vh-1.5rem)] w-[calc(100vw-1.5rem)] max-w-none translate-x-0 translate-y-0 overflow-hidden p-0 sm:top-3 sm:left-3 sm:max-w-none sm:translate-x-0 sm:translate-y-0\"\n      >\n        {content}\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/story/[story]/audio-cutter-storage.ts",
    "content": "\"use client\";\n\nimport type { BulkAudioEditorItem } from \"@/app/editor/story/[story]/bulk-audio-editor\";\nimport type { Audio } from \"@/components/editor/story/syntax_parser_types\";\n\nexport type AudioCutterTranscriptItem = Pick<\n  BulkAudioEditorItem,\n  | \"id\"\n  | \"order\"\n  | \"lineIndex\"\n  | \"type\"\n  | \"speaker\"\n  | \"content\"\n  | \"existingFilename\"\n  | \"existingKeypoints\"\n  | \"ssml\"\n>;\n\nexport type AudioCutterPreparedSegment = {\n  file: File;\n  itemId: string;\n  lineIndex: number;\n  ssml: Audio[\"ssml\"];\n  keypoints: { rangeEnd: number; audioStart: number }[];\n};\n\ntype StoredAudioCutterTranscript = {\n  items: AudioCutterTranscriptItem[];\n  updatedAt: number;\n};\n\ntype StoredAudioCutterOutput = {\n  files: {\n    name: string;\n    type: string;\n    blobKey: string;\n  }[];\n  updatedAt: number;\n};\n\nconst AUDIO_CUTTER_DB_NAME = \"audio-cutter\";\nconst AUDIO_CUTTER_OUTPUT_STORE = \"output-files\";\n\nfunction getTranscriptStorageKey(storyId: number) {\n  return `audio-cutter:transcript:${storyId}`;\n}\n\nexport function getOutputStorageKey(storyId: number) {\n  return `audio-cutter:output:${storyId}`;\n}\n\nfunction parseStoredAudioCutterOutput(raw: string | null) {\n  if (!raw) return null;\n\n  try {\n    return JSON.parse(raw) as StoredAudioCutterOutput;\n  } catch {\n    return null;\n  }\n}\n\nfunction getIndexedDb() {\n  if (typeof window === \"undefined\" || !window.indexedDB) {\n    throw new Error(\"IndexedDB is not available in this browser.\");\n  }\n\n  return window.indexedDB;\n}\n\nfunction openAudioCutterDb() {\n  return new Promise<IDBDatabase>((resolve, reject) => {\n    const request = getIndexedDb().open(AUDIO_CUTTER_DB_NAME, 1);\n\n    request.onupgradeneeded = () => {\n      const database = request.result;\n      if (!database.objectStoreNames.contains(AUDIO_CUTTER_OUTPUT_STORE)) {\n        database.createObjectStore(AUDIO_CUTTER_OUTPUT_STORE);\n      }\n    };\n    request.onsuccess = () => {\n      resolve(request.result);\n    };\n    request.onerror = () => {\n      reject(\n        request.error ?? new Error(\"Could not open the audio cutter database.\"),\n      );\n    };\n  });\n}\n\nfunction requestToPromise<T>(request: IDBRequest<T>) {\n  return new Promise<T>((resolve, reject) => {\n    request.onsuccess = () => {\n      resolve(request.result);\n    };\n    request.onerror = () => {\n      reject(request.error ?? new Error(\"IndexedDB request failed.\"));\n    };\n  });\n}\n\nfunction transactionToPromise(transaction: IDBTransaction) {\n  return new Promise<void>((resolve, reject) => {\n    transaction.oncomplete = () => {\n      resolve();\n    };\n    transaction.onabort = () => {\n      reject(transaction.error ?? new Error(\"IndexedDB transaction aborted.\"));\n    };\n    transaction.onerror = () => {\n      reject(transaction.error ?? new Error(\"IndexedDB transaction failed.\"));\n    };\n  });\n}\n\nasync function deleteStoredOutputBlobs(blobKeys: string[]) {\n  if (blobKeys.length === 0) return;\n\n  const database = await openAudioCutterDb();\n\n  try {\n    const transaction = database.transaction(\n      AUDIO_CUTTER_OUTPUT_STORE,\n      \"readwrite\",\n    );\n    const store = transaction.objectStore(AUDIO_CUTTER_OUTPUT_STORE);\n\n    for (const blobKey of blobKeys) {\n      store.delete(blobKey);\n    }\n\n    await transactionToPromise(transaction);\n  } finally {\n    database.close();\n  }\n}\n\nexport function storeAudioCutterTranscript(\n  storyId: number,\n  items: AudioCutterTranscriptItem[],\n) {\n  if (typeof window === \"undefined\") return;\n\n  const payload: StoredAudioCutterTranscript = {\n    items,\n    updatedAt: Date.now(),\n  };\n\n  window.localStorage.setItem(\n    getTranscriptStorageKey(storyId),\n    JSON.stringify(payload),\n  );\n}\n\nexport function loadAudioCutterTranscript(storyId: number) {\n  if (typeof window === \"undefined\") return [] as AudioCutterTranscriptItem[];\n\n  const raw = window.localStorage.getItem(getTranscriptStorageKey(storyId));\n  if (!raw) return [] as AudioCutterTranscriptItem[];\n\n  try {\n    const payload = JSON.parse(raw) as StoredAudioCutterTranscript;\n    return Array.isArray(payload.items) ? payload.items : [];\n  } catch {\n    return [];\n  }\n}\n\nexport async function storeAudioCutterOutput(storyId: number, files: File[]) {\n  if (typeof window === \"undefined\") return;\n\n  const storageKey = getOutputStorageKey(storyId);\n  const previousPayload = parseStoredAudioCutterOutput(\n    window.localStorage.getItem(storageKey),\n  );\n  const nextFiles = files.map((file, index) => ({\n    name: file.name,\n    type: file.type,\n    blobKey: `${storageKey}:${index}:${Date.now()}:${globalThis.crypto.randomUUID()}`,\n  }));\n  const database = await openAudioCutterDb();\n\n  try {\n    const transaction = database.transaction(\n      AUDIO_CUTTER_OUTPUT_STORE,\n      \"readwrite\",\n    );\n    const store = transaction.objectStore(AUDIO_CUTTER_OUTPUT_STORE);\n\n    for (const [index, file] of files.entries()) {\n      store.put(file, nextFiles[index]?.blobKey);\n    }\n\n    await transactionToPromise(transaction);\n  } finally {\n    database.close();\n  }\n\n  const payload: StoredAudioCutterOutput = {\n    files: nextFiles,\n    updatedAt: Date.now(),\n  };\n\n  try {\n    window.localStorage.setItem(storageKey, JSON.stringify(payload));\n  } catch (error) {\n    await deleteStoredOutputBlobs(nextFiles.map((file) => file.blobKey));\n    throw error;\n  }\n\n  await deleteStoredOutputBlobs(\n    (previousPayload?.files ?? []).map((file) => file.blobKey),\n  );\n}\n\nexport async function consumeAudioCutterOutput(storyId: number) {\n  if (typeof window === \"undefined\") return [] as File[];\n\n  const key = getOutputStorageKey(storyId);\n  const raw = window.localStorage.getItem(key);\n  if (!raw) return [] as File[];\n\n  try {\n    const payload = JSON.parse(raw) as StoredAudioCutterOutput;\n    if (!Array.isArray(payload.files)) return [] as File[];\n\n    const database = await openAudioCutterDb();\n    let files: File[];\n\n    try {\n      const transaction = database.transaction(\n        AUDIO_CUTTER_OUTPUT_STORE,\n        \"readonly\",\n      );\n      const store = transaction.objectStore(AUDIO_CUTTER_OUTPUT_STORE);\n\n      files = await Promise.all(\n        payload.files.map(async (file) => {\n          const blob = await requestToPromise(store.get(file.blobKey));\n          if (!(blob instanceof Blob)) {\n            throw new Error(`Missing staged audio blob for ${file.name}.`);\n          }\n\n          return new File([blob], file.name, {\n            type: file.type || blob.type || \"audio/mpeg\",\n          });\n        }),\n      );\n\n      await transactionToPromise(transaction);\n    } finally {\n      database.close();\n    }\n\n    window.localStorage.removeItem(key);\n    void deleteStoredOutputBlobs(payload.files.map((file) => file.blobKey));\n\n    return files;\n  } catch {\n    return [];\n  }\n}\n"
  },
  {
    "path": "src/app/editor/story/[story]/bulk-audio-editor.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport { createPortal } from \"react-dom\";\nimport WaveSurfer from \"wavesurfer.js\";\nimport { useWavesurfer } from \"@wavesurfer/react\";\nimport Regions from \"wavesurfer.js/dist/plugins/regions.js\";\nimport { unzipSync } from \"fflate\";\nimport { Volume2Icon } from \"lucide-react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport PlayAudio from \"@/components/PlayAudio\";\nimport StoryLineHints from \"@/components/StoryLineHints\";\nimport type {\n  Audio,\n  ContentWithHints,\n  HideRange,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport {\n  text_to_keypoints,\n  timings_to_text,\n} from \"@/lib/editor/audio/audio_edit_tools\";\nimport { splitTextTokens } from \"@/lib/editor/tts_transcripte\";\nimport {\n  consumeAudioCutterOutput,\n  getOutputStorageKey,\n  storeAudioCutterTranscript,\n} from \"@/app/editor/story/[story]/audio-cutter-storage\";\n\nconst PUBLIC_BLOB_BASE_URL =\n  \"https://ptoqrnbx8ghuucmt.public.blob.vercel-storage.com/\";\nconst DEFAULT_WAVEFORM_ZOOM = 420;\nconst MIN_WAVEFORM_ZOOM = 40;\nconst MAX_WAVEFORM_ZOOM = 640;\nconst WAVEFORM_ZOOM_STEP = 40;\nconst ZIP_MIME_TYPES = new Set([\n  \"application/zip\",\n  \"application/x-zip-compressed\",\n  \"multipart/x-zip\",\n]);\nconst AUDIO_EXTENSIONS = new Set([\".mp3\", \".wav\", \".m4a\", \".ogg\", \".aac\"]);\n\ntype BulkAudioEditorDraft = {\n  file: File | null;\n  localUrl: string | null;\n  uploadedFilename: string;\n  uploadState: \"idle\" | \"uploading\" | \"uploaded\" | \"failed\";\n  error: string | null;\n  matchSource: \"existing\" | \"filename\" | \"order\" | \"manual\" | null;\n  timingText: string;\n};\n\nexport type BulkAudioEditorItem = {\n  id: string;\n  order: number;\n  lineIndex: number;\n  type: \"HEADER\" | \"LINE\";\n  speaker: string;\n  content: ContentWithHints;\n  hideRangesForChallenge?: HideRange[];\n  existingFilename: string;\n  existingKeypoints: { rangeEnd: number; audioStart: number }[];\n  ssml: Audio[\"ssml\"];\n};\n\nexport type BulkAudioEditorUpdate = {\n  itemId: string;\n  filename: string;\n  keypoints: { rangeEnd: number; audioStart: number }[];\n  serializedText: string;\n  ssml: Audio[\"ssml\"];\n};\n\ninterface Region {\n  start: number;\n  element?: HTMLElement | null;\n  content?: HTMLElement;\n  innerText?: string;\n}\n\ninterface RegionsPlugin {\n  regions: Array<\n    Region & {\n      setOptions: (options: { start?: number; end?: number }) => void;\n    }\n  >;\n  addRegion: (options: {\n    start: number;\n    content: string;\n    color: string;\n  }) => void;\n  on: (event: string, callback: (region: Region) => void) => void;\n  un: (event: string, callback: (region: Region) => void) => void;\n}\n\ninterface Part {\n  text: string;\n  pos: number;\n}\n\nfunction cumulativeSums(values: number[]): number[] {\n  let total = 0;\n  const sums: number[] = [];\n  values.forEach((v) => {\n    total += v;\n    sums.push(total);\n  });\n  return sums;\n}\n\nfunction stripAudioPathPrefix(filename: string) {\n  if (!filename) return \"\";\n  if (filename.startsWith(\"audio/\")) {\n    return filename.slice(\"audio/\".length);\n  }\n  return filename;\n}\n\nfunction getPublicAudioUrl(filename: string) {\n  if (!filename) return \"\";\n  if (filename.startsWith(\"blob:\") || filename.startsWith(\"http\")) {\n    return filename;\n  }\n  return `${PUBLIC_BLOB_BASE_URL}${filename.startsWith(\"audio/\") ? filename : `audio/${filename}`}`;\n}\n\nfunction isZipFile(file: File) {\n  return (\n    ZIP_MIME_TYPES.has(file.type) || file.name.toLowerCase().endsWith(\".zip\")\n  );\n}\n\nfunction getFileExtension(filename: string) {\n  const dotIndex = filename.lastIndexOf(\".\");\n  return dotIndex === -1 ? \"\" : filename.slice(dotIndex).toLowerCase();\n}\n\nfunction getAudioMimeType(filename: string) {\n  const extension = getFileExtension(filename);\n  if (extension === \".mp3\") return \"audio/mpeg\";\n  if (extension === \".wav\") return \"audio/wav\";\n  if (extension === \".m4a\") return \"audio/mp4\";\n  if (extension === \".ogg\") return \"audio/ogg\";\n  if (extension === \".aac\") return \"audio/aac\";\n  return \"\";\n}\n\nfunction isAudioFilename(filename: string) {\n  return AUDIO_EXTENSIONS.has(getFileExtension(filename));\n}\n\nfunction timingTextFromKeypoints(\n  keypoints: { rangeEnd: number; audioStart: number }[],\n) {\n  let text = \"\";\n  let lastEnd = 0;\n  let lastTime = 0;\n  for (const point of keypoints) {\n    text += `;${Math.round(point.rangeEnd - lastEnd)},${Math.round(point.audioStart - lastTime)}`;\n    lastEnd = point.rangeEnd;\n    lastTime = point.audioStart;\n  }\n  return text;\n}\n\nfunction createDraft(item: BulkAudioEditorItem): BulkAudioEditorDraft {\n  return {\n    file: null,\n    localUrl: null,\n    uploadedFilename: \"\",\n    uploadState: \"idle\",\n    error: null,\n    matchSource: item.existingFilename ? \"existing\" : null,\n    timingText: timingTextFromKeypoints(item.existingKeypoints),\n  };\n}\n\nfunction createDraftMap(items: BulkAudioEditorItem[]) {\n  return Object.fromEntries(items.map((item) => [item.id, createDraft(item)]));\n}\n\nfunction revokeDraftUrls(drafts: Record<string, BulkAudioEditorDraft>) {\n  for (const draft of Object.values(drafts)) {\n    if (draft.localUrl) URL.revokeObjectURL(draft.localUrl);\n  }\n}\n\nfunction getLeadingNumber(filename: string) {\n  const match = filename.match(/^(\\d{1,4})(?:\\D|$)/);\n  return match ? Number(match[1]) : null;\n}\n\nconst naturalSort = new Intl.Collator(undefined, {\n  numeric: true,\n  sensitivity: \"base\",\n});\n\nfunction isChanged(item: BulkAudioEditorItem, draft: BulkAudioEditorDraft) {\n  const filename = draft.uploadedFilename || item.existingFilename;\n  if (draft.file) return true;\n  if (!filename) return false;\n  const initialText = timings_to_text({\n    filename: item.existingFilename,\n    keypoints: item.existingKeypoints,\n  });\n  return `$${filename}${draft.timingText}` !== initialText;\n}\n\nasync function uploadAudioFile(file: File, storyId: number) {\n  const data = new FormData();\n  data.set(\"file\", file);\n  data.set(\"story_id\", String(storyId));\n\n  const response = await fetch(\"/audio/upload\", {\n    method: \"POST\",\n    body: data,\n  });\n\n  if (!response.ok) {\n    throw new Error(await response.text());\n  }\n\n  const payload = (await response.json()) as {\n    success?: boolean;\n    filename?: string;\n  };\n\n  if (!payload.success || !payload.filename) {\n    throw new Error(\"Upload failed.\");\n  }\n\n  return payload.filename;\n}\n\nasync function expandUploadFiles(files: File[]) {\n  const expandedFiles: File[] = [];\n\n  for (const file of files) {\n    if (isZipFile(file)) {\n      const archive = unzipSync(new Uint8Array(await file.arrayBuffer()));\n      for (const [archivePath, content] of Object.entries(archive)) {\n        const name = archivePath.split(\"/\").pop() ?? archivePath;\n        if (!name || !isAudioFilename(name)) continue;\n        const fileBytes = new Uint8Array(content);\n        expandedFiles.push(\n          new File([fileBytes], name, {\n            type: getAudioMimeType(name),\n          }),\n        );\n      }\n      continue;\n    }\n\n    if (file.type.startsWith(\"audio/\") || isAudioFilename(file.name)) {\n      expandedFiles.push(file);\n    }\n  }\n\n  return expandedFiles;\n}\nfunction rowLabel(item: BulkAudioEditorItem) {\n  return item.type === \"HEADER\" ? \"Header\" : `Line ${item.order}`;\n}\n\nfunction statusLabel(item: BulkAudioEditorItem, draft: BulkAudioEditorDraft) {\n  if (draft.uploadState === \"failed\") return \"Upload failed\";\n  if (draft.file && !draft.uploadedFilename) return \"Staged\";\n  if (draft.uploadedFilename || item.existingFilename) return \"Ready\";\n  return \"Missing audio\";\n}\n\nfunction getParts(text: string) {\n  const parts = splitTextTokens(text);\n  const words: Part[] = [];\n  if (parts[parts.length - 1] === \"\") parts.pop();\n  let currentPos = 0;\n  for (let index = 0; index < parts.length; index += 1) {\n    const part = parts[index] ?? \"\";\n    if (index % 2 === 0) {\n      words.push({ text: part, pos: currentPos });\n    }\n    currentPos += part.length;\n  }\n  return words;\n}\n\nfunction getAutoRegionStarts(parts: Part[], duration: number) {\n  if (parts.length === 0 || duration <= 0) return [];\n\n  const startPadding = Math.min(0.08, duration * 0.05);\n  const endPadding = Math.min(0.14, duration * 0.08);\n  const usableDuration = Math.max(0.05, duration - startPadding - endPadding);\n  const weights = parts.map((part) => {\n    const trimmed = part.text.trim();\n    const charCount = Math.max(1, trimmed.length);\n    return Math.max(1, Math.round(Math.sqrt(charCount) * 10));\n  });\n  const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) || 1;\n\n  let elapsed = startPadding;\n  return parts.map((_, index) => {\n    const currentStart = elapsed;\n    const sliceDuration = usableDuration * (weights[index]! / totalWeight);\n    elapsed += sliceDuration;\n    return currentStart;\n  });\n}\n\nfunction timingTextFromStarts(parts: Part[], starts: number[]) {\n  let text = \"\";\n  let previousPos = 0;\n  let previousStart = 0;\n\n  for (let index = 0; index < parts.length; index += 1) {\n    const part = parts[index];\n    if (!part) continue;\n    const nextPos = part.pos + part.text.length;\n    const nextStart = starts[index] ?? 0;\n    text += `;${nextPos - previousPos},${Math.round(nextStart * 1000 - previousStart)}`;\n    previousPos = nextPos;\n    previousStart = Math.round(nextStart * 1000);\n  }\n\n  return text;\n}\n\nfunction getWordPlaybackSegments(\n  parts: Part[],\n  timingText: string,\n  durationMs: number,\n) {\n  const startsMs = timingText\n    ? cumulativeSums(\n        [...timingText.matchAll(/(\\d*),(\\d*)/g)].map((match) =>\n          Number.parseInt(match[2] ?? \"0\", 10),\n        ),\n      )\n    : [];\n\n  return parts.map((part, index) => {\n    const startMs = startsMs[index] ?? 0;\n    const nextStartMs = startsMs[index + 1] ?? durationMs;\n    const safeEndMs = Math.max(startMs + 60, nextStartMs);\n    return {\n      text: part.text,\n      startMs,\n      endMs: safeEndMs,\n    };\n  });\n}\n\nfunction BulkAudioRow({\n  item,\n  draft,\n  onChange,\n  autoAllRequest,\n}: {\n  item: BulkAudioEditorItem;\n  draft: BulkAudioEditorDraft;\n  onChange: (\n    updater: (draft: BulkAudioEditorDraft) => BulkAudioEditorDraft,\n  ) => void;\n  autoAllRequest: number;\n}) {\n  const fileInputId = React.useId();\n  const containerRef = React.useRef<HTMLDivElement | null>(null);\n  const scrollContainerRef = React.useRef<HTMLDivElement | null>(null);\n  const parts = React.useMemo(\n    () => getParts(item.content.text),\n    [item.content.text],\n  );\n  const [audioRange, setAudioRange] = React.useState(99999);\n  const [zoomPxPerSec, setZoomPxPerSec] = React.useState(DEFAULT_WAVEFORM_ZOOM);\n  const [waveformReady, setWaveformReady] = React.useState(false);\n  const [audioDurationMs, setAudioDurationMs] = React.useState(0);\n  const [wordPlaybackHost, setWordPlaybackHost] =\n    React.useState<HTMLDivElement | null>(null);\n  const lastHandledAutoAllRequestRef = React.useRef(0);\n  const audioUrl = React.useMemo(() => {\n    if (draft.localUrl) return draft.localUrl;\n    const filename = draft.uploadedFilename || item.existingFilename;\n    return filename ? getPublicAudioUrl(filename) : \"\";\n  }, [draft.localUrl, draft.uploadedFilename, item.existingFilename]);\n\n  const onDecode = React.useCallback(\n    (wavesurfer: WaveSurfer, duration: number) => {\n      const timings = draft.timingText\n        ? cumulativeSums(\n            [...draft.timingText.matchAll(/(\\d*),(\\d*)/g)].map((match) => {\n              return Number.parseInt(match[2] ?? \"0\", 10) / 1000;\n            }),\n          )\n        : [];\n\n      const plugin = (wavesurfer as unknown as { plugins: unknown[] })\n        .plugins[0] as RegionsPlugin;\n\n      for (let index = 0; index < parts.length; index += 1) {\n        if (!plugin.regions[index]) {\n          plugin.addRegion({\n            start:\n              timings[index] ||\n              duration * (parts[index]!.pos / item.content.text.length),\n            content: parts[index]!.text,\n            color: \"#e06c75\",\n          });\n        }\n\n        plugin.regions[index]!.innerText = parts[index]!.text;\n        plugin.regions[index]!.setOptions({\n          start:\n            timings[index] ||\n            duration * (parts[index]!.pos / item.content.text.length),\n        });\n      }\n    },\n    [draft.timingText, item.content.text.length, parts],\n  );\n\n  const onTimeUpdate = React.useCallback(\n    (wavesurfer: WaveSurfer, currentTime: number) => {\n      const plugin = (wavesurfer as unknown as { plugins: unknown[] })\n        .plugins[0] as RegionsPlugin;\n      const regions = plugin.regions.sort(\n        (left, right) => left.start - right.start,\n      );\n      let pos = 0;\n      for (let index = 0; index < regions.length; index += 1) {\n        if (regions[index]!.start < currentTime && parts[index] !== undefined) {\n          pos = parts[index]!.pos;\n        }\n      }\n      if (pos !== audioRange) {\n        setAudioRange(pos);\n      }\n    },\n    [audioRange, parts],\n  );\n\n  const onRegionUpdated = React.useCallback(\n    (plugin: RegionsPlugin) => {\n      const regions = plugin.regions.sort(\n        (left, right) => left.start - right.start,\n      );\n      let nextTimingText = \"\";\n      for (let index = 0; index < regions.length; index += 1) {\n        if (\n          regions[index]!.content !== undefined &&\n          parts[index] !== undefined\n        ) {\n          regions[index]!.content!.innerText = parts[index]!.text;\n        }\n        nextTimingText +=\n          \";\" +\n          (parts[index]!.text.length +\n            parts[index]!.pos -\n            (parts[index - 1]?.text.length + parts[index - 1]?.pos || 0)) +\n          \",\" +\n          (Math.floor(regions[index]!.start * 1000) -\n            Math.floor(regions[index - 1]?.start * 1000 || 0));\n      }\n\n      onChange((currentDraft) => ({\n        ...currentDraft,\n        timingText: nextTimingText,\n      }));\n    },\n    [onChange, parts],\n  );\n\n  const { wavesurfer } = useWavesurfer({\n    container: containerRef,\n    height: 60,\n    waveColor: \"#1cb0f6\",\n    progressColor: \"rgba(28,176,246,0.62)\",\n    cursorColor: \"#0f5f83\",\n    normalize: true,\n    barWidth: 4,\n    barGap: 3,\n    barRadius: 30,\n    minPxPerSec: DEFAULT_WAVEFORM_ZOOM,\n    fillParent: false,\n    autoScroll: false,\n    hideScrollbar: false,\n    url: audioUrl || undefined,\n    plugins: React.useMemo(() => [Regions.create()], []),\n  });\n\n  React.useEffect(() => {\n    if (!audioUrl) {\n      setZoomPxPerSec(DEFAULT_WAVEFORM_ZOOM);\n      setWaveformReady(false);\n      setAudioDurationMs(0);\n    }\n  }, [audioUrl]);\n\n  React.useEffect(() => {\n    if (!wavesurfer) return;\n    wavesurfer.setOptions({\n      minPxPerSec: zoomPxPerSec,\n      fillParent: false,\n      autoScroll: false,\n      hideScrollbar: false,\n    });\n  }, [wavesurfer, zoomPxPerSec]);\n\n  React.useEffect(() => {\n    if (!wavesurfer) return;\n    setWaveformReady(false);\n\n    const onDecodeEvent = (duration: number) => {\n      setWaveformReady(true);\n      setAudioDurationMs(Math.round(duration * 1000));\n      onDecode(wavesurfer, duration);\n    };\n    const onTimeUpdateEvent = (currentTime: number) =>\n      onTimeUpdate(wavesurfer, currentTime);\n    const onReadyEvent = () => {\n      setWaveformReady(true);\n      setAudioDurationMs(Math.round(wavesurfer.getDuration() * 1000));\n    };\n\n    wavesurfer.on(\"decode\", onDecodeEvent);\n    wavesurfer.on(\"timeupdate\", onTimeUpdateEvent);\n    wavesurfer.on(\"ready\", onReadyEvent);\n\n    const plugin = (wavesurfer as unknown as { plugins: RegionsPlugin[] })\n      .plugins[0];\n    const onRegionUpdatedEvent = () => {\n      onRegionUpdated(plugin);\n    };\n    plugin?.on(\"region-updated\", onRegionUpdatedEvent);\n\n    return () => {\n      wavesurfer.un(\"decode\", onDecodeEvent);\n      wavesurfer.un(\"timeupdate\", onTimeUpdateEvent);\n      wavesurfer.un(\"ready\", onReadyEvent);\n      plugin?.un(\"region-updated\", onRegionUpdatedEvent);\n    };\n  }, [onDecode, onRegionUpdated, onTimeUpdate, wavesurfer]);\n\n  React.useEffect(() => {\n    if (!wavesurfer) {\n      setWordPlaybackHost(null);\n      return;\n    }\n\n    const wrapper = (\n      wavesurfer as unknown as {\n        getWrapper?: () => HTMLElement;\n      }\n    ).getWrapper?.();\n\n    if (!wrapper) {\n      setWordPlaybackHost(null);\n      return;\n    }\n\n    const host = document.createElement(\"div\");\n    host.style.position = \"absolute\";\n    host.style.left = \"0\";\n    host.style.top = \"68px\";\n    host.style.width = \"100%\";\n    host.style.height = \"28px\";\n    host.style.pointerEvents = \"none\";\n    host.style.zIndex = \"6\";\n    wrapper.appendChild(host);\n    setWordPlaybackHost(host);\n\n    return () => {\n      if (wrapper.contains(host)) {\n        wrapper.removeChild(host);\n      }\n      wrapper.style.paddingBottom = \"\";\n      setWordPlaybackHost(null);\n    };\n  }, [wavesurfer]);\n\n  const wordPlaybackSegments = React.useMemo(\n    () => getWordPlaybackSegments(parts, draft.timingText, audioDurationMs),\n    [audioDurationMs, draft.timingText, parts],\n  );\n\n  React.useEffect(() => {\n    const wrapper = wavesurfer\n      ? (\n          wavesurfer as unknown as {\n            getWrapper?: () => HTMLElement;\n          }\n        ).getWrapper?.()\n      : undefined;\n    if (!wrapper) return;\n\n    const hasWordPlayback =\n      audioDurationMs > 0 && wordPlaybackSegments.length > 0;\n    wrapper.style.paddingBottom = hasWordPlayback ? \"36px\" : \"\";\n    if (wordPlaybackHost) {\n      wordPlaybackHost.style.display = hasWordPlayback ? \"block\" : \"none\";\n    }\n  }, [\n    audioDurationMs,\n    wavesurfer,\n    wordPlaybackHost,\n    wordPlaybackSegments.length,\n  ]);\n\n  const onPlayPause = React.useCallback(() => {\n    wavesurfer?.playPause();\n  }, [wavesurfer]);\n\n  const onZoomIn = React.useCallback(() => {\n    setZoomPxPerSec((current) =>\n      Math.min(MAX_WAVEFORM_ZOOM, current + WAVEFORM_ZOOM_STEP),\n    );\n  }, []);\n\n  const onZoomOut = React.useCallback(() => {\n    setZoomPxPerSec((current) =>\n      Math.max(MIN_WAVEFORM_ZOOM, current - WAVEFORM_ZOOM_STEP),\n    );\n  }, []);\n\n  const onZoomFit = React.useCallback(() => {\n    if (!wavesurfer || !waveformReady || !scrollContainerRef.current) return;\n    const duration = wavesurfer.getDuration();\n    if (!duration || !Number.isFinite(duration)) return;\n    const fittedZoom = Math.max(\n      MIN_WAVEFORM_ZOOM,\n      Math.round(scrollContainerRef.current.clientWidth / duration),\n    );\n    setZoomPxPerSec(fittedZoom);\n  }, [waveformReady, wavesurfer]);\n\n  const onAutoTiming = React.useCallback(() => {\n    if (!wavesurfer || parts.length === 0) return;\n    const duration = wavesurfer.getDuration();\n    if (!duration || !Number.isFinite(duration)) return;\n\n    const plugin = (wavesurfer as unknown as { plugins: RegionsPlugin[] })\n      .plugins[0];\n    if (!plugin) return;\n\n    const autoStarts = getAutoRegionStarts(parts, duration);\n    for (let index = 0; index < parts.length; index += 1) {\n      if (!plugin.regions[index]) {\n        plugin.addRegion({\n          start: autoStarts[index] ?? 0,\n          content: parts[index]!.text,\n          color: \"#e06c75\",\n        });\n      } else {\n        plugin.regions[index]!.setOptions({\n          start: autoStarts[index] ?? 0,\n        });\n      }\n    }\n\n    onRegionUpdated(plugin);\n  }, [onRegionUpdated, parts, wavesurfer]);\n\n  React.useEffect(() => {\n    if (autoAllRequest === 0) return;\n    if (lastHandledAutoAllRequestRef.current >= autoAllRequest) return;\n    if (!audioUrl || !waveformReady) return;\n\n    lastHandledAutoAllRequestRef.current = autoAllRequest;\n    onAutoTiming();\n  }, [audioUrl, autoAllRequest, onAutoTiming, waveformReady]);\n\n  const onPlayWord = React.useCallback(\n    (startMs: number, endMs: number) => {\n      if (!wavesurfer) return;\n      if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return;\n      void wavesurfer.play(startMs / 1000, endMs / 1000);\n    },\n    [wavesurfer],\n  );\n\n  const plugin = (wavesurfer as unknown as { plugins?: RegionsPlugin[] } | null)\n    ?.plugins?.[0];\n\n  const onFileChange = React.useCallback(\n    (event: React.ChangeEvent<HTMLInputElement>) => {\n      const file = event.target.files?.[0];\n      if (!file) return;\n      onChange((currentDraft) => {\n        if (currentDraft.localUrl) URL.revokeObjectURL(currentDraft.localUrl);\n        return {\n          ...currentDraft,\n          file,\n          localUrl: URL.createObjectURL(file),\n          uploadedFilename: \"\",\n          uploadState: \"idle\",\n          error: null,\n          matchSource: \"manual\",\n        };\n      });\n      event.target.value = \"\";\n    },\n    [onChange],\n  );\n\n  return (\n    <div className=\"border-b border-[var(--color_base_border)] px-4 py-4\">\n      <div className=\"mb-3 flex items-start justify-between gap-3\">\n        <div className=\"flex min-w-0 items-start gap-4\">\n          <div className=\"min-w-0\">\n            <div className=\"text-sm font-semibold text-[var(--text-color)]\">\n              {rowLabel(item)}\n              {item.speaker ? ` · ${item.speaker}` : \"\"}\n            </div>\n            <div className=\"text-xs text-[var(--text-color-dim)]\">\n              {statusLabel(item, draft)}\n              {draft.matchSource ? ` · ${draft.matchSource}` : \"\"}\n            </div>\n          </div>\n          <div className=\"flex shrink-0 items-center gap-3\">\n            <label\n              htmlFor={fileInputId}\n              className=\"inline-flex h-9 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n            >\n              Upload audio\n            </label>\n            <input\n              id={fileInputId}\n              className=\"sr-only\"\n              type=\"file\"\n              accept=\"audio/*\"\n              onChange={onFileChange}\n            />\n            <PlayAudio onClick={audioUrl ? onPlayPause : undefined} />\n            <div className=\"flex items-center gap-2 text-xs\">\n              <button\n                type=\"button\"\n                className=\"inline-flex h-7 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-2 leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n                onClick={onAutoTiming}\n                disabled={!audioUrl}\n                title={audioUrl ? \"Auto-adjust timing\" : \"Load audio first\"}\n              >\n                Auto\n              </button>\n              <button\n                type=\"button\"\n                className=\"inline-flex h-7 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-2 leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n                onClick={onZoomOut}\n              >\n                -\n              </button>\n              <button\n                type=\"button\"\n                className=\"inline-flex h-7 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-2 leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n                onClick={onZoomIn}\n              >\n                +\n              </button>\n              <button\n                type=\"button\"\n                className=\"inline-flex h-7 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-2 leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n                onClick={onZoomFit}\n              >\n                Fit\n              </button>\n            </div>\n          </div>\n        </div>\n        <div className=\"max-w-[40ch] truncate text-xs text-[var(--text-color-dim)]\">\n          {draft.file?.name ||\n            draft.uploadedFilename ||\n            item.existingFilename ||\n            \"No file selected.\"}\n        </div>\n      </div>\n\n      <div className=\"grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]\">\n        <div className=\"min-w-0\">\n          <div ref={scrollContainerRef}>\n            <div ref={containerRef} />\n            {wordPlaybackHost &&\n            audioDurationMs > 0 &&\n            wordPlaybackSegments.length > 0\n              ? createPortal(\n                  <>\n                    {wordPlaybackSegments.map((segment, index) => {\n                      const regionLeft =\n                        plugin?.regions[index]?.element?.style.left;\n                      const fallbackLeft =\n                        audioDurationMs > 0\n                          ? `${(segment.startMs / audioDurationMs) * 100}%`\n                          : \"0%\";\n\n                      return (\n                        <button\n                          key={`${segment.text}-${segment.startMs}-${index}`}\n                          type=\"button\"\n                          style={{\n                            position: \"absolute\",\n                            top: \"0\",\n                            left: regionLeft || fallbackLeft,\n                            transform: \"translateX(-50%)\",\n                            width: \"24px\",\n                            height: \"24px\",\n                            borderRadius: \"9999px\",\n                            border: \"1px solid var(--color_base_border)\",\n                            background: \"var(--body-background)\",\n                            color: \"var(--text-color-dim)\",\n                            display: \"inline-flex\",\n                            alignItems: \"center\",\n                            justifyContent: \"center\",\n                            cursor: \"pointer\",\n                            pointerEvents: \"auto\",\n                          }}\n                          title={`Play \"${segment.text}\"`}\n                          onClick={(event) => {\n                            event.stopPropagation();\n                            onPlayWord(segment.startMs, segment.endMs);\n                          }}\n                        >\n                          <Volume2Icon className=\"h-3.5 w-3.5\" />\n                        </button>\n                      );\n                    })}\n                  </>,\n                  wordPlaybackHost,\n                )\n              : null}\n          </div>\n          {draft.error ? (\n            <p className=\"mt-2 text-xs text-[#b33b3b]\">{draft.error}</p>\n          ) : null}\n        </div>\n\n        <div className=\"min-w-0 border border-[var(--color_base_border)] bg-[var(--body-background-faint)] p-3\">\n          <div className=\"mb-2 text-xs font-semibold uppercase tracking-[0.08em] text-[var(--text-color-dim)]\">\n            Story preview\n          </div>\n          <StoryLineHints\n            audioRange={audioRange}\n            hideRangesForChallenge={item.hideRangesForChallenge ?? []}\n            content={item.content}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default function BulkAudioEditor({\n  open,\n  onOpenChange,\n  storyId,\n  courseId,\n  items,\n  onApply,\n}: {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  storyId: number;\n  courseId: string;\n  items: BulkAudioEditorItem[];\n  onApply: (updates: BulkAudioEditorUpdate[]) => void;\n}) {\n  const router = useRouter();\n  const fileInputRef = React.useRef<HTMLInputElement>(null);\n  const wasOpenRef = React.useRef(false);\n  const draftsRef = React.useRef<Record<string, BulkAudioEditorDraft>>(\n    createDraftMap(items),\n  );\n  const [drafts, setDrafts] = React.useState<\n    Record<string, BulkAudioEditorDraft>\n  >(() => createDraftMap(items));\n  const [unmatchedFiles, setUnmatchedFiles] = React.useState<string[]>([]);\n  const [applyError, setApplyError] = React.useState<string | null>(null);\n  const [isApplying, setIsApplying] = React.useState(false);\n  const [isPreparingFiles, setIsPreparingFiles] = React.useState(false);\n  const [autoAllRequest, setAutoAllRequest] = React.useState(0);\n\n  React.useEffect(() => {\n    draftsRef.current = drafts;\n  }, [drafts]);\n\n  React.useEffect(() => {\n    return () => revokeDraftUrls(draftsRef.current);\n  }, []);\n\n  React.useEffect(() => {\n    const wasOpen = wasOpenRef.current;\n    wasOpenRef.current = open;\n    if (wasOpen || !open) return;\n\n    revokeDraftUrls(draftsRef.current);\n    const nextDrafts = createDraftMap(items);\n    draftsRef.current = nextDrafts;\n    setDrafts(nextDrafts);\n    setUnmatchedFiles([]);\n    setApplyError(null);\n    setAutoAllRequest(0);\n    setIsPreparingFiles(false);\n  }, [items, open]);\n\n  const updateDraft = React.useCallback(\n    (\n      itemId: string,\n      updater: (draft: BulkAudioEditorDraft) => BulkAudioEditorDraft,\n    ) => {\n      setDrafts((current) => {\n        const target = current[itemId];\n        if (!target) return current;\n        return {\n          ...current,\n          [itemId]: updater(target),\n        };\n      });\n    },\n    [],\n  );\n\n  const applyMatchedFiles = React.useCallback(\n    (files: File[]) => {\n      if (files.length === 0) return;\n\n      const sortedFiles = [...files].sort((left, right) =>\n        naturalSort.compare(left.name, right.name),\n      );\n      const unmatched = new Set<string>();\n      setDrafts((current) => {\n        const next = { ...current };\n        const sortedItems = [...items].sort((left, right) => {\n          if (left.order !== right.order) return left.order - right.order;\n          return left.lineIndex - right.lineIndex;\n        });\n\n        for (const [index, file] of sortedFiles.entries()) {\n          const item = sortedItems[index];\n          if (!item) {\n            unmatched.add(file.name);\n            continue;\n          }\n\n          const previous = next[item.id];\n          if (!previous) continue;\n          if (previous.localUrl) URL.revokeObjectURL(previous.localUrl);\n\n          next[item.id] = {\n            ...previous,\n            file,\n            localUrl: URL.createObjectURL(file),\n            uploadedFilename: \"\",\n            uploadState: \"idle\",\n            error: null,\n            matchSource: \"order\",\n          };\n        }\n\n        return next;\n      });\n\n      setUnmatchedFiles([...unmatched]);\n    },\n    [items],\n  );\n\n  const importStoredCuts = React.useCallback(async () => {\n    const files = await consumeAudioCutterOutput(storyId);\n    if (files.length === 0) return;\n\n    setIsPreparingFiles(true);\n    try {\n      applyMatchedFiles(files);\n      setApplyError(null);\n    } finally {\n      setIsPreparingFiles(false);\n    }\n  }, [applyMatchedFiles, storyId]);\n\n  React.useEffect(() => {\n    if (!open) return;\n\n    void importStoredCuts();\n\n    const outputStorageKey = getOutputStorageKey(storyId);\n    const onStorage = (event: StorageEvent) => {\n      if (event.key !== outputStorageKey || !event.newValue) return;\n      void importStoredCuts();\n    };\n    const onFocus = () => {\n      void importStoredCuts();\n    };\n\n    window.addEventListener(\"storage\", onStorage);\n    window.addEventListener(\"focus\", onFocus);\n\n    return () => {\n      window.removeEventListener(\"storage\", onStorage);\n      window.removeEventListener(\"focus\", onFocus);\n    };\n  }, [importStoredCuts, open, storyId]);\n\n  const openAudioCutterPage = React.useCallback(() => {\n    storeAudioCutterTranscript(storyId, items);\n    router.push(`/editor/course/${courseId}/story/${storyId}/audio-cutter`);\n  }, [courseId, items, router, storyId]);\n\n  const onBulkFileChange = React.useCallback(\n    async (event: React.ChangeEvent<HTMLInputElement>) => {\n      const files = Array.from(event.target.files ?? []);\n      event.target.value = \"\";\n      setIsPreparingFiles(true);\n      try {\n        const expandedFiles = await expandUploadFiles(files);\n        if (expandedFiles.length === 0 && files.length > 0) {\n          setUnmatchedFiles([]);\n          setApplyError(\"No audio files were found in the selected upload.\");\n        } else if (expandedFiles.length !== items.length) {\n          setUnmatchedFiles([]);\n          setApplyError(\n            `Upload has ${expandedFiles.length} audio file${expandedFiles.length === 1 ? \"\" : \"s\"}, but this story has ${items.length} line${items.length === 1 ? \"\" : \"s\"}. Upload exactly one audio file per line.`,\n          );\n        } else {\n          applyMatchedFiles(expandedFiles);\n          setApplyError(null);\n        }\n      } catch (error) {\n        setApplyError(\n          error instanceof Error && error.message\n            ? error.message\n            : \"Could not read the selected zip file.\",\n        );\n      } finally {\n        setIsPreparingFiles(false);\n      }\n    },\n    [applyMatchedFiles, items.length],\n  );\n\n  const summary = React.useMemo(() => {\n    let ready = 0;\n    let changed = 0;\n    for (const item of items) {\n      const draft = drafts[item.id];\n      if (!draft) continue;\n      if (draft.file || draft.uploadedFilename || item.existingFilename) {\n        ready += 1;\n      }\n      if (isChanged(item, draft)) {\n        changed += 1;\n      }\n    }\n    return {\n      total: items.length,\n      ready,\n      changed,\n    };\n  }, [drafts, items]);\n\n  const canAutoAll = React.useMemo(\n    () =>\n      items.some((item) => {\n        const draft = drafts[item.id];\n        return Boolean(\n          draft &&\n            (draft.localUrl || draft.uploadedFilename || item.existingFilename),\n        );\n      }),\n    [drafts, items],\n  );\n\n  const uploadPendingFiles = React.useCallback(async () => {\n    const pendingItems = items.filter((item) => {\n      const draft = draftsRef.current[item.id];\n      return draft?.file && !draft.uploadedFilename;\n    });\n\n    const uploadedFilenames: Record<string, string> = {};\n    const failedIds: string[] = [];\n\n    for (const item of pendingItems) {\n      const draft = draftsRef.current[item.id];\n      if (!draft?.file) continue;\n\n      updateDraft(item.id, (currentDraft) => ({\n        ...currentDraft,\n        uploadState: \"uploading\",\n        error: null,\n      }));\n\n      try {\n        const filename = stripAudioPathPrefix(\n          await uploadAudioFile(draft.file, storyId),\n        );\n        uploadedFilenames[item.id] = filename;\n        updateDraft(item.id, (currentDraft) => ({\n          ...currentDraft,\n          uploadedFilename: filename,\n          uploadState: \"uploaded\",\n          error: null,\n        }));\n      } catch (error) {\n        failedIds.push(item.id);\n        updateDraft(item.id, (currentDraft) => ({\n          ...currentDraft,\n          uploadState: \"failed\",\n          error:\n            error instanceof Error && error.message\n              ? error.message\n              : \"Upload failed.\",\n        }));\n      }\n    }\n\n    return { uploadedFilenames, failedIds };\n  }, [items, storyId, updateDraft]);\n\n  const onApplyChanges = React.useCallback(async () => {\n    setApplyError(null);\n    setIsApplying(true);\n\n    try {\n      const { uploadedFilenames, failedIds } = await uploadPendingFiles();\n      if (failedIds.length > 0) {\n        setApplyError(\"Some uploads failed. Fix those rows and retry.\");\n        return;\n      }\n\n      const updates: BulkAudioEditorUpdate[] = [];\n\n      for (const item of items) {\n        const draft = draftsRef.current[item.id];\n        if (!draft) continue;\n        const filename =\n          uploadedFilenames[item.id] ||\n          draft.uploadedFilename ||\n          item.existingFilename;\n        if (!filename) continue;\n\n        const serializedText = `$${filename}${draft.timingText}`;\n        const [_, keypoints] = text_to_keypoints(\n          `${filename}${draft.timingText}`,\n        );\n        const initialText = timings_to_text({\n          filename: item.existingFilename,\n          keypoints: item.existingKeypoints,\n        });\n\n        if (serializedText === initialText) continue;\n\n        updates.push({\n          itemId: item.id,\n          filename,\n          keypoints,\n          serializedText,\n          ssml: item.ssml,\n        });\n      }\n\n      onApply(updates);\n      onOpenChange(false);\n    } catch (error) {\n      setApplyError(\n        error instanceof Error && error.message\n          ? error.message\n          : \"Could not apply bulk audio changes.\",\n      );\n    } finally {\n      setIsApplying(false);\n    }\n  }, [items, onApply, onOpenChange, uploadPendingFiles]);\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent\n        showCloseButton={true}\n        className=\"inset-2 h-[calc(100vh-1rem)] w-[calc(100vw-1rem)] max-w-none translate-x-0 translate-y-0 overflow-hidden p-0 sm:top-2 sm:left-2 sm:max-w-none sm:translate-x-0 sm:translate-y-0\"\n      >\n        <div className=\"flex min-h-full min-w-[1100px] flex-col bg-[var(--body-background)]\">\n          <div className=\"border-b border-[var(--color_base_border)] px-4 py-4\">\n            <DialogTitle className=\"text-lg font-semibold text-[var(--text-color)]\">\n              Bulk audio editor\n            </DialogTitle>\n            <DialogDescription className=\"mt-1 text-sm text-[var(--text-color-dim)]\">\n              Work through the story line by line with the same audio editor\n              layout, then apply all updated audio entries back into the story\n              text at once.\n            </DialogDescription>\n          </div>\n\n          <div className=\"flex flex-wrap items-center gap-3 border-b border-[var(--color_base_border)] px-4 py-3\">\n            <button\n              type=\"button\"\n              className=\"inline-flex h-9 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n              onClick={openAudioCutterPage}\n            >\n              Open cutter page\n            </button>\n            <button\n              type=\"button\"\n              className=\"inline-flex h-9 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n              onClick={() => fileInputRef.current?.click()}\n              disabled={isPreparingFiles}\n            >\n              {isPreparingFiles ? \"Preparing...\" : \"Upload files\"}\n            </button>\n            <button\n              type=\"button\"\n              className=\"inline-flex h-9 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)] disabled:cursor-default disabled:opacity-70\"\n              onClick={() => setAutoAllRequest((current) => current + 1)}\n              disabled={!canAutoAll}\n              title={\n                canAutoAll\n                  ? \"Auto-adjust timing for every loaded row\"\n                  : \"Load audio first\"\n              }\n            >\n              Auto all\n            </button>\n            <input\n              ref={fileInputRef}\n              className=\"sr-only\"\n              type=\"file\"\n              multiple={true}\n              accept=\"audio/*,.zip,application/zip\"\n              onChange={onBulkFileChange}\n            />\n            <div className=\"text-sm text-[var(--text-color-dim)]\">\n              {summary.ready} / {summary.total} rows have audio\n            </div>\n            <div className=\"text-sm text-[var(--text-color-dim)]\">\n              {summary.changed} rows changed\n            </div>\n            {isPreparingFiles ? (\n              <div className=\"text-sm text-[var(--text-color-dim)]\">\n                Reading selected audio files...\n              </div>\n            ) : null}\n            {unmatchedFiles.length > 0 ? (\n              <div className=\"text-sm text-[#b33b3b]\">\n                Unmatched: {unmatchedFiles.join(\", \")}\n              </div>\n            ) : null}\n          </div>\n\n          <div className=\"min-h-0 flex-1 overflow-auto\">\n            {items.map((item) => {\n              const draft = drafts[item.id];\n              if (!draft) return null;\n              return (\n                <BulkAudioRow\n                  key={item.id}\n                  item={item}\n                  draft={draft}\n                  autoAllRequest={autoAllRequest}\n                  onChange={(updater) => updateDraft(item.id, updater)}\n                />\n              );\n            })}\n          </div>\n\n          <div className=\"flex flex-wrap items-center justify-between gap-3 border-t border-[var(--color_base_border)] px-4 py-3\">\n            <div className=\"text-sm text-[var(--text-color-dim)]\">\n              {summary.changed > 0\n                ? `${summary.changed} rows will be written back when you apply.`\n                : \"No changed rows yet.\"}\n            </div>\n            <div className=\"flex items-center gap-2\">\n              {applyError ? (\n                <div className=\"text-sm text-[#b33b3b]\">{applyError}</div>\n              ) : null}\n              <button\n                type=\"button\"\n                className=\"inline-flex h-9 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)]\"\n                onClick={() => onOpenChange(false)}\n                disabled={isApplying}\n              >\n                Close\n              </button>\n              <button\n                type=\"button\"\n                className=\"inline-flex h-9 items-center justify-center rounded-md border border-[#0f5f83] bg-[#1cb0f6] px-3 text-sm font-semibold leading-none text-white transition-colors hover:bg-[#1598d7] disabled:cursor-default disabled:opacity-70\"\n                onClick={() => {\n                  void onApplyChanges();\n                }}\n                disabled={isApplying || summary.changed === 0}\n              >\n                {isApplying ? \"Applying...\" : \"Apply bulk audio\"}\n              </button>\n            </div>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/story/[story]/editor_state.ts",
    "content": "import type { EditorView } from \"codemirror\";\nimport type {\n  Audio,\n  StoryElementHeader,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport { type processStoryFile } from \"@/components/editor/story/syntax_parser_new\";\nimport type { AudioInsertAnchor } from \"@/lib/editor/audio/audio_edit_tools\";\n\ntype AudioInsertLinesType = ReturnType<typeof processStoryFile>[2];\n\nexport type EditorStateType = {\n  line_no: number;\n  view: EditorView;\n  select: (line: string, scroll: boolean) => void;\n  audio_insert_lines: AudioInsertLinesType | undefined;\n  create_audio_insert_anchor: (\n    ssml: Audio[\"ssml\"],\n  ) => AudioInsertAnchor | undefined;\n  track_audio_insert_anchor: (anchor: AudioInsertAnchor) => () => void;\n  insert_audio_at_anchor: (text: string, anchor: AudioInsertAnchor) => void;\n  show_audio_editor: (data: StoryElementLine | StoryElementHeader) => void;\n};\n"
  },
  {
    "path": "src/app/editor/story/[story]/header.tsx",
    "content": "import React from \"react\";\nimport Link from \"next/link\";\nimport EditorButton from \"../../editor_button\";\nimport { EditorHeaderActions } from \"../../_components/header_context\";\nimport type { StoryData } from \"./types\";\n\ntype StoryNavigationTarget = {\n  href: string;\n  name: string;\n};\n\ndeclare global {\n  interface Window {\n    editorShowTranslations?: boolean;\n    editorShowSsml?: boolean;\n  }\n}\n\ntype HeaderProps = {\n  isAdmin: boolean;\n  story_data: StoryData;\n  unsaved_changes: boolean;\n  func_save: () => Promise<void>;\n  func_delete: () => Promise<void>;\n  is_saving: boolean;\n  is_deleting: boolean;\n  last_saved_at: number | null;\n  show_trans: boolean;\n  set_show_trans: (show: boolean) => void;\n  show_ssml: boolean;\n  set_show_ssml: (show: boolean) => void;\n  open_bulk_audio: () => void;\n  previous_story: StoryNavigationTarget | null;\n  next_story: StoryNavigationTarget | null;\n};\n\nexport function StoryEditorHeader({\n  isAdmin,\n  story_data,\n  unsaved_changes,\n  func_save,\n  func_delete,\n  is_saving,\n  is_deleting,\n  last_saved_at,\n  show_trans,\n  set_show_trans,\n  show_ssml,\n  set_show_ssml,\n  open_bulk_audio,\n  previous_story,\n  next_story,\n}: HeaderProps) {\n  function do_set_show_trans() {\n    let value = !show_trans;\n    const event = new CustomEvent(\"editorShowTranslations\", {\n      detail: { show: value },\n    });\n    window.dispatchEvent(event);\n    window.editorShowTranslations = value;\n    set_show_trans(value);\n    window.requestAnimationFrame(() =>\n      window.dispatchEvent(new CustomEvent(\"resize\")),\n    );\n  }\n\n  function do_set_show_ssml() {\n    let value = !show_ssml;\n    const event = new CustomEvent(\"editorShowSsml\", {\n      detail: { show: value },\n    });\n\n    window.dispatchEvent(event);\n    window.editorShowSsml = value;\n    set_show_ssml(value);\n    window.requestAnimationFrame(() =>\n      window.dispatchEvent(new CustomEvent(\"resize\")),\n    );\n  }\n\n  async function Save() {\n    if (is_saving || is_deleting) return;\n    try {\n      await func_save();\n    } catch (e) {\n      console.log(\"error save\", e);\n    }\n  }\n\n  async function Delete() {\n    if (is_saving || is_deleting) return;\n    if (confirm(\"Are you sure that you want to delete this story?\")) {\n      try {\n        await func_delete();\n      } catch (e) {\n        console.log(\"error delete\", e);\n      }\n    }\n  }\n\n  return (\n    <EditorHeaderActions>\n      <div className=\"flex items-center\">\n        <StoryNavButton\n          href={previous_story?.href}\n          label=\"Previous\"\n          title={previous_story?.name}\n          compactIconDirection=\"left\"\n        />\n        <StoryNavButton\n          href={next_story?.href}\n          label=\"Next\"\n          title={next_story?.name}\n          compactIconDirection=\"right\"\n        />\n        <EditorButton\n          style={{ marginLeft: \"auto\" }}\n          id=\"button_delete\"\n          onClick={Delete}\n          img={\"delete.svg\"}\n          text={is_deleting ? \"Deleting...\" : \"Delete\"}\n          disabled={is_saving || is_deleting}\n        />\n        <EditorButton\n          onClick={do_set_show_trans}\n          checked={show_trans}\n          text={\"Hints\"}\n        />\n        <div className=\"ml-2 flex shrink-0 flex-col items-center gap-0.5\">\n          <EditorButton\n            onClick={do_set_show_ssml}\n            checked={show_ssml}\n            text={\"Audio\"}\n          />\n          <button\n            type=\"button\"\n            className=\"inline-flex h-5 max-w-full items-center rounded-full border border-slate-300 bg-white px-1.5 text-[10px] font-semibold leading-none text-slate-700 transition hover:border-slate-400 hover:bg-slate-50\"\n            onClick={open_bulk_audio}\n          >\n            Bulk audio\n          </button>\n        </div>\n        <div className=\"relative\">\n          <EditorButton\n            id=\"button_save\"\n            onClick={Save}\n            img={\"save.svg\"}\n            text={\n              (is_saving ? \"Saving...\" : \"Save\") + (unsaved_changes ? \"*\" : \"\")\n            }\n            disabled={is_saving || is_deleting}\n            title={\n              story_data.official && !isAdmin\n                ? \"Only admins can overwrite official stories.\"\n                : undefined\n            }\n          />\n          {last_saved_at ? <SaveStatus lastSavedAt={last_saved_at} /> : null}\n        </div>\n      </div>\n    </EditorHeaderActions>\n  );\n}\n\nexport function StoryEditorHeaderLoading() {\n  return (\n    <EditorHeaderActions>\n      <div className=\"flex items-center\">\n        <StoryNavButton\n          label=\"Previous\"\n          compactIconDirection=\"left\"\n          disabled={true}\n        />\n        <StoryNavButton\n          label=\"Next\"\n          compactIconDirection=\"right\"\n          disabled={true}\n        />\n        <EditorButton\n          style={{ marginLeft: \"auto\" }}\n          id=\"button_delete_loading\"\n          onClick={() => {}}\n          img={\"delete.svg\"}\n          text={\"Delete\"}\n          disabled={true}\n        />\n        <EditorButton\n          onClick={() => {}}\n          checked={false}\n          text={\"Hints\"}\n          disabled={true}\n        />\n        <div className=\"ml-2 flex shrink-0 flex-col items-center gap-0.5\">\n          <EditorButton\n            onClick={() => {}}\n            checked={false}\n            text={\"Audio\"}\n            disabled={true}\n          />\n          <button\n            type=\"button\"\n            className=\"inline-flex h-5 max-w-full items-center rounded-full border border-slate-200 bg-slate-100 px-1.5 text-[10px] font-semibold leading-none text-slate-400\"\n            disabled={true}\n          >\n            Bulk audio\n          </button>\n        </div>\n        <EditorButton\n          id=\"button_save_loading\"\n          onClick={() => {}}\n          img={\"save.svg\"}\n          text={\"Save\"}\n          disabled={true}\n        />\n      </div>\n    </EditorHeaderActions>\n  );\n}\n\nfunction SaveStatus({ lastSavedAt }: { lastSavedAt: number }) {\n  return (\n    <div className=\"pointer-events-none absolute left-1/2 top-[calc(100%-18px)] z-10 -translate-x-1/2 whitespace-nowrap text-[0.75rem] text-[var(--text-color-dim)]\">\n      {`Saved at ${new Date(lastSavedAt).toLocaleTimeString([], {\n        hour: \"2-digit\",\n        minute: \"2-digit\",\n      })}`}\n    </div>\n  );\n}\n\nfunction StoryNavButton({\n  href,\n  label,\n  title,\n  compactIconDirection,\n  disabled = false,\n}: {\n  href?: string;\n  label: string;\n  title?: string;\n  compactIconDirection: \"left\" | \"right\";\n  disabled?: boolean;\n}) {\n  const className =\n    \"px-3 py-2 text-center text-sm text-[var(--text-color-dim)] no-underline transition-colors hover:text-[var(--text-color)]\";\n  const content = (\n    <>\n      <span className=\"max-[1100px]:hidden\">{label}</span>\n      <span className=\"min-[1101px]:hidden\">\n        <ChevronIcon direction={compactIconDirection} />\n      </span>\n    </>\n  );\n\n  if (!href || disabled) {\n    return (\n      <span\n        className={`${className} hidden min-[701px]:block min-[701px]:min-w-[48px] min-[1101px]:min-w-[86px] cursor-default opacity-50`}\n        aria-disabled=\"true\"\n      >\n        {content}\n      </span>\n    );\n  }\n\n  return (\n    <Link\n      href={href}\n      title={title}\n      className={`${className} hidden min-[701px]:block min-[701px]:min-w-[48px] min-[1101px]:min-w-[86px]`}\n    >\n      {content}\n    </Link>\n  );\n}\n\nfunction ChevronIcon({ direction }: { direction: \"left\" | \"right\" }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 16 16\"\n      className=\"inline-block h-4 w-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.75\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      {direction === \"left\" ? (\n        <path d=\"M10 3.5 5.5 8 10 12.5\" />\n      ) : (\n        <path d=\"M6 3.5 10.5 8 6 12.5\" />\n      )}\n    </svg>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/story/[story]/layout.tsx",
    "content": "import React from \"react\";\nimport EditorPageLayout from \"../../_components/page_layout\";\n\nexport default function Layout({ children }: { children: React.ReactNode }) {\n  return (\n    <EditorPageLayout contentClassName=\"min-h-0 min-w-0 flex-1 overflow-hidden\">\n      {children}\n    </EditorPageLayout>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/story/[story]/page.tsx",
    "content": "import React from \"react\";\nimport { notFound, redirect } from \"next/navigation\";\nimport { Metadata } from \"next\";\nimport { fetchQuery } from \"convex/nextjs\";\nimport { api } from \"@convex/_generated/api\";\n\nfunction getCanonicalStoryEditorPath(courseShort: string, storyId: number) {\n  return `/editor/course/${courseShort}/story/${storyId}`;\n}\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ story: number }>;\n}): Promise<Metadata> {\n  const storyId = Number((await params).story);\n  const story = await fetchQuery(api.editorRead.getEditorStoryPageData, {\n    storyId,\n  });\n\n  if (!story) notFound();\n\n  return {\n    title: `${story.story_data.name} | Duostories Editor`,\n    alternates: {\n      canonical: `https://duostories.org${getCanonicalStoryEditorPath(\n        story.story_data.short,\n        story.story_data.id,\n      )}`,\n    },\n  };\n}\n\nexport default async function Page({\n  params,\n  searchParams,\n}: {\n  params: Promise<{ story: number }>;\n  searchParams?: Promise<{ line?: string | string[] }>;\n}) {\n  const storyId = Number((await params).story);\n  const story = await fetchQuery(api.editorRead.getEditorStoryPageData, {\n    storyId,\n  });\n\n  if (!story) notFound();\n\n  const resolvedSearchParams = searchParams ? await searchParams : undefined;\n  const lineParam = resolvedSearchParams?.line;\n  const line =\n    typeof lineParam === \"string\"\n      ? `?line=${encodeURIComponent(lineParam)}`\n      : Array.isArray(lineParam) && typeof lineParam[0] === \"string\"\n        ? `?line=${encodeURIComponent(lineParam[0])}`\n        : \"\";\n\n  redirect(\n    `${getCanonicalStoryEditorPath(story.story_data.short, story.story_data.id)}${line}`,\n  );\n}\n"
  },
  {
    "path": "src/app/editor/story/[story]/page_client.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { useConvex, useQuery } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport EditorV2 from \"./v2/editor_v2\";\nimport { StoryEditorHeaderLoading } from \"./header\";\nimport type { Avatar, StoryEditorPageData } from \"./types\";\nimport type {\n  DetailedCourseProps,\n  StoryListDataProps,\n} from \"@/app/editor/(course)/types\";\nimport { Breadcrumbs } from \"../../_components/breadcrumbs\";\nimport { EditorHeaderBreadcrumbs } from \"../../_components/header_context\";\n\nexport default function StoryEditorPageClient({\n  storyId,\n  courseId,\n  initialFocusLine,\n  initialBulkAudioOpen,\n}: {\n  storyId: number;\n  courseId?: string;\n  initialFocusLine?: number;\n  initialBulkAudioOpen?: boolean;\n}) {\n  const convex = useConvex();\n  const data = useQuery(api.editorRead.getEditorStoryPageData, {\n    storyId,\n  }) as StoryEditorPageData | null | undefined;\n  const effectiveCourseId =\n    data?.story_data.short && data.story_data.short !== courseId\n      ? data.story_data.short\n      : courseId;\n  const course = useQuery(\n    api.editorRead.getEditorCourseByIdentifier,\n    effectiveCourseId ? { identifier: effectiveCourseId } : \"skip\",\n  ) as DetailedCourseProps | null | undefined;\n  const stories = useQuery(\n    api.editorRead.getEditorStoriesByCourseLegacyId,\n    effectiveCourseId ? { identifier: effectiveCourseId } : \"skip\",\n  ) as StoryListDataProps[] | undefined;\n  const avatarRows = useQuery(\n    api.editorRead.getEditorAvatarNamesByLanguageLegacyId,\n    data ? { languageLegacyId: data.story_data.learning_language } : \"skip\",\n  );\n  const storyIndex =\n    stories?.findIndex((story) => story.id === data?.story_data.id) ?? -1;\n  const previousStory = storyIndex > 0 ? stories?.[storyIndex - 1] : null;\n  const nextStory =\n    storyIndex >= 0 && stories && storyIndex < stories.length - 1\n      ? stories[storyIndex + 1]\n      : null;\n\n  React.useEffect(() => {\n    if (!data || !course) return;\n\n    const storyIdsToPrewarm = [previousStory?.id, nextStory?.id].filter(\n      (candidate): candidate is number => typeof candidate === \"number\",\n    );\n    if (storyIdsToPrewarm.length === 0) return;\n\n    const prewarm = () => {\n      for (const adjacentStoryId of storyIdsToPrewarm) {\n        convex.prewarmQuery({\n          query: api.editorRead.getEditorStoryPageData,\n          args: { storyId: adjacentStoryId },\n          extendSubscriptionFor: 15_000,\n        });\n      }\n    };\n\n    if (typeof window.requestIdleCallback === \"function\") {\n      const idleCallbackId = window.requestIdleCallback(() => {\n        prewarm();\n      });\n      return () => window.cancelIdleCallback(idleCallbackId);\n    }\n\n    const timeoutId = window.setTimeout(prewarm, 250);\n    return () => window.clearTimeout(timeoutId);\n  }, [convex, data, course, nextStory?.id, previousStory?.id]);\n\n  if (data === undefined || !effectiveCourseId || course === undefined) {\n    return (\n      <>\n        {course ? (\n          <EditorHeaderBreadcrumbs>\n            <Breadcrumbs\n              path={[\n                { type: \"Editor\", href: `/editor` },\n                { type: \"sep\", href: \"#\" },\n                {\n                  type: \"course\",\n                  lang1: {\n                    languageId: course.learningLanguageId,\n                    name: course.learning_language_name,\n                  },\n                  lang2: {\n                    languageId: course.fromLanguageId,\n                    name: course.from_language_name,\n                  },\n                  href: `/editor/course/${course.short}`,\n                },\n              ]}\n            />\n          </EditorHeaderBreadcrumbs>\n        ) : null}\n        <StoryEditorHeaderLoading />\n        <Spinner />\n      </>\n    );\n  }\n  if (!data) return <p>Story not found.</p>;\n  if (!course) return <p>Course not found.</p>;\n\n  const avatarNames: Record<number, Avatar> = {};\n  for (const avatar of (avatarRows ?? []) as Avatar[]) {\n    avatarNames[avatar.avatar_id] = avatar;\n  }\n  const coursePathId = course.short ?? effectiveCourseId;\n\n  return (\n    <>\n      <EditorHeaderBreadcrumbs>\n        <Breadcrumbs\n          path={[\n            { type: \"Editor\", href: `/editor` },\n            { type: \"sep\", href: \"#\" },\n            {\n              type: \"course\",\n              lang1: {\n                languageId: course.learningLanguageId,\n                name: course.learning_language_name,\n              },\n              lang2: {\n                languageId: course.fromLanguageId,\n                name: course.from_language_name,\n              },\n              href: `/editor/course/${course.short}`,\n            },\n            { type: \"sep\", href: \"#\" },\n            { type: \"story\", href: `#`, data: data.story_data },\n          ]}\n        />\n      </EditorHeaderBreadcrumbs>\n      <EditorV2\n        isAdmin={data.isAdmin}\n        story_data={data.story_data}\n        avatar_names={avatarNames}\n        initialFocusLine={initialFocusLine}\n        initialBulkAudioOpen={initialBulkAudioOpen}\n        story_navigation={{\n          previousStory: previousStory\n            ? {\n                href: `/editor/course/${coursePathId}/story/${previousStory.id}`,\n                name: previousStory.name,\n              }\n            : null,\n          nextStory: nextStory\n            ? {\n                href: `/editor/course/${coursePathId}/story/${nextStory.id}`,\n                name: nextStory.name,\n              }\n            : null,\n        }}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/story/[story]/sound-recorder.tsx",
    "content": "\"use no memo\";\nimport React, { useCallback, useMemo, useRef, useState } from \"react\";\nimport WaveSurfer from \"wavesurfer.js\";\nimport StoryLineHints from \"@/components/StoryLineHints\";\nimport { splitTextTokens } from \"@/lib/editor/tts_transcripte\";\nimport { timing_text_without_filename } from \"@/lib/editor/audio/audio_edit_tools\";\n\nimport { useWavesurfer } from \"@wavesurfer/react\";\nimport Regions from \"wavesurfer.js/dist/plugins/regions.js\";\nimport PlayAudio from \"@/components/PlayAudio\";\nimport type { ContentWithHints } from \"@/components/editor/story/syntax_parser_types\";\n\ninterface Region {\n  start: number;\n  content?: HTMLElement;\n  innerText?: string;\n}\n\ninterface RegionsPlugin {\n  regions: Region[];\n  addRegion: (options: {\n    start: number;\n    content: string;\n    color: string;\n  }) => void;\n  on: (event: string, callback: (region: Region) => void) => void;\n}\n\ninterface SoundRecorderProps {\n  content: ContentWithHints;\n  initialTimingText: string;\n  url: string;\n  story_id: number;\n  onClose: () => void;\n  onSave: (filename: string, timingText: string) => void;\n  soundRecorderNext: () => void;\n  soundRecorderPrevious: () => void;\n  total_index: number;\n  current_index: number;\n}\n\ninterface Part {\n  text: string;\n  pos: number;\n}\n\nfunction cumulativeSums(values: number[]): number[] {\n  let total = 0;\n  const sums: number[] = [];\n  values.forEach((v: number) => {\n    total += v;\n    sums.push(total);\n  });\n  return sums;\n}\n\nasync function uploadAudio(\n  file: File | null,\n  story_id: number,\n): Promise<Response | undefined> {\n  if (!file) return;\n\n  try {\n    const data = new FormData();\n    data.set(\"file\", file);\n    data.set(\"story_id\", String(story_id));\n\n    const res = await fetch(\"/audio/upload\", {\n      method: \"POST\",\n      body: data,\n    });\n    // handle the error\n    if (!res.ok) throw new Error(await res.text());\n    return res;\n  } catch (e) {\n    // Handle errors here\n    console.error(e);\n  }\n}\n\nexport default function SoundRecorder({\n  content,\n  initialTimingText,\n  url,\n  story_id,\n  onClose,\n  onSave,\n  soundRecorderNext,\n  soundRecorderPrevious,\n  total_index,\n  current_index,\n}: SoundRecorderProps) {\n  const fileInputId = React.useId();\n  const actionButtonClassName =\n    \"inline-flex h-9 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] px-3 text-sm font-medium leading-none transition-colors hover:bg-[var(--color_base_background)]\";\n  const primaryButtonClassName =\n    \"inline-flex h-9 items-center justify-center rounded-md border border-[#0f5f83] bg-[#1cb0f6] px-3 text-sm font-semibold leading-none text-white transition-colors hover:bg-[#1598d7]\";\n\n  const containerRef = useRef(null);\n  const [urlIndex, setUrlIndex] = useState(url);\n  const [audioRange, setAudioRange] = React.useState(99999);\n  const [uploaded, setUploaded] = React.useState(!!url);\n  const [file, setFile] = React.useState<File | null>(null);\n  const [selectedFileName, setSelectedFileName] =\n    React.useState(\"No file selected.\");\n\n  const [timingText, setTimingText] = useState(() =>\n    timing_text_without_filename(initialTimingText),\n  );\n\n  const parts2 = useMemo(() => {\n    const parts = splitTextTokens(content.text);\n    const parts2 = [];\n    //if (parts[0] === \"\") parts.splice(0, 1);\n    if (parts[parts.length - 1] === \"\") parts.pop();\n    let current_pos = 0;\n    for (let i = 0; i < parts.length; i++) {\n      if (i % 2 === 0) {\n        parts2.push({ text: parts[i], pos: current_pos });\n      }\n      current_pos += parts[i].length;\n    }\n    return parts2;\n  }, [content]);\n\n  const updateTimingText = useCallback((regions: Region[], parts2: Part[]) => {\n    let text = \"\";\n\n    for (let i = 0; i < regions.length; i++) {\n      text +=\n        \";\" +\n        (parts2[i].text.length +\n          parts2[i].pos -\n          (parts2[i - 1]?.text?.length + parts2[i - 1]?.pos || 0)) +\n        \",\" +\n        (Math.floor(regions[i].start * 1000) -\n          Math.floor(regions[i - 1]?.start * 1000 || 0));\n    }\n    setTimingText(text);\n  }, []);\n\n  const onDecode = useCallback(\n    (wavesurfer: WaveSurfer, duration: number) => {\n      const timings = initialTimingText\n        ? cumulativeSums(\n            [...initialTimingText.matchAll(/(\\d*),(\\d*)/g)].map(\n              (a) => parseInt(a[2]) / 1000,\n            ),\n          )\n        : [];\n\n      const regionsPlugin = (wavesurfer as unknown as { plugins: unknown[] })\n        .plugins[0] as RegionsPlugin;\n      for (let i = 0; i < parts2.length; i++) {\n        if (i >= regionsPlugin.regions.length)\n          regionsPlugin.addRegion({\n            start:\n              timings[i] || duration * (parts2[i].pos / content.text.length),\n            content: parts2[i].text,\n            color: \"#e06c75\",\n          });\n\n        regionsPlugin.regions[i].innerText =\n          parts2[i].text + \" \" + parts2[i].pos;\n        regionsPlugin.regions[i].start =\n          timings[i] || duration * (parts2[i].pos / content.text.length);\n      }\n    },\n    [parts2, initialTimingText, content.text.length],\n  );\n  const onTimeUpdate = useCallback(\n    (wavesurfer: WaveSurfer, currentTime: number) => {\n      const regionsPlugin = (wavesurfer as unknown as { plugins: unknown[] })\n        .plugins[0] as RegionsPlugin;\n      const regions = regionsPlugin.regions.sort(\n        (a: Region, b: Region) => a.start - b.start,\n      );\n      let pos = 0;\n      for (let i = 0; i < regions.length; i++) {\n        if (regions[i].start < currentTime && parts2[i] !== undefined)\n          pos = parts2[i].pos;\n      }\n      if (pos !== audioRange) {\n        setAudioRange(pos);\n      }\n    },\n    [audioRange, parts2],\n  );\n  const onRegionUpdated = useCallback(\n    (regionsPlugin: RegionsPlugin, region: Region) => {\n      const regions = regionsPlugin.regions.sort(\n        (a: Region, b: Region) => a.start - b.start,\n      );\n      for (let i = 0; i < regions.length; i++) {\n        if (regions[i].content !== undefined && parts2[i] !== undefined)\n          regions[i].content!.innerText = parts2[i].text; //+ \" \" + parts2[i].pos;\n      }\n      updateTimingText(regions, parts2);\n    },\n    [parts2, updateTimingText],\n  );\n\n  const { wavesurfer } = useWavesurfer({\n    container: containerRef,\n    height: 60,\n    waveColor: \"#1cb0f6\",\n    progressColor: \"rgba(28,176,246,0.62)\",\n    cursorColor: \"#0f5f83\",\n    normalize: true,\n    barWidth: 4,\n    barGap: 3,\n    barRadius: 30,\n    url: urlIndex,\n    plugins: useMemo(\n      () => [\n        /*Timeline.create({\n          timeInterval: 0.01,\n          primaryLabelInterval: 0.1,\n          secondaryLabelInterval: 0.05,\n        }),*/\n        Regions.create(),\n      ],\n      [],\n    ),\n  });\n\n  if (wavesurfer) {\n    wavesurfer.on(\"decode\", (duration) => onDecode(wavesurfer, duration));\n    wavesurfer.on(\"timeupdate\", (currentTime) =>\n      onTimeUpdate(wavesurfer, currentTime),\n    );\n    const plugins = (wavesurfer as unknown as { plugins: RegionsPlugin[] })\n      .plugins;\n    plugins[0].on(\"region-updated\", (region: Region) => {\n      if (plugins[0]) onRegionUpdated(plugins[0], region);\n    });\n  }\n\n  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n    const file = event.target.files?.[0];\n    if (!file) return;\n    //console.log(\"file\", file);\n    setFile(file);\n    setSelectedFileName(file.name);\n    setUploaded(false);\n    let url = URL.createObjectURL(file);\n    setUrlIndex(url);\n  };\n\n  const onPlayPause = useCallback(() => {\n    wavesurfer && wavesurfer.playPause();\n  }, [wavesurfer]);\n\n  const onSaveX = async () => {\n    let filename = urlIndex;\n    if (!uploaded) {\n      const uploadResult = await uploadAudio(file, story_id);\n      if (!uploadResult) {\n        window.alert(\"Upload failed.\");\n        return;\n      }\n      const response = await uploadResult.json();\n      if (response.success) {\n        setUrlIndex(\n          \"https://ptoqrnbx8ghuucmt.public.blob.vercel-storage.com/\" +\n            response.filename,\n        );\n        filename = response.filename;\n        setUploaded(true);\n      } else {\n        window.alert(\"Upload failed.\");\n        return;\n      }\n    }\n    //console.log(timingText);\n    if (\n      filename.startsWith(\n        \"https://ptoqrnbx8ghuucmt.public.blob.vercel-storage.com/\",\n      )\n    ) {\n      filename = filename.substring(\n        \"https://ptoqrnbx8ghuucmt.public.blob.vercel-storage.com/\".length,\n      );\n    }\n    if (filename.startsWith(\"audio/\")) {\n      filename = filename.substring(\"audio/\".length);\n    }\n    //console.log(\"filename\", filename);\n    onSave(filename, timing_text_without_filename(timingText));\n  };\n\n  //return <></>;\n  return (\n    <div className=\"absolute top-[58px] z-[2] block w-full border-2 border-[var(--color_base_border)] bg-[var(--body-background)] p-[25px]\">\n      <button\n        className=\"absolute right-2 top-2 inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color_base_border)] bg-[var(--body-background-faint)] text-xl leading-none\"\n        onClick={onClose}\n        aria-label=\"Close sound recorder\"\n      >\n        X\n      </button>\n      <div className=\"mb-3 flex items-center gap-3\">\n        <label htmlFor={fileInputId} className={actionButtonClassName}>\n          Upload audio\n        </label>\n        <span className=\"max-w-[40ch] truncate text-sm\">\n          {selectedFileName}\n        </span>\n        <input\n          id={fileInputId}\n          className=\"sr-only\"\n          type=\"file\"\n          onChange={handleFileChange}\n          accept=\"audio/*\"\n        />\n      </div>\n      <PlayAudio onClick={onPlayPause} />\n      <StoryLineHints\n        audioRange={audioRange}\n        hideRangesForChallenge={[]}\n        content={content}\n      />\n      <p>{timingText}</p>\n      <div ref={containerRef} />\n      <div className=\"mt-3 flex items-center justify-end gap-2\">\n        <button\n          className={actionButtonClassName}\n          onClick={soundRecorderPrevious}\n        >\n          Previous\n        </button>\n        <button className={primaryButtonClassName} onClick={onSaveX}>\n          Save\n        </button>\n        <button className={actionButtonClassName} onClick={soundRecorderNext}>\n          Next\n        </button>\n        <span className=\"ml-2 text-sm\">\n          {current_index + 1} / {total_index}\n        </span>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/story/[story]/types.ts",
    "content": "export type StoryData = {\n  id: number;\n  official: boolean;\n  course_id: number;\n  duo_id: string;\n  image: string;\n  name: string;\n  set_id: number;\n  set_index: number;\n  text: string;\n  short: string;\n  learning_language: number;\n  from_language: number;\n};\n\nexport type StoryEditorPageData = {\n  isAdmin: boolean;\n  story_data: StoryData;\n};\n\nexport type Avatar = {\n  id: number | null;\n  avatar_id: number;\n  language_id: number;\n  name: string;\n  link: string;\n  speaker: string;\n};\n"
  },
  {
    "path": "src/app/editor/story/[story]/v2/editor_v2.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { basicSetup, EditorView } from \"codemirror\";\nimport { EditorSelection, EditorState } from \"@codemirror/state\";\nimport { useQuery } from \"convex/react\";\nimport { useRouter } from \"next/navigation\";\nimport { api } from \"@convex/_generated/api\";\nimport { example, highlightStyle } from \"@/components/editor/story/parser\";\nimport useScrollLinking from \"@/components/editor/story/scroll_linking\";\nimport useResizeEditor from \"@/components/editor/story/editor-resize\";\nimport StoryEditorPreview from \"@/components/StoryEditorPreview\";\nimport Cast from \"@/components/editor/story/cast\";\nimport { StoryEditorHeader } from \"@/app/editor/story/[story]/header\";\nimport type { Avatar, StoryData } from \"@/app/editor/story/[story]/types\";\nimport type { EditorStateType } from \"@/app/editor/story/[story]/editor_state\";\nimport BulkAudioEditor, {\n  type BulkAudioEditorItem,\n  type BulkAudioEditorUpdate,\n} from \"@/app/editor/story/[story]/bulk-audio-editor\";\nimport SoundRecorder from \"@/app/editor/story/[story]/sound-recorder\";\nimport { useStoryEditorPreferences } from \"@/app/editor/_components/story_editor_preferences\";\nimport {\n  create_audio_insert_anchor,\n  insert_audio_at_anchor,\n  map_audio_insert_anchor,\n  insert_audio_lines,\n  timings_to_text,\n  type AudioInsertAnchor,\n} from \"@/lib/editor/audio/audio_edit_tools\";\nimport type {\n  Audio,\n  StoryElement,\n  StoryElementHeader,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport { useStoryEditorModel } from \"./use_story_editor_model\";\n\ntype StoryNavigation = {\n  previousStory: {\n    href: string;\n    name: string;\n  } | null;\n  nextStory: {\n    href: string;\n    name: string;\n  } | null;\n};\n\ntype LanguageData = {\n  languageId: string;\n  id: number;\n  name: string;\n  short: string;\n  flag: number | null;\n  flag_file: string | null;\n  speaker: string | null;\n  default_text: string;\n  tts_replace: string | null;\n  public: boolean;\n  rtl: boolean;\n};\n\nfunction getMax<T>(list: T[], callback: (obj: T) => number) {\n  let max = -Infinity;\n  for (const obj of list) {\n    const v = callback(obj);\n    if (v > max) max = v;\n  }\n  return max;\n}\n\nfunction normalizeDocText(text: string): string {\n  return text.replace(/\\r\\n/g, \"\\n\");\n}\n\nfunction scrollEditorLineIntoView(view: EditorView, lineNumber: number) {\n  const boundedLine = Math.min(Math.max(lineNumber, 1), view.state.doc.lines);\n  const pos = view.state.doc.line(boundedLine).from;\n\n  view.dispatch({\n    selection: EditorSelection.cursor(pos),\n  });\n\n  view.requestMeasure({\n    read(view) {\n      const block = view.lineBlockAt(pos);\n      const targetTop = Math.max(\n        0,\n        block.top - view.scrollDOM.clientHeight / 3,\n      );\n      return targetTop;\n    },\n    write(targetTop, view) {\n      view.scrollDOM.scrollTo({\n        top: targetTop,\n        behavior: \"auto\",\n      });\n      view.scrollDOM.dispatchEvent(\n        new CustomEvent(\"story-editor-sync-preview\"),\n      );\n      view.focus();\n    },\n  });\n}\n\nfunction getElementAudio(\n  element: StoryElementLine | StoryElementHeader | undefined,\n): Audio | undefined {\n  if (!element) return undefined;\n  if (element.type === \"HEADER\") return element.audio;\n  return element.line.content.audio ?? element.audio;\n}\n\nfunction getBulkAudioEditorItems(\n  elements: StoryElement[],\n): BulkAudioEditorItem[] {\n  const items: BulkAudioEditorItem[] = [];\n  let order = 1;\n\n  for (const element of elements) {\n    if (element.type !== \"HEADER\" && element.type !== \"LINE\") continue;\n    if (!element.audio) continue;\n\n    const text =\n      element.type === \"HEADER\"\n        ? element.learningLanguageTitleContent?.text\n        : element.line.content?.text;\n    const content =\n      element.type === \"HEADER\"\n        ? element.learningLanguageTitleContent\n        : element.line.content;\n    const speaker =\n      element.type === \"HEADER\"\n        ? \"Narrator\"\n        : element.line.type === \"CHARACTER\"\n          ? (element.line.characterName ??\n            element.line.characterId?.toString() ??\n            \"Narrator\")\n          : \"Narrator\";\n\n    if (!text || !content) continue;\n\n    items.push({\n      id: `${element.type}-${element.trackingProperties.line_index}-${element.audio.ssml.inser_index}`,\n      order,\n      lineIndex: element.trackingProperties.line_index || 0,\n      type: element.type,\n      speaker,\n      content,\n      hideRangesForChallenge:\n        element.type === \"LINE\" ? element.hideRangesForChallenge : undefined,\n      existingFilename: element.audio.url?.replace(/^audio\\//, \"\") ?? \"\",\n      existingKeypoints: element.audio.keypoints ?? [],\n      ssml: element.audio.ssml,\n    });\n\n    order += 1;\n  }\n\n  return items;\n}\n\nexport default function EditorV2({\n  isAdmin,\n  story_data,\n  avatar_names,\n  initialFocusLine,\n  initialBulkAudioOpen = false,\n  story_navigation,\n}: {\n  isAdmin: boolean;\n  story_data: StoryData;\n  avatar_names: Record<number, Avatar>;\n  initialFocusLine?: number;\n  initialBulkAudioOpen?: boolean;\n  story_navigation: StoryNavigation;\n}) {\n  const router = useRouter();\n  const navigate = router.push;\n  const editorRef = React.useRef<HTMLDivElement>(null);\n  const previewRef = React.useRef<HTMLDivElement>(null);\n  const marginRef = React.useRef<SVGSVGElement>(null);\n  const svgParentRef = React.useRef<SVGSVGElement>(null);\n  const viewRef = React.useRef<EditorView | null>(null);\n  const hasAppliedInitialFocusRef = React.useRef(false);\n  const previousStoryIdRef = React.useRef<number | null>(null);\n  const trackedAudioAnchorsRef = React.useRef<Set<AudioInsertAnchor>>(\n    new Set(),\n  );\n  const audioEditorAnchorRef = React.useRef<{\n    anchor: AudioInsertAnchor;\n    release: () => void;\n  } | null>(null);\n  const [view, setView] = React.useState<EditorView | undefined>(undefined);\n\n  const language_data = (useQuery(api.editorRead.getEditorLanguageByLegacyId, {\n    legacyLanguageId: story_data.learning_language,\n  }) ?? undefined) as LanguageData | undefined;\n  const language_data2 = (useQuery(api.editorRead.getEditorLanguageByLegacyId, {\n    legacyLanguageId: story_data.from_language,\n  }) ?? undefined) as LanguageData | undefined;\n\n  const [docText, setDocText] = React.useState(story_data.text ?? \"\");\n  const [revision, setRevision] = React.useState(0);\n  const [lineNo, setLineNo] = React.useState(1);\n  const {\n    showHints: show_trans,\n    setShowHints: set_show_trans,\n    showAudio: show_ssml,\n    setShowAudio: set_show_ssml,\n  } = useStoryEditorPreferences();\n  const [audioEditorData, setAudioEditorData] = React.useState<\n    StoryElementLine | StoryElementHeader | undefined\n  >(undefined);\n  const [bulkAudioOpen, setBulkAudioOpen] =\n    React.useState(initialBulkAudioOpen);\n  const storySnapshot = React.useMemo(\n    () => ({\n      id: story_data.id,\n      text: story_data.text ?? \"\",\n    }),\n    [story_data.id, story_data.text],\n  );\n  const storyText = storySnapshot.text;\n  const model = useStoryEditorModel({\n    isAdmin,\n    storyData: story_data,\n    avatarNames: avatar_names,\n    docText,\n    revision,\n    learningLanguage: language_data,\n    fromLanguage: language_data2,\n  });\n  const {\n    audioInsertLines,\n    dirty,\n    isDeleting,\n    isSaving,\n    markServerSynced,\n    parsedStory,\n    save,\n  } = model;\n\n  const releaseTrackedAudioEditorAnchor = React.useCallback(() => {\n    audioEditorAnchorRef.current?.release();\n    audioEditorAnchorRef.current = null;\n  }, []);\n\n  const trackAudioInsertAnchor = React.useCallback(\n    (ssml: Audio[\"ssml\"]) => {\n      const view = viewRef.current;\n      if (!view || !audioInsertLines) return undefined;\n      const anchor = create_audio_insert_anchor(ssml, view, audioInsertLines);\n      if (!anchor) return undefined;\n      trackedAudioAnchorsRef.current.add(anchor);\n      return {\n        anchor,\n        release: () => {\n          trackedAudioAnchorsRef.current.delete(anchor);\n        },\n      };\n    },\n    [audioInsertLines],\n  );\n\n  const openAudioEditor = React.useCallback(\n    (data: StoryElementLine | StoryElementHeader | undefined) => {\n      releaseTrackedAudioEditorAnchor();\n      if (!data) {\n        setAudioEditorData(undefined);\n        return;\n      }\n      const audio = getElementAudio(data);\n      const trackedAnchor = audio?.ssml\n        ? trackAudioInsertAnchor(audio.ssml)\n        : undefined;\n      if (trackedAnchor) {\n        audioEditorAnchorRef.current = trackedAnchor;\n      }\n      setAudioEditorData(data);\n    },\n    [releaseTrackedAudioEditorAnchor, trackAudioInsertAnchor],\n  );\n\n  React.useEffect(() => {\n    const previousStoryId = previousStoryIdRef.current;\n    previousStoryIdRef.current = storySnapshot.id;\n\n    // Reset editor-local state when switching stories, even if the text matches.\n    setDocText(normalizeDocText(storySnapshot.text));\n    setRevision(0);\n    setLineNo(1);\n    hasAppliedInitialFocusRef.current = false;\n    releaseTrackedAudioEditorAnchor();\n    setAudioEditorData(undefined);\n    if (previousStoryId !== null && previousStoryId !== storySnapshot.id) {\n      setBulkAudioOpen(false);\n    }\n  }, [releaseTrackedAudioEditorAnchor, storySnapshot]);\n\n  React.useEffect(\n    () => () => {\n      releaseTrackedAudioEditorAnchor();\n      trackedAudioAnchorsRef.current.clear();\n    },\n    [releaseTrackedAudioEditorAnchor],\n  );\n\n  useResizeEditor(editorRef.current, previewRef.current, marginRef.current);\n  useScrollLinking(view, previewRef, svgParentRef);\n\n  React.useEffect(() => {\n    const sync = EditorView.updateListener.of((update) => {\n      const currentLine = update.state.doc.lineAt(\n        update.state.selection.main.from,\n      ).number;\n      setLineNo(currentLine);\n\n      if (update.docChanged) {\n        for (const anchor of trackedAudioAnchorsRef.current) {\n          map_audio_insert_anchor(anchor, update.changes);\n        }\n        setDocText(update.state.doc.toString());\n        setRevision((prev) => prev + 1);\n      }\n    });\n\n    const state = EditorState.create({\n      doc: normalizeDocText(storySnapshot.text),\n      extensions: [basicSetup, sync, example(), highlightStyle],\n    });\n\n    const view = new EditorView({\n      state,\n      parent: editorRef.current ?? undefined,\n    });\n\n    viewRef.current = view;\n    setView(view);\n    return () => {\n      view.destroy();\n      viewRef.current = null;\n      setView(undefined);\n    };\n  }, [storySnapshot]);\n\n  React.useEffect(() => {\n    const view = viewRef.current;\n    if (!view) return;\n    if (dirty) return;\n\n    const remoteText = normalizeDocText(storyText);\n    const localText = view.state.doc.toString();\n    if (localText === remoteText) return;\n\n    markServerSynced(remoteText);\n    view.dispatch({\n      changes: { from: 0, to: view.state.doc.length, insert: remoteText },\n    });\n  }, [dirty, markServerSynced, storyText]);\n\n  React.useEffect(() => {\n    const editorView = viewRef.current ?? view;\n    if (!editorView || !initialFocusLine || hasAppliedInitialFocusRef.current)\n      return;\n    hasAppliedInitialFocusRef.current = true;\n    scrollEditorLineIntoView(editorView, initialFocusLine);\n  }, [initialFocusLine, view]);\n\n  React.useEffect(() => {\n    const warningMessage =\n      \"You have unsaved changes. Are you sure you want to leave this page?\";\n\n    const onBeforeUnload = (event: BeforeUnloadEvent) => {\n      if (!dirty) return;\n      event.preventDefault();\n      event.returnValue = warningMessage;\n    };\n\n    const onDocumentClick = (event: MouseEvent) => {\n      if (!dirty) return;\n      if (event.defaultPrevented) return;\n      if (event.button !== 0) return;\n      if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {\n        return;\n      }\n\n      const target = event.target as Element | null;\n      const anchor = target?.closest(\"a[href]\") as HTMLAnchorElement | null;\n      if (!anchor) return;\n      if (anchor.target && anchor.target !== \"_self\") return;\n      if (anchor.hasAttribute(\"download\")) return;\n\n      const href = anchor.getAttribute(\"href\");\n      if (!href || href.startsWith(\"#\")) return;\n\n      const nextUrl = new URL(anchor.href, window.location.href);\n      const currentUrl = new URL(window.location.href);\n      const isSameRoute =\n        nextUrl.origin === currentUrl.origin &&\n        nextUrl.pathname === currentUrl.pathname &&\n        nextUrl.search === currentUrl.search;\n\n      if (isSameRoute) return;\n\n      const shouldLeave = window.confirm(warningMessage);\n      if (shouldLeave) return;\n\n      event.preventDefault();\n      event.stopPropagation();\n    };\n\n    const onPopState = () => {\n      if (!dirty) return;\n      const shouldLeave = window.confirm(warningMessage);\n      if (shouldLeave) return;\n      window.history.pushState(null, \"\", window.location.href);\n    };\n\n    window.addEventListener(\"beforeunload\", onBeforeUnload);\n    document.addEventListener(\"click\", onDocumentClick, true);\n    window.addEventListener(\"popstate\", onPopState);\n    return () => {\n      window.removeEventListener(\"beforeunload\", onBeforeUnload);\n      document.removeEventListener(\"click\", onDocumentClick, true);\n      window.removeEventListener(\"popstate\", onPopState);\n    };\n  }, [dirty]);\n\n  React.useEffect(() => {\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key.toLowerCase() !== \"s\") return;\n      if (!event.ctrlKey && !event.metaKey) return;\n      event.preventDefault();\n      if (isSaving || isDeleting) return;\n      void save();\n    };\n\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => document.removeEventListener(\"keydown\", onKeyDown);\n  }, [isDeleting, isSaving, save]);\n\n  const onAudioSave = React.useCallback(\n    (filename: string, timingText: string) => {\n      const view = viewRef.current;\n      let trackedAnchor = audioEditorAnchorRef.current;\n      if (!trackedAnchor) {\n        const audio = getElementAudio(audioEditorData);\n        trackedAnchor = audio?.ssml\n          ? (trackAudioInsertAnchor(audio.ssml) ?? null)\n          : null;\n        if (trackedAnchor) {\n          audioEditorAnchorRef.current = trackedAnchor;\n        }\n      }\n      if (!view || !trackedAnchor) return;\n      insert_audio_at_anchor(\n        `$${filename}${timingText}`,\n        view,\n        trackedAnchor.anchor,\n      );\n    },\n    [audioEditorData, trackAudioInsertAnchor],\n  );\n\n  const onBulkAudioApply = React.useCallback(\n    (updates: BulkAudioEditorUpdate[]) => {\n      const view = viewRef.current;\n      if (!view) return;\n      if (!audioInsertLines || updates.length === 0) return;\n      insert_audio_lines(\n        updates.map((update) => ({\n          text: update.serializedText,\n          ssml: update.ssml,\n        })),\n        view,\n        audioInsertLines,\n      );\n    },\n    [audioInsertLines],\n  );\n\n  const soundRecorderNext = React.useCallback(() => {\n    if (!audioEditorData) return;\n    const index = audioEditorData.trackingProperties.line_index || 0;\n    for (const element of parsedStory.elements) {\n      if (\n        element.type === \"LINE\" &&\n        (element.trackingProperties?.line_index ?? 0) > index\n      ) {\n        openAudioEditor(element);\n        break;\n      }\n    }\n  }, [audioEditorData, openAudioEditor, parsedStory.elements]);\n\n  const soundRecorderPrevious = React.useCallback(() => {\n    if (!audioEditorData) return;\n    const index = audioEditorData.trackingProperties.line_index || 0;\n    for (const element of [...parsedStory.elements].reverse()) {\n      if (\n        (element.type === \"LINE\" || element.type === \"HEADER\") &&\n        (element.trackingProperties?.line_index ?? 0) < index\n      ) {\n        openAudioEditor(element);\n        break;\n      }\n    }\n  }, [audioEditorData, openAudioEditor, parsedStory.elements]);\n\n  const editorStateForPreview: EditorStateType | undefined =\n    React.useMemo(() => {\n      if (!view) return undefined;\n      return {\n        line_no: lineNo,\n        view,\n        select: (line: string, scroll: boolean) => {\n          const lineNumber = Number.parseInt(line, 10);\n          if (!Number.isFinite(lineNumber) || lineNumber <= 0) return;\n          if (scroll) {\n            scrollEditorLineIntoView(view, lineNumber);\n            return;\n          }\n          const boundedLine = Math.min(\n            Math.max(lineNumber, 1),\n            view.state.doc.lines,\n          );\n          const pos = view.state.doc.line(boundedLine).from;\n          view.dispatch({\n            selection: EditorSelection.cursor(pos),\n          });\n        },\n        audio_insert_lines: audioInsertLines,\n        create_audio_insert_anchor: (ssml) =>\n          audioInsertLines\n            ? create_audio_insert_anchor(ssml, view, audioInsertLines)\n            : undefined,\n        track_audio_insert_anchor: (anchor) => {\n          trackedAudioAnchorsRef.current.add(anchor);\n          return () => {\n            trackedAudioAnchorsRef.current.delete(anchor);\n          };\n        },\n        insert_audio_at_anchor: (text, anchor) =>\n          insert_audio_at_anchor(text, view, anchor),\n        show_audio_editor: (data) => openAudioEditor(data),\n      };\n    }, [audioInsertLines, lineNo, openAudioEditor, view]);\n\n  const audioEditorDataContent =\n    (audioEditorData?.type === \"LINE\" && audioEditorData.line.content) ||\n    (audioEditorData?.type === \"HEADER\" &&\n      audioEditorData.learningLanguageTitleContent);\n  const audioEditorAudio = getElementAudio(audioEditorData);\n  const bulkAudioItems = React.useMemo(\n    () => getBulkAudioEditorItems(model.parsedStory.elements),\n    [model.parsedStory.elements],\n  );\n\n  return (\n    <div id=\"body\" className=\"flex h-full min-h-0 flex-col\">\n      {model.saveError && (\n        <>\n          <div\n            className=\"fixed inset-0 z-[1999] bg-[rgba(0,0,0,0.5)]\"\n            onClick={model.clearSaveError}\n          />\n          <div className=\"fixed bottom-0 left-0 right-0 z-[9999] bg-[#f44336] p-[10px] text-center text-white\">\n            {model.saveErrorMessage}\n            <button\n              className=\"ml-2 rounded border border-white/60 px-2 py-0.5 text-[0.85rem] hover:bg-white/15 disabled:cursor-default disabled:opacity-70\"\n              disabled={model.isSaving || model.isDeleting}\n              onClick={() => {\n                void model.save();\n              }}\n            >\n              {model.isSaving ? \"Saving...\" : \"Retry\"}\n            </button>\n            <div\n              className=\"absolute right-[10px] top-0\"\n              onClick={model.clearSaveError}\n            >\n              X\n            </div>\n          </div>\n        </>\n      )}\n\n      <StoryEditorHeader\n        isAdmin={isAdmin}\n        story_data={story_data}\n        unsaved_changes={model.dirty}\n        func_save={model.save}\n        func_delete={async () => {\n          await model.remove();\n          navigate(`/editor/course/${story_data.short}`);\n        }}\n        is_saving={model.isSaving}\n        is_deleting={model.isDeleting}\n        last_saved_at={model.lastSavedAt}\n        show_trans={show_trans}\n        set_show_trans={set_show_trans}\n        show_ssml={show_ssml}\n        set_show_ssml={set_show_ssml}\n        open_bulk_audio={() => {\n          setAudioEditorData(undefined);\n          setBulkAudioOpen(true);\n        }}\n        previous_story={story_navigation.previousStory}\n        next_story={story_navigation.nextStory}\n      />\n      <BulkAudioEditor\n        open={bulkAudioOpen}\n        onOpenChange={setBulkAudioOpen}\n        storyId={story_data.id}\n        courseId={story_data.short}\n        items={bulkAudioItems}\n        onApply={onBulkAudioApply}\n      />\n      {audioEditorData &&\n        audioEditorAudio &&\n        audioEditorDataContent &&\n        model.parsedStory && (\n          <SoundRecorder\n            key={audioEditorData.trackingProperties.line_index}\n            content={audioEditorDataContent}\n            initialTimingText={timings_to_text({\n              filename: audioEditorAudio.url ?? \"\",\n              keypoints: audioEditorAudio.keypoints ?? [],\n            })}\n            url={`https://ptoqrnbx8ghuucmt.public.blob.vercel-storage.com/${audioEditorAudio.url}`}\n            story_id={story_data.id}\n            onClose={() => openAudioEditor(undefined)}\n            onSave={onAudioSave}\n            soundRecorderNext={soundRecorderNext}\n            soundRecorderPrevious={soundRecorderPrevious}\n            total_index={getMax(\n              model.parsedStory.elements,\n              (elem) => elem.trackingProperties.line_index || 0,\n            )}\n            current_index={audioEditorData.trackingProperties.line_index || 0}\n          />\n        )}\n\n      <div className=\"flex min-h-0 flex-1\">\n        <svg\n          className=\"pointer-events-none fixed z-[-1] h-full w-full float-left\"\n          ref={svgParentRef}\n        >\n          <path\n            className=\"fill-[var(--svg-fill)] stroke-[var(--svg-stroke)]\"\n            d=\"\"\n          />\n        </svg>\n        <div\n          ref={editorRef}\n          className={\n            \"min-h-0 w-[100px] grow [scroll-behavior:auto] max-[975px]:h-[calc((100vh-64px)/2)] max-[975px]:w-full \" +\n            (language_data?.rtl ? \"[direction:rtl]\" : \"\")\n          }\n        />\n        <svg\n          className=\"h-full w-[2%] cursor-col-resize overflow-scroll float-left\"\n          ref={marginRef}\n        />\n        <div\n          ref={previewRef}\n          className=\"min-h-0 w-[100px] grow overflow-scroll [scroll-behavior:auto] max-[975px]:absolute max-[975px]:top-[calc((100vh-64px)/2+64px)] max-[975px]:h-[calc((100vh-64px)/2)] max-[975px]:w-full p-3\"\n        >\n          <Cast\n            id={story_data.id}\n            cast={model.parsedMeta.cast}\n            short={story_data.short}\n          />\n          <StoryEditorPreview\n            story={model.parsedStory}\n            editorState={editorStateForPreview}\n            onOpenAudioEditor={openAudioEditor}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/editor/story/[story]/v2/use_story_editor_model.ts",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { useConvex, useMutation } from \"convex/react\";\nimport { api } from \"@convex/_generated/api\";\nimport {\n  processStoryFile,\n  type StoryType,\n} from \"@/components/editor/story/syntax_parser_new\";\nimport type { Avatar, StoryData } from \"@/app/editor/story/[story]/types\";\n\ntype LanguageLike = {\n  short: string;\n  rtl: boolean;\n  tts_replace: string | null;\n};\n\ntype ImageLike = {\n  active: string;\n  gilded: string;\n  locked: string;\n};\n\ntype UseStoryEditorModelArgs = {\n  isAdmin: boolean;\n  storyData: StoryData;\n  avatarNames: Record<number, Avatar>;\n  docText: string;\n  revision: number;\n  learningLanguage?: LanguageLike;\n  fromLanguage?: LanguageLike;\n};\n\nfunction normalizeDocText(text: string): string {\n  return text.replace(/\\r\\n/g, \"\\n\");\n}\n\nfunction toConvexValue(value: unknown): unknown {\n  if (value === undefined) return null;\n  if (Array.isArray(value)) return value.map((item) => toConvexValue(item));\n  if (value && typeof value === \"object\") {\n    const result: Record<string, unknown> = {};\n    for (const [key, item] of Object.entries(value)) {\n      result[key] = toConvexValue(item);\n    }\n    return result;\n  }\n  return value;\n}\n\ntype EditorModel = {\n  parsedStory: StoryType & {\n    learning_language_rtl?: boolean;\n    from_language_rtl?: boolean;\n    learning_language?: string;\n    from_language?: string;\n    illustrations: {\n      active?: string;\n      gilded?: string;\n      locked?: string;\n    };\n  };\n  parsedMeta: ReturnType<typeof processStoryFile>[1];\n  audioInsertLines: ReturnType<typeof processStoryFile>[2];\n  save: () => Promise<void>;\n  remove: () => Promise<void>;\n  isSaving: boolean;\n  isDeleting: boolean;\n  saveError: boolean;\n  saveErrorMessage: string;\n  clearSaveError: () => void;\n  lastSavedAt: number | null;\n  dirty: boolean;\n  markServerSynced: (text: string) => void;\n};\n\nexport function useStoryEditorModel({\n  isAdmin,\n  storyData,\n  avatarNames,\n  docText,\n  revision,\n  learningLanguage,\n  fromLanguage,\n}: UseStoryEditorModelArgs): EditorModel {\n  const convex = useConvex();\n  const setStoryMutation = useMutation(api.storyWrite.setStory);\n  const deleteStoryMutation = useMutation(api.storyWrite.deleteStory);\n\n  const [isSaving, setIsSaving] = React.useState(false);\n  const [isDeleting, setIsDeleting] = React.useState(false);\n  const [saveError, setSaveError] = React.useState(false);\n  const [saveErrorMessage, setSaveErrorMessage] = React.useState(\n    \"There was an error saving.\",\n  );\n  const [lastSavedAt, setLastSavedAt] = React.useState<number | null>(null);\n  const storySnapshot = React.useMemo(\n    () => ({\n      id: storyData.id,\n      text: storyData.text ?? \"\",\n    }),\n    [storyData.id, storyData.text],\n  );\n  const storyText = storySnapshot.text;\n  const [lastSavedText, setLastSavedText] = React.useState(\n    normalizeDocText(storyText),\n  );\n  const [image, setImage] = React.useState<ImageLike | null>(null);\n\n  React.useEffect(() => {\n    // Reset editor-save state when switching stories, even if the text matches.\n    setIsSaving(false);\n    setIsDeleting(false);\n    setSaveError(false);\n    setSaveErrorMessage(\"There was an error saving.\");\n    setLastSavedAt(null);\n    setLastSavedText(normalizeDocText(storySnapshot.text));\n  }, [storySnapshot]);\n\n  const [parsedStoryBase, parsedMeta, audioInsertLines] = React.useMemo(\n    () =>\n      processStoryFile(\n        docText,\n        storyData.id,\n        avatarNames,\n        {\n          learning_language: learningLanguage?.short ?? \"\",\n          from_language: fromLanguage?.short ?? \"\",\n        },\n        learningLanguage?.tts_replace ?? \"\",\n      ),\n    [\n      avatarNames,\n      docText,\n      fromLanguage?.short,\n      learningLanguage?.short,\n      learningLanguage?.tts_replace,\n      storyData.id,\n    ],\n  );\n\n  React.useEffect(() => {\n    let cancelled = false;\n    const icon = parsedMeta.icon;\n    if (!icon) {\n      setImage(null);\n      return;\n    }\n\n    void convex\n      .query(api.editorRead.getEditorImageByLegacyId, { legacyImageId: icon })\n      .then((value) => {\n        if (cancelled) return;\n        if (!value) {\n          setImage(null);\n          return;\n        }\n        setImage({\n          active: value.active,\n          gilded: value.gilded,\n          locked: value.locked,\n        });\n      })\n      .catch(() => {\n        if (!cancelled) setImage(null);\n      });\n\n    return () => {\n      cancelled = true;\n    };\n  }, [convex, parsedMeta.icon]);\n\n  const parsedStory = React.useMemo(\n    () => ({\n      ...parsedStoryBase,\n      illustrations: {\n        active: image?.active,\n        gilded: image?.gilded,\n        locked: image?.locked,\n      },\n      learning_language_rtl: learningLanguage?.rtl ?? false,\n      from_language_rtl: fromLanguage?.rtl ?? false,\n      learning_language: learningLanguage?.short,\n      from_language: fromLanguage?.short,\n    }),\n    [\n      fromLanguage?.rtl,\n      fromLanguage?.short,\n      image?.active,\n      image?.gilded,\n      image?.locked,\n      learningLanguage?.rtl,\n      learningLanguage?.short,\n      parsedStoryBase,\n    ],\n  );\n\n  const toFriendlyError = React.useCallback((error: unknown, verb: string) => {\n    const rawMessage = error instanceof Error ? error.message : \"\";\n    const isOffline =\n      typeof window !== \"undefined\" && window.navigator.onLine === false;\n    if (isOffline) {\n      return `You are offline. Reconnect to the internet and retry ${verb}.`;\n    }\n    if (\n      rawMessage.includes(\"Unauthorized\") ||\n      rawMessage.toLowerCase().includes(\"unauthorized\")\n    ) {\n      return \"Your session expired or your account no longer has editor access. Please sign in again and retry.\";\n    }\n    if (rawMessage === \"Official stories cannot be overwritten.\") {\n      return \"Official stories cannot be overwritten unless you are an admin.\";\n    }\n    if (\n      rawMessage === \"Official story overwrite requires explicit confirmation.\"\n    ) {\n      return \"Official story overwrite requires confirmation before saving.\";\n    }\n    return `There was an error ${verb}.`;\n  }, []);\n\n  const save = React.useCallback(async () => {\n    if (isSaving || isDeleting) return;\n    const confirmOfficialOverwrite =\n      storyData.official && isAdmin\n        ? window.confirm(\n            \"This is an official story. Saving will overwrite it. Continue?\",\n          )\n        : false;\n    if (storyData.official && !isAdmin) {\n      const message =\n        \"Official stories cannot be overwritten unless you are an admin.\";\n      setSaveError(true);\n      setSaveErrorMessage(message);\n      return;\n    }\n    if (storyData.official && !confirmOfficialOverwrite) {\n      return;\n    }\n    setIsSaving(true);\n    const saveStartRevision = revision;\n    try {\n      const result = await setStoryMutation({\n        legacyStoryId: storyData.id,\n        duo_id: storyData.duo_id ?? \"\",\n        name: parsedMeta.fromLanguageName,\n        image: parsedMeta.icon ?? \"\",\n        set_id: parsedMeta.set_id,\n        set_index: parsedMeta.set_index,\n        legacyCourseId: storyData.course_id,\n        text: docText,\n        json: toConvexValue(parsedStoryBase),\n        todo_count: parsedMeta.todo_count,\n        change_date: new Date().toISOString(),\n        confirmOfficialOverwrite,\n        operationKey: `story:${storyData.id}:set_story:v2:${Date.now()}:${saveStartRevision}`,\n      });\n      if (!result) {\n        throw new Error(`Story ${storyData.id} not found`);\n      }\n      setLastSavedAt(Date.now());\n      setLastSavedText(normalizeDocText(docText));\n      setSaveError(false);\n      setSaveErrorMessage(\"There was an error saving.\");\n    } catch (error) {\n      const message = toFriendlyError(error, \"saving\");\n      setSaveError(true);\n      setSaveErrorMessage(message);\n      throw new Error(message);\n    } finally {\n      setIsSaving(false);\n    }\n  }, [\n    docText,\n    isAdmin,\n    isDeleting,\n    isSaving,\n    parsedMeta.fromLanguageName,\n    parsedMeta.icon,\n    parsedMeta.set_id,\n    parsedMeta.set_index,\n    parsedMeta.todo_count,\n    parsedStoryBase,\n    revision,\n    setStoryMutation,\n    storyData.course_id,\n    storyData.duo_id,\n    storyData.id,\n    storyData.official,\n    toFriendlyError,\n  ]);\n\n  const remove = React.useCallback(async () => {\n    if (isSaving || isDeleting) return;\n    setIsDeleting(true);\n    try {\n      const result = await deleteStoryMutation({\n        legacyStoryId: storyData.id,\n        operationKey: `story:${storyData.id}:delete:v2:${Date.now()}`,\n      });\n      if (!result) throw new Error(`Story ${storyData.id} not found`);\n    } catch (error) {\n      const message = toFriendlyError(error, \"deleting\");\n      setSaveError(true);\n      setSaveErrorMessage(message);\n      throw new Error(message);\n    } finally {\n      setIsDeleting(false);\n    }\n  }, [\n    deleteStoryMutation,\n    isDeleting,\n    isSaving,\n    storyData.id,\n    toFriendlyError,\n  ]);\n\n  const markServerSynced = React.useCallback((text: string) => {\n    setLastSavedText(normalizeDocText(text));\n  }, []);\n\n  return {\n    parsedStory,\n    parsedMeta,\n    audioInsertLines,\n    save,\n    remove,\n    isSaving,\n    isDeleting,\n    saveError,\n    saveErrorMessage,\n    clearSaveError: () => setSaveError(false),\n    lastSavedAt,\n    dirty: normalizeDocText(docText) !== lastSavedText,\n    markServerSynced,\n  };\n}\n"
  },
  {
    "path": "src/app/layout.tsx",
    "content": "import React from \"react\";\nimport { Nunito } from \"next/font/google\";\nimport \"@/styles/global.css\";\nimport Script from \"next/script\";\nimport NavigationModeProvider from \"@/components/NavigationModeProvider\";\nimport ConvexClientProvider from \"@/components/providers/ConvexClientProvider\";\nimport PostHogUserIdentifier from \"@/components/providers/PostHogUserIdentifier\";\n\n// If loading a variable font, you don't need to specify the font weight\nconst nunito = Nunito({\n  subsets: [\"latin-ext\", \"cyrillic-ext\", \"vietnamese\"],\n  variable: \"--font-nunito\",\n});\n\nexport default function RootLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <html lang=\"en\" className={nunito.variable}>\n      <head>\n        <link rel=\"manifest\" href=\"/manifest.json\" />\n        <meta name=\"theme-color\" content=\"#1cb0f6\" />\n        <Script src=\"/darklight.js\"></Script>\n      </head>\n      <body>\n        <ConvexClientProvider>\n          <PostHogUserIdentifier />\n          <NavigationModeProvider>{children}</NavigationModeProvider>\n          {/*<AnalyticsTracker />*/}\n        </ConvexClientProvider>\n      </body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "src/app/manifest.json",
    "content": "{\n  \"name\": \"Duostories\",\n  \"short_name\": \"Duostories\",\n  \"description\": \"Learn languages with stories\",\n  \"start_url\": \"/learn\",\n  \"scope\": \"/\",\n  \"id\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#ffffff\",\n  \"theme_color\": \"#1cb0f6\",\n  \"orientation\": \"portrait\",\n  \"icons\": [\n    {\n      \"src\": \"icon512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"any\"\n    },\n    {\n      \"src\": \"icon512_maskable.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    },\n    {\n      \"src\": \"icon512_monochrome.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"monochrome\"\n    },\n    {\n      \"src\": \"icon192_maskable.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"screenshots\": [\n    {\n      \"src\": \"screenshot_1.jpg\",\n      \"sizes\": \"1080x1920\",\n      \"type\": \"image/jpeg\"\n    },\n    {\n      \"src\": \"screenshot_2.jpg\",\n      \"sizes\": \"1080x1920\",\n      \"type\": \"image/jpeg\"\n    },\n    {\n      \"src\": \"screenshot_3.jpg\",\n      \"sizes\": \"1080x1920\",\n      \"type\": \"image/jpeg\"\n    },\n    {\n      \"src\": \"screenshot_4.jpg\",\n      \"sizes\": \"1080x1920\",\n      \"type\": \"image/jpeg\"\n    },\n    {\n      \"src\": \"screenshot_5.jpg\",\n      \"sizes\": \"1080x1920\",\n      \"type\": \"image/jpeg\"\n    }\n  ]\n}\n"
  },
  {
    "path": "src/app/not-found.tsx",
    "content": "import Header from \"./(stories)/(main)/header\";\nimport Link from \"next/link\";\n\nexport default function NotFound() {\n  return (\n    <Header>\n      <h1>Page Not Found</h1>\n      <p>This Page does not exist.</p>\n      <p>\n        Go back to the <Link href={\"/\"}>main page</Link>.\n      </p>\n    </Header>\n  );\n}\n"
  },
  {
    "path": "src/components/Button/Button.tsx",
    "content": "export { default } from \"@/components/ui/button\";\nexport type {\n  ButtonProps,\n  ButtonSize,\n  ButtonVariant,\n} from \"@/components/ui/button\";\n"
  },
  {
    "path": "src/components/Button/index.ts",
    "content": "export * from \"./Button\";\nexport { default } from \"./Button\";\n"
  },
  {
    "path": "src/components/CheckButton/CheckButton.tsx",
    "content": "import React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction CheckButton({ type }: { type: string }) {\n  const className = cn(\n    \"relative h-[42px] min-w-10 select-none rounded-[9px] border-2 border-b-4 border-[var(--color_base_border)] bg-[var(--color_base_background)] text-[var(--color_base_color)]\",\n    type === \"done\" &&\n      \"border-[var(--color_disabled_border-color)] border-b-2 bg-[var(--color_disabled_background)] text-[var(--color_disabled_color)]\",\n    type === \"right\" &&\n      \"border-[var(--color_right_border-color)] bg-[var(--color_right_background)] text-[var(--color_right_color)]\",\n    type === \"false\" &&\n      \"animate-[story-checkbutton-false-to-disabled_1.5s] border-[var(--color_disabled_border-color)] border-b-2 bg-[var(--color_disabled_background)] text-[var(--color_disabled_color)]\",\n  );\n\n  return (\n    <button className={className} data-cy=\"button\" type=\"button\">\n      {type === \"right\" ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 bg-center bg-no-repeat\"\n          style={{\n            backgroundImage:\n              \"url(//d35aaqx5ub95lt.cloudfront.net/images/867bf7feaeebef6c4938d14983f4f9df.svg)\",\n          }}\n        />\n      ) : null}\n      {type === \"false\" ? (\n        <>\n          <span\n            aria-hidden=\"true\"\n            className=\"absolute inset-0 bg-center bg-no-repeat\"\n            style={{\n              backgroundImage:\n                \"url(//d35aaqx5ub95lt.cloudfront.net/images/c854160d63716d5ccede79734b63f36b.svg)\",\n            }}\n          />\n          <span\n            aria-hidden=\"true\"\n            className=\"absolute top-1/2 left-1/2 z-[1] h-[30px] w-[30px] -translate-x-1/2 -translate-y-1/2 bg-center bg-no-repeat\"\n            style={{\n              backgroundImage:\n                \"url(//d35aaqx5ub95lt.cloudfront.net/images/45590f17eefeed5ed18cef9ac9d1f7d2.svg)\",\n            }}\n          />\n        </>\n      ) : null}\n    </button>\n  );\n}\n\nexport default CheckButton;\n"
  },
  {
    "path": "src/components/CheckButton/index.ts",
    "content": "export * from \"./CheckButton\";\nexport { default } from \"./CheckButton\";\n"
  },
  {
    "path": "src/components/ContributorList.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { IconDiscord } from \"@/components/icons\";\n\ntype ContributorProfile = {\n  legacyUserId: number;\n  name: string;\n  image: string | null;\n  discordLinked: boolean;\n};\n\ntype ContributorInput =\n  | ContributorProfile\n  | {\n      legacyUserId?: number;\n      name?: string | null;\n      image?: string | null;\n      discordLinked?: boolean | null;\n    }\n  | string;\n\nfunction normalizeContributor(\n  contributor: ContributorInput,\n  index: number,\n): ContributorProfile {\n  if (typeof contributor === \"string\") {\n    const name = contributor.trim();\n    return {\n      legacyUserId: -(index + 1),\n      name: name || \"Unknown\",\n      image: null,\n      discordLinked: false,\n    };\n  }\n\n  const name = contributor.name?.trim() || \"Unknown\";\n  return {\n    legacyUserId:\n      typeof contributor.legacyUserId === \"number\"\n        ? contributor.legacyUserId\n        : -(index + 1),\n    name,\n    image:\n      typeof contributor.image === \"string\" && contributor.image.length > 0\n        ? contributor.image\n        : null,\n    discordLinked: contributor.discordLinked === true,\n  };\n}\n\nfunction ContributorAvatar({\n  contributor,\n  size,\n}: {\n  contributor: ContributorProfile;\n  size: \"sm\" | \"md\";\n}) {\n  const [imageFailed, setImageFailed] = React.useState(false);\n  const initial = contributor.name.trim().charAt(0).toUpperCase() || \"?\";\n  const sizeClass =\n    size === \"sm\" ? \"h-8 w-8 text-[13px]\" : \"h-10 w-10 text-[15px]\";\n  const showImage = contributor.image && !imageFailed;\n  const showDiscordFallback = !showImage && contributor.discordLinked;\n\n  return (\n    <div\n      className={`inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--profile-background)] font-bold text-[var(--profile-text)] ${sizeClass}`}\n      aria-hidden=\"true\"\n    >\n      {showImage ? (\n        <img\n          alt=\"\"\n          src={contributor.image ?? undefined}\n          className=\"h-full w-full object-cover\"\n          onError={() => setImageFailed(true)}\n        />\n      ) : showDiscordFallback ? (\n        <span className=\"scale-[0.62] text-[var(--profile-text)]\">\n          <IconDiscord />\n        </span>\n      ) : (\n        initial\n      )}\n    </div>\n  );\n}\n\nexport default function ContributorList({\n  contributors,\n  emptyLabel,\n  size = \"sm\",\n  muted = false,\n}: {\n  contributors: ContributorInput[];\n  emptyLabel?: string;\n  size?: \"sm\" | \"md\";\n  muted?: boolean;\n}) {\n  if (contributors.length === 0) {\n    return emptyLabel ? (\n      <p className=\"text-[var(--text-color-dim)]\">{emptyLabel}</p>\n    ) : null;\n  }\n\n  const normalizedContributors = contributors.map((contributor, index) =>\n    normalizeContributor(contributor, index),\n  );\n\n  return (\n    <ul className=\"m-0 flex list-none flex-wrap gap-3 p-0\">\n      {normalizedContributors.map((contributor, index) => (\n        <li\n          key={`${contributor.legacyUserId}-${contributor.name}-${index}`}\n          className={`inline-flex items-center gap-2 rounded-full border p-1 pr-3 ${\n            muted\n              ? \"border-[var(--overview-hr)] bg-[var(--body-background-faint)] text-[var(--text-color-dim)]\"\n              : \"border-[var(--overview-hr)] bg-[var(--body-background)] text-[var(--text-color)]\"\n          }`}\n          title={contributor.name}\n        >\n          <ContributorAvatar contributor={contributor} size={size} />\n          <span className={size === \"sm\" ? \"text-[14px]\" : \"text-[15px]\"}>\n            {contributor.name}\n          </span>\n        </li>\n      ))}\n    </ul>\n  );\n}\n"
  },
  {
    "path": "src/components/Docs/CustomMDXServer/CustomMDXServer.tsx",
    "content": "\"use no memo\";\nimport React from \"react\";\n//import { Code } from \"bright\";\nimport process_mdx from \"./process_mdx\";\nimport MdxTreeRoot from \"../MdxTree\";\n\nasync function CustomMDXServer({ source }: { source: string }) {\n  try {\n    const ast: any = await process_mdx(source, \"hast\", 0, false);\n    return <MdxTreeRoot {...ast} in_editor={false} />;\n  } catch (e) {\n    //console.log(e);\n    return <div>Parsing error</div>;\n  }\n}\n\nexport default CustomMDXServer;\n"
  },
  {
    "path": "src/components/Docs/CustomMDXServer/index.ts",
    "content": "export * from \"./CustomMDXServer\";\nexport { default } from \"./CustomMDXServer\";\n"
  },
  {
    "path": "src/components/Docs/CustomMDXServer/process_mdx.ts",
    "content": "import { compile, run } from \"@mdx-js/mdx\";\nimport { VFile } from \"vfile\";\nimport { Fragment, jsx, jsxs } from \"react/jsx-runtime\";\nimport remarkMath from \"remark-math\";\nimport rehypeKatex from \"rehype-katex\";\n//import rehypeMdxCodeProps from \"rehype-mdx-code-props\";\n//import remarkFrontmatter from \"remark-frontmatter\";\nimport remarkGfm from \"remark-gfm\";\n\n//import { Nodes as MdastNodes, Root as MdastRoot } from \"mdast\";\n\n//import { PluggableList } from \"@mdx-js/mdx/lib/core\";\n\ninterface TreeNode {\n  type: string;\n  value?: string;\n  position?: {\n    start: { line: number };\n    end: { line: number };\n  };\n  children?: TreeNode[];\n}\n\nexport default async function process_mdx(\n  value: string,\n  show: string = \"hast\",\n  offset: number = 0,\n  positions: boolean = true,\n): Promise<any> {\n  const development = false;\n  const generateJsx = false;\n  const outputFormatFunctionBody = false;\n\n  //const recmaPlugins: PluggableList = [];\n  //const rehypePlugins: PluggableList = [];\n  //const remarkPlugins: PluggableList = [];\n\n  // regex replacements inspired by SmartyPants\n  //value = value.replace(/---/g, \"&#8212;\");\n  //value = value.replace(/--/g, \"&#8211;\");\n  //value = value.replace(/\\.\\.\\./g, \"&#8230;\");\n  //value = value.replace(/\\. \\. \\./g, \"&#8230;\");\n\n  //if (directive) remarkPlugins.unshift(remarkDirective)\n  //remarkPlugins.unshift(remarkFrontmatter);\n  // remarkPlugins.unshift(remarkGfm);\n  // remarkPlugins.unshift(remarkMath);\n  // remarkPlugins.unshift(rehypeMdxCodeProps);\n  // rehypePlugins.unshift(rehypeKatex);\n  //if (raw) rehypePlugins.unshift([rehypeRaw, {passThrough: nodeTypes}])\n\n  const file = new VFile({\n    basename: \"example.mdx\",\n    value,\n  });\n\n  //if (show === \"esast\") recmaPlugins.push([captureEsast]);\n  //if (show === \"hast\") rehypePlugins.push([captureHast]);\n  //if (show === \"mdast\") remarkPlugins.push([captureMdast]);\n  let ast: TreeNode | null = null;\n\n  await compile(file, {\n    development: show === \"result\" ? false : development,\n    jsx: show === \"code\" || show === \"esast\" ? generateJsx : false,\n    outputFormat:\n      show === \"result\" || outputFormatFunctionBody\n        ? \"function-body\"\n        : \"program\",\n    //recmaPlugins,\n    rehypePlugins: [rehypeKatex, captureHast],\n    remarkPlugins: [remarkMath, remarkGfm],\n  });\n\n  if (show === \"result\") {\n    return await run(String(file), {\n      Fragment,\n      jsx,\n      jsxs,\n      baseUrl: typeof window !== \"undefined\" ? window.location.href : \"\",\n    });\n  }\n\n  function addOffset(tree: TreeNode): void {\n    if (tree.position) {\n      tree.position.start.line += offset;\n      tree.position.end.line += offset;\n    }\n    if (tree.children) {\n      for (const i of tree.children || []) {\n        addOffset(i);\n      }\n    }\n  }\n  if (ast) {\n    if (offset) addOffset(ast);\n    return ast;\n  }\n\n  return {};\n\n  function clean(tree: TreeNode): void {\n    delete tree.position;\n    for (const i of tree.children || []) {\n      clean(i);\n    }\n  }\n\n  function captureHast() {\n    return function (tree: TreeNode) {\n      let clone = structuredClone(tree);\n      if (!positions) clean(clone);\n      // delete text nodes with \"\\n\"\n      if (clone.children) {\n        clone.children = clone.children.filter(\n          (i) => i.type !== \"text\" || i.value !== \"\\n\",\n        );\n      }\n      ast = clone;\n    };\n  }\n}\n"
  },
  {
    "path": "src/components/Docs/MdxTree/MdxTree.tsx",
    "content": "\"use no memo\";\nimport React from \"react\";\nimport { Fragment } from \"react/jsx-runtime\";\n\nimport Link from \"next/link\";\n\nimport { MDXComponents } from \"mdx/types\";\nimport {\n  docsAlertBoxClass,\n  docsChannelLinkClass,\n  docsImageWrapperClass,\n  docsInfoBoxClass,\n  docsWarningBoxClass,\n} from \"../docsClasses\";\n\nfunction startsLowerCase(tagName: string) {\n  return (\n    tagName && tagName.substring(0, 1) === tagName.substring(0, 1).toLowerCase()\n  );\n}\n\nfunction node_to_string(children: any) {\n  let value = \"\";\n  if (typeof children === \"string\") value = children;\n  else if (typeof children === \"object\")\n    value = (children as string[]).join(\"\");\n  return value;\n}\n\nfunction save_tag(tag: string) {\n  try {\n    tag = node_to_string(tag);\n    return tag.trim().toLowerCase().replace(/\\s+/g, \"-\");\n  } catch (e) {\n    return null;\n  }\n}\n\nfunction Video({\n  src,\n  ...props\n}: { src: string } & React.VideoHTMLAttributes<any>) {\n  return (\n    <video className={\"mx-auto\"} controls {...props}>\n      <source src={src} />\n      Your browser does not support the video tag.\n    </video>\n  );\n}\n\nconst components: MDXComponents = {\n  blockquote: \"blockquote\",\n  br: \"br\",\n  Video: Video,\n  em: \"em\",\n  h1: \"h1\",\n  h2: (props: any) => (\n    <h2\n      {...props}\n      id={save_tag(props.children)}\n      className={`mt-10 mb-4 scroll-mt-28 text-[1.65rem] leading-[1.25] font-bold ${props.className ?? \"\"}`}\n    >\n      {props.children}\n    </h2>\n  ),\n  h3: (props: any) => (\n    <h3\n      {...props}\n      id={save_tag(props.children)}\n      className={`mt-8 mb-3 scroll-mt-28 text-[1.3rem] leading-[1.3] font-bold ${props.className ?? \"\"}`}\n    >\n      {props.children}\n    </h3>\n  ),\n  h4: (props: any) => (\n    <h4\n      {...props}\n      id={save_tag(props.children)}\n      className={`mt-6 mb-2 scroll-mt-28 text-[1.12rem] leading-[1.35] font-bold ${props.className ?? \"\"}`}\n    >\n      {props.children}\n    </h4>\n  ),\n  h5: \"h5\",\n  h6: \"h6\",\n  hr: \"hr\",\n  li: \"li\",\n  ol: \"ol\",\n  p: \"p\",\n  strong: \"strong\",\n  ul: \"ul\",\n  /* gfm */\n  del: \"del\",\n  input: \"input\",\n  section: \"section\",\n  sup: \"sup\",\n  table: \"table\",\n  tbody: \"tbody\",\n  td: \"td\",\n  th: \"th\",\n  thead: \"thead\",\n  tr: \"tr\",\n\n  span: \"span\",\n\n  Info: (props) => (\n    <p {...props} className={docsInfoBoxClass}>\n      {props.children}\n    </p>\n  ),\n  Warning: (props) => (\n    <p {...props} className={docsWarningBoxClass}>\n      {props.children}\n    </p>\n  ),\n  Alert: (props) => (\n    <p {...props} className={docsAlertBoxClass}>\n      {props.children}\n    </p>\n  ),\n  Channel: (props) => (\n    <Link {...props} className={docsChannelLinkClass}>\n      {props.children}\n    </Link>\n  ),\n  a: (props) => <Link href={props.href as string}>{props.children}</Link>,\n  Image: (props) => (\n    <div className={docsImageWrapperClass}>{props.children}</div>\n  ),\n};\n\nfunction getTreeLeaveID(i: any, index: number) {\n  for (const attr of i.attributes || []) {\n    if (attr.name === \"id\") {\n      return attr.value;\n    }\n  }\n  return index;\n}\n\nfunction toCamelCase(cssProperty: string) {\n  return cssProperty\n    .split(\"-\")\n    .map((part, index) => {\n      // If it's the first part, return it as is. Otherwise, capitalize the first letter.\n      return index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1);\n    })\n    .join(\"\");\n}\n\nfunction MdxTreeRoot({\n  children,\n  Code,\n  in_editor,\n}: {\n  children: any;\n  Code: any;\n  in_editor: boolean;\n}) {\n  return (\n    <>\n      {children.map((d: any, index: number) => (\n        <MdxTree\n          key={getTreeLeaveID(d, index)}\n          {...d}\n          in_editor={in_editor}\n          Code={Code}\n        />\n      ))}\n    </>\n  );\n}\n\nfunction MdxTree({\n  type,\n  tagName,\n  name,\n  value,\n  children,\n  properties,\n  position,\n  attributes,\n  in_editor,\n  Code,\n}: {\n  type: string;\n  tagName: string;\n  name: string;\n  value: any;\n  children: any[];\n  properties: any;\n  position: any;\n  attributes: any;\n  in_editor: boolean;\n  Code: any;\n}) {\n  const my_components = components;\n\n  if (type === \"text\") return value;\n  let Element: any = my_components[tagName];\n  if (type === \"root\") Element = Fragment;\n  if (type === \"mdxJsxFlowElement\") Element = my_components[name];\n  if (type === \"mdxJsxTextElement\") Element = my_components[name];\n\n  if (Element === undefined) {\n    if (my_components[tagName]) Element = my_components[tagName];\n    else if (startsLowerCase(tagName)) {\n      Element = tagName;\n    } else Element = Fragment;\n  }\n  if (Element === \"hr\") return <hr />;\n  if (Element === \"br\") return <br />;\n  if (Element === \"img\") return <img alt={\"\"} {...properties} />;\n\n  // convert camelCase to kebab-case\n  if (properties?.ariaHidden) {\n    properties[\"aria-hidden\"] = properties.ariaHidden;\n    delete properties.ariaHidden;\n  }\n\n  if (properties?.className && typeof properties?.className !== \"string\")\n    properties.className = properties.className.join(\" \");\n\n  // convert camelCase to kebab-case\n  if (properties?.class) {\n    properties.className = properties.class.split(\",\").join(\" \");\n    delete properties.class;\n  }\n\n  if (properties?.style) {\n    if (typeof properties.style === \"string\") {\n      const style: { [key: string]: string } = {};\n      for (const p of properties.style.split(\";\")) {\n        const [key, value] = p.split(\":\");\n        style[toCamelCase(key)] = value;\n      }\n      properties.style = style;\n    }\n  }\n  if (!properties) properties = {};\n  if (position && Element !== Fragment && in_editor) {\n    properties[\"data-start\"] = position.start.line;\n    properties[\"data-end\"] = position.end.line;\n    if (typeof Element !== \"string\") {\n      properties[\"position\"] = position;\n      //properties[\"in_editor\"] = in_editor;\n    }\n  }\n  if (type === \"mdxJsxFlowElement\" || type === \"mdxJsxTextElement\") {\n    if (attributes) {\n      for (const attr of attributes) {\n        if (attr.value?.type === \"mdxJsxAttributeValueExpression\") {\n          if (\n            attr.value.value.startsWith(\"`\") &&\n            attr.value.value.endsWith(\"`\")\n          )\n            properties[attr.name] = attr.value.value.substring(\n              1,\n              attr.value.value.length - 1,\n            );\n          else if (\n            attr.value.value.startsWith(\"{\") &&\n            attr.value.value.endsWith(\"}\")\n          ) {\n            properties[attr.name] = JSON.parse(attr.value.value);\n          } else properties[attr.name] = attr.value.value;\n        } else properties[attr.name] = attr.value;\n      }\n    }\n  }\n\n  if (Element === Fragment) {\n    //console.log(\"Unknown Element\", tagName, name, properties);\n  }\n  if (Element === Fragment) properties = {};\n\n  return (\n    <Element {...properties}>\n      {children !== undefined &&\n        children.map((d, index) =>\n          d.type === \"text\" ? d.value : <MdxTree key={index} {...d} />,\n        )}\n    </Element>\n  );\n}\nexport default MdxTreeRoot;\n"
  },
  {
    "path": "src/components/Docs/MdxTree/index.ts",
    "content": "export * from \"./MdxTree\";\nexport { default } from \"./MdxTree\";\n"
  },
  {
    "path": "src/components/Docs/docsClasses.ts",
    "content": "import { cn } from \"@/lib/utils\";\n\nexport const docsRootClass =\n  \"[--docs-background:white] [--docs-border:#fbfbfb] [--docs-toc-hover:#f6f6f6] [--docs-toc-active:#eae9eb] bg-[var(--docs-background)] text-black [&_a]:text-black\";\n\nexport const docsMainContainerClass = \"flex justify-center\";\n\nexport const docsHeaderBarClass =\n  \"sticky top-0 z-[2] w-full border-b-2 border-[var(--docs-border)] bg-[var(--docs-background)]\";\nexport const docsHeaderInnerClass =\n  \"mx-auto flex max-w-[1450px] items-baseline justify-start px-8 py-4\";\nexport const docsHeaderLogoClass = \"mr-auto [&_a]:no-underline\";\nexport const docsSearchButtonClass =\n  \"flex items-baseline whitespace-nowrap rounded-[10px] border-0 px-2 py-2 pl-4 !text-[16px] !leading-[1.6] outline-none\";\nexport const docsSearchLabelClass =\n  \"inline-block overflow-hidden text-ellipsis whitespace-nowrap max-[470px]:max-w-14\";\nexport const docsSearchShortcutClass =\n  \"ml-[50px] rounded-[5px] border border-[var(--docs-border)] bg-[var(--docs-background)] px-[3px] py-[3px]\";\n\nexport const docsDesktopNavigationClass =\n  \"hidden w-72 shrink-0 min-[1001px]:block\";\nexport const docsDesktopNavigationInnerClass =\n  \"fixed h-[calc(100vh-70px)] w-72 overflow-y-auto bg-[var(--docs-background)] p-4 pb-10 text-[16px] leading-[1.6]\";\nexport const docsMobileNavigationInnerClass =\n  \"overflow-y-auto bg-[var(--docs-background)] p-4 pb-10 text-[16px] leading-[1.6]\";\nexport const docsNavigationHeadingClass =\n  \"m-0 px-4 pt-6 pb-[6px] text-[16px] leading-[1.6] font-bold\";\nexport const docsNavigationListClass = \"m-0 flex w-full list-none flex-col p-0\";\nexport const docsNavigationItemClass = \"m-0 w-full list-none p-0\";\nexport function docsPageLinkClass(active: boolean) {\n  return cn(\n    \"block w-full rounded-lg px-4 py-1.5 no-underline font-light hover:bg-[var(--docs-toc-hover)]\",\n    active && \"bg-[var(--docs-toc-active)] font-bold\",\n  );\n}\n\nexport const docsMobileBreadcrumbClass =\n  \"sticky top-20 z-[1] flex h-12 w-full items-center gap-[6px] border-b-2 border-[var(--docs-border)] bg-[var(--docs-background)] px-2 py-2 pl-[26px] text-[15px] leading-6 min-[1001px]:hidden\";\nexport const docsUnstyledButtonClass =\n  \"grid cursor-pointer place-content-center rounded-md border-0 bg-transparent p-0\";\n\nexport const docsPageMainClass =\n  \"w-[100px] max-w-[880px] grow overflow-x-auto px-8 py-5 text-[calc(18/16*1rem)] max-[1280.98px]:w-[calc(100%-288px)] max-[1280.98px]:max-w-[calc(100%-288px)] max-[1000.98px]:w-full max-[1000.98px]:max-w-[1000px] [&_h1]:m-0 [&_h1]:text-left [&_h1]:text-[1.875rem] [&_h1]:leading-9 [&_h2]:mt-8 [&_h2]:mb-4 [&_h3]:m-[32px_0_16px] [&_ol]:my-4 [&_ol]:pl-[19px] [&_p]:my-4 [&_ul]:my-4 [&_ul]:pl-[19px]\";\nexport const docsHeaderIntroClass = \"mb-7 max-[640px]:mb-5\";\nexport const docsEditButtonContainerClass = \"mt-10 flex w-full justify-end\";\nexport const docsEditButtonClass =\n  \"rounded-[10px] border-2 border-[#f0f0f0] px-[15px] py-[5px] no-underline hover:border-[#d2d2d2]\";\nexport const docsFooterClass = \"mt-10 flex justify-between\";\nexport const docsFooterLinkClass = \"font-bold no-underline\";\nexport const docsRightTocClass =\n  \"sticky top-[84px] h-full w-[304px] shrink-0 max-[1280.98px]:hidden\";\nexport const docsRightTocInnerClass =\n  \"fixed h-[calc(100vh-70px)] w-[304px] overflow-y-auto pl-10 text-[16px] leading-[1.6] font-bold\";\n\nexport const docsBoxClass =\n  \"relative !mt-12 !mb-0 mx-[-16px] rounded-[10px] px-4 py-2 before:absolute before:top-0 before:left-0 before:-translate-y-full before:rounded-t-[10px] before:px-4 before:pt-1 before:font-bold\";\nexport const docsInfoBoxClass = cn(\n  docsBoxClass,\n  \"border border-[#3535f2] bg-[hsla(210.8,100%,71.4%,0.5)] before:content-['Info'] before:text-[#3535f2]\",\n);\nexport const docsWarningBoxClass = cn(\n  docsBoxClass,\n  \"border border-[#f29435] bg-[hsla(36,100%,71%,0.5)] before:content-['Warning'] before:text-[#f29435]\",\n);\nexport const docsAlertBoxClass = cn(\n  docsBoxClass,\n  \"border border-[#f23535] bg-[hsla(0,100%,71%,0.5)] before:content-['Alert'] before:text-[#f23535]\",\n);\nexport const docsChannelLinkClass =\n  \"rounded-[5px] bg-[#b5d9ff] px-1 py-0.5 font-mono no-underline hover:bg-[#89c3ff]\";\nexport const docsImageWrapperClass = \"mx-[-32px] overflow-auto px-8 [&_p]:m-0\";\n\nexport const docsSearchOverlayClass =\n  \"fixed inset-0 z-[16] hidden [backdrop-filter:blur(4px)_brightness(0.9)] data-[state=open]:block max-[640px]:bg-black/50 max-[640px]:[backdrop-filter:none]\";\nexport const docsSearchModalClass =\n  \"[--docs-background:white] [--docs-border:#fbfbfb] [--docs-toc-hover:#f6f6f6] [--docs-toc-active:#eae9eb] fixed top-[15%] left-1/2 z-[100] hidden w-[640px] -translate-x-1/2 overflow-hidden rounded-[12px] bg-[var(--docs-background)] text-[16px] leading-[1.6] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0px_1px_1px_rgba(0,0,0,0.02),0px_8px_16px_-4px_rgba(0,0,0,0.04),0px_24px_32px_-8px_rgba(0,0,0,0.06)] outline-none data-[state=open]:block max-[640px]:top-auto max-[640px]:bottom-0 max-[640px]:h-[80%] max-[640px]:w-full max-[640px]:translate-x-[-50%] max-[640px]:rounded-none\";\nexport const docsSearchTopRowClass =\n  \"flex border-b-2 border-[var(--docs-border)] p-[10px]\";\nexport const docsSearchInputClass =\n  \"w-full border-0 bg-transparent outline-none\";\nexport const docsSearchCloseButtonClass =\n  \"rounded-[5px] border border-[var(--docs-border)] bg-[var(--docs-background)] px-[5px] py-[5px]\";\nexport const docsSearchResultsClass = \"max-h-[400px] overflow-y-auto p-[10px]\";\nexport function docsSearchResultClass(type: \"main\" | \"sub\") {\n  return cn(\n    \"block overflow-hidden rounded-[5px] px-[5px] py-[5px] text-ellipsis whitespace-nowrap no-underline hover:bg-[#f6f6f6]\",\n    type === \"main\" && \"font-bold\",\n    type === \"sub\" && \"pl-10\",\n  );\n}\n"
  },
  {
    "path": "src/components/DocsBreadCrumbNav/DocsBreadCrumbNav.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport { showNavContext } from \"../DocsNavigationBackdrop\";\nimport { useSelectedLayoutSegment } from \"next/navigation\";\nimport VisuallyHidden from \"../VisuallyHidden\";\nimport {\n  docsMobileBreadcrumbClass,\n  docsUnstyledButtonClass,\n} from \"../Docs/docsClasses\";\n\nfunction DocsBreadCrumbNav({\n  path_titles,\n}: {\n  path_titles: Record<string, { group: string; title: string }>;\n}) {\n  const { setShow } = React.useContext(showNavContext);\n  const segment = useSelectedLayoutSegment() || \"\";\n  const current = path_titles[segment];\n\n  return (\n    <>\n      <div className={docsMobileBreadcrumbClass}>\n        <button\n          className={docsUnstyledButtonClass}\n          onClick={() => setShow(true)}\n        >\n          <VisuallyHidden>Open documentation navigation</VisuallyHidden>\n          <svg\n            id=\"toggle\"\n            width=\"30\"\n            height=\"30\"\n            version=\"1.1\"\n            viewBox=\"0 0 3.175 3.175\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g\n              fill=\"none\"\n              stroke=\"#000\"\n              strokeLinecap=\"square\"\n              strokeWidth=\".28222\"\n            >\n              <path d=\"m0.80839 0.88828h1.5582\" />\n              <path d=\"m0.80839 1.5875h1.5582\" />\n              <path d=\"m0.80839 2.2867h1.5582\" />\n            </g>\n          </svg>\n        </button>\n        <span>\n          {current && (\n            <>\n              {current.group ? `${current.group} › ` : null}{\" \"}\n              <b>{current.title}</b>\n            </>\n          )}\n        </span>\n      </div>\n    </>\n  );\n}\n\nexport default DocsBreadCrumbNav;\n"
  },
  {
    "path": "src/components/DocsBreadCrumbNav/index.ts",
    "content": "export * from \"./DocsBreadCrumbNav\";\nexport { default } from \"./DocsBreadCrumbNav\";\n"
  },
  {
    "path": "src/components/DocsHeader/DocsHeader.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport Link from \"next/link\";\nimport DocsSearchModal from \"../DocsSearchModal\";\nimport useKeypress from \"@/hooks/use-keypress.hook\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport {\n  docsHeaderBarClass,\n  docsHeaderInnerClass,\n  docsHeaderLogoClass,\n  docsSearchButtonClass,\n  docsSearchLabelClass,\n  docsSearchShortcutClass,\n} from \"../Docs/docsClasses\";\n\nfunction DocsHeader() {\n  const [showSearch, setShowSearch] = React.useState(false);\n  const [searchText, setSearchText] = React.useState(\"\");\n\n  function doShow(value: boolean) {\n    setShowSearch(value);\n    setSearchText(\"\");\n  }\n  useKeypress(\n    \"ctrl+k\",\n    (e: KeyboardEvent | number) => {\n      if (typeof e !== \"number\") {\n        e.preventDefault();\n        !showSearch && doShow(true);\n      }\n    },\n    \"keydown\",\n  );\n\n  return (\n    <>\n      <div className={docsHeaderBarClass}>\n        <div className={docsHeaderInnerClass}>\n          <div className={docsHeaderLogoClass}>\n            <Link href=\"/\">Duostories</Link>\n          </div>\n\n          <Dialog.Root open={showSearch} onOpenChange={doShow}>\n            <Dialog.Trigger asChild={true}>\n              <button\n                id=\"search\"\n                className={docsSearchButtonClass}\n                onClick={() => doShow(true)}\n              >\n                <span className={docsSearchLabelClass}>\n                  Search Documentation...\n                </span>\n                <span className={docsSearchShortcutClass}>Ctrl K</span>\n              </button>\n            </Dialog.Trigger>\n            <DocsSearchModal\n              showSearch={showSearch}\n              setShowSearch={doShow}\n              searchText={searchText}\n              setSearchText={setSearchText}\n            />\n          </Dialog.Root>\n        </div>\n      </div>\n    </>\n  );\n}\n\nexport default DocsHeader;\n"
  },
  {
    "path": "src/components/DocsHeader/index.ts",
    "content": "export * from \"./DocsHeader\";\nexport { default } from \"./DocsHeader\";\n"
  },
  {
    "path": "src/components/DocsNavigation/DocsNavigation.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport Link from \"next/link\";\nimport { showNavContext } from \"../DocsNavigationBackdrop\";\nimport { useSelectedLayoutSegment } from \"next/navigation\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\nimport {\n  docsDesktopNavigationClass,\n  docsDesktopNavigationInnerClass,\n  docsNavigationHeadingClass,\n  docsNavigationItemClass,\n  docsNavigationListClass,\n  docsMobileNavigationInnerClass,\n  docsPageLinkClass,\n} from \"../Docs/docsClasses\";\n\nfunction DocsNavigation({\n  data,\n}: {\n  data: {\n    navigation: { group: string; pages: { slug: string; title: string }[] }[];\n  };\n}) {\n  const { show, setShow } = React.useContext(showNavContext);\n\n  const segment = useSelectedLayoutSegment();\n\n  return (\n    <>\n      <div className={docsDesktopNavigationClass} id=\"toc\">\n        <div className={docsDesktopNavigationInnerClass}>\n          <NavigationContent data={data} segment={segment} setShow={setShow} />\n        </div>\n      </div>\n\n      <Sheet open={show} onOpenChange={setShow}>\n        <SheetContent\n          side=\"left\"\n          className=\"w-[min(18rem,calc(100vw-1.5rem))] p-0\"\n          aria-describedby={undefined}\n        >\n          <SheetHeader>\n            <SheetTitle>Documentation navigation</SheetTitle>\n          </SheetHeader>\n          <div className={docsMobileNavigationInnerClass}>\n            <NavigationContent\n              data={data}\n              segment={segment}\n              setShow={setShow}\n            />\n          </div>\n        </SheetContent>\n      </Sheet>\n    </>\n  );\n}\n\nfunction NavigationContent({\n  data,\n  segment,\n  setShow,\n}: {\n  data: {\n    navigation: { group: string; pages: { slug: string; title: string }[] }[];\n  };\n  segment: string | null;\n  setShow: (value: boolean) => void;\n}) {\n  return (\n    <>\n      {data.navigation.map((item, i) => (\n        <div key={i}>\n          {item.group ? (\n            <h5 className={docsNavigationHeadingClass}>{item.group}</h5>\n          ) : null}\n          <ul className={docsNavigationListClass}>\n            {item.pages.map((child, i) => (\n              <li className={docsNavigationItemClass} key={i}>\n                <PageLink\n                  page={child.slug}\n                  title={child.title}\n                  setShow={setShow}\n                  active={child.slug === segment}\n                />\n              </li>\n            ))}\n          </ul>\n        </div>\n      ))}\n    </>\n  );\n}\n\nfunction PageLink({\n  page,\n  title,\n  active,\n  setShow,\n}: {\n  page: string;\n  title: string;\n  active: boolean;\n  setShow: (value: boolean) => void;\n}) {\n  return (\n    <Link\n      href={`/docs/${page}`}\n      className={docsPageLinkClass(active)}\n      onClick={() => setShow(false)}\n    >\n      {title}\n    </Link>\n  );\n}\n\nexport default DocsNavigation;\n"
  },
  {
    "path": "src/components/DocsNavigation/index.ts",
    "content": "export * from \"./DocsNavigation\";\nexport { default } from \"./DocsNavigation\";\n"
  },
  {
    "path": "src/components/DocsNavigationBackdrop/DocsNavigationBackdrop.tsx",
    "content": "\"use client\";\nimport React from \"react\";\n\nexport const showNavContext = React.createContext({\n  show: false,\n  setShow: (value: boolean) => {},\n});\n\nfunction DocsNavigationBackdrop({ children }: { children: React.ReactNode }) {\n  const [show, setShow] = React.useState(false);\n  return (\n    <showNavContext.Provider value={{ show, setShow }}>\n      {children}\n    </showNavContext.Provider>\n  );\n}\n\nexport default DocsNavigationBackdrop;\n"
  },
  {
    "path": "src/components/DocsNavigationBackdrop/index.ts",
    "content": "export * from \"./DocsNavigationBackdrop\";\nexport { default } from \"./DocsNavigationBackdrop\";\n"
  },
  {
    "path": "src/components/DocsSearchModal/DocsSearchModal.tsx",
    "content": "import React from \"react\";\nimport useKeypress from \"@/hooks/use-keypress.hook\";\nimport Link from \"next/link\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport {\n  docsSearchCloseButtonClass,\n  docsSearchInputClass,\n  docsSearchModalClass,\n  docsSearchOverlayClass,\n  docsSearchResultClass,\n  docsSearchResultsClass,\n  docsSearchTopRowClass,\n} from \"../Docs/docsClasses\";\n\n// https://beta.duostories.org/docs/story-creation/import.mdx\nconst basefolder = \"/docs\";\n\nasync function getPageData(path: string) {\n  try {\n    const res = await (await fetch(basefolder + \"/\" + path + \".mdx\")).text();\n    let data = res.split(\"---\");\n    let metadata = {\n      body: \"\",\n      parts: [] as { type: string; text: string; link: string }[],\n      link: \"\",\n      title: \"\",\n      description: \"\",\n    };\n    for (let line of data[1].split(\"\\n\")) {\n      let pos = line.indexOf(\":\");\n      if (pos === -1) continue;\n      let key = line.slice(0, pos).trim();\n      let value =\n        line\n          .slice(pos + 1)\n          .trim()\n          .match(/\\s*\"(.*)\"\\s*/)?.[1] || \"\";\n      if (key == \"title\") metadata.title = value;\n      if (key == \"description\") metadata.description = value;\n    }\n    metadata.body = data[2];\n    let parts = [];\n    let current_link = path;\n    let current_index = undefined;\n    for (let line of metadata.body.split(\"\\n\")) {\n      line = line.trim();\n      if (line.substring(0, 1).match(/\\w/)) {\n        if (current_index !== undefined) {\n          parts[current_index].text += \" \" + line;\n        } else {\n          parts.push({ type: \"text\", text: line, link: current_link });\n          current_index = parts.length - 1;\n        }\n        continue;\n      }\n      current_index = undefined;\n      if (line.startsWith(\"#\")) {\n        parts.push({\n          type: \"heading\",\n          text: line.match(\"#*s*(.*)\")?.[1] || \"\",\n          link: current_link,\n        });\n      }\n    }\n    metadata.parts = parts;\n    metadata.link = path;\n    return metadata;\n  } catch (e) {\n    //console.log(\"getPageDate\", path, e);\n  }\n}\n\nimport { z } from \"zod\";\nconst schema = z.object({\n  navigation: z.array(\n    z.object({\n      group: z.string(),\n      pages: z.array(z.string()),\n    }),\n  ),\n});\n\ntype Page =\n  | {\n      body: string;\n      parts: {\n        type: string;\n        text: string;\n        link: string;\n      }[];\n      link: string;\n      title: string;\n      description: string;\n    }\n  | undefined;\nlet data: z.infer<typeof schema> | undefined = undefined;\nlet pages: Page[] | undefined = undefined;\n\nasync function loadAll() {\n  if (!data) {\n    const file_content = await (await fetch(\"/docs/docs.json\")).json();\n    data = schema.parse(file_content);\n  }\n  if (!pages) {\n    const new_pages: Page[] = [];\n    let promises = [];\n    for (let group of data.navigation) {\n      for (let page of group.pages) {\n        promises.push(getPageData(page).then((page) => new_pages.push(page)));\n      }\n    }\n    await Promise.all(promises);\n    pages = new_pages;\n  }\n  return pages;\n}\n\nfunction DocsSearchModal({\n  showSearch,\n  setShowSearch,\n  searchText,\n  setSearchText,\n}: {\n  showSearch: boolean;\n  setShowSearch: (show: boolean) => void;\n  searchText: string;\n  setSearchText: (text: string) => void;\n}) {\n  const ref = React.useRef<HTMLInputElement>(null);\n  const [searchResults, setSearchResults] = React.useState<\n    | {\n        link: string;\n        type: string;\n        text: string;\n      }[]\n    | undefined\n  >([]);\n  // close on Escape if open\n  useKeypress(\"Escape\", () => showSearch && setShowSearch(false));\n\n  React.useEffect(() => {\n    if (showSearch) {\n      //ref.current.focus();\n      setSearchResults(undefined);\n    }\n  }, [showSearch]);\n\n  async function search(value: string) {\n    setSearchText(value);\n    const pages = await loadAll();\n\n    const results = [];\n    for (let page of pages) {\n      if (!page) continue;\n      let found = false;\n      for (let part of page.parts) {\n        if (part.text.includes(value)) {\n          if (!found) {\n            found = true;\n            results.push({ link: page.link, type: \"main\", text: page.title });\n          }\n          results.push({ link: part.link, type: \"sub\", text: part.text });\n        }\n      }\n    }\n    setSearchResults(results);\n  }\n  /*\n            <Dialog.Title />\n          <Dialog.Description />\n   */\n\n  return (\n    <>\n      <Dialog.Portal>\n        <Dialog.Overlay className={docsSearchOverlayClass} />\n        <Dialog.Content className={docsSearchModalClass} id=\"search_modal\">\n          <div className={docsSearchTopRowClass}>\n            <input\n              className={docsSearchInputClass}\n              id=\"search_input\"\n              ref={ref}\n              placeholder=\" Search Documentation...\"\n              value={searchText}\n              onChange={(e) => search(e.target.value)}\n            ></input>\n            <Dialog.Close asChild={true}>\n              <button\n                className={docsSearchCloseButtonClass}\n                onClick={() => setShowSearch(false)}\n              >\n                Esc\n              </button>\n            </Dialog.Close>\n          </div>\n          <div className={docsSearchResultsClass} id=\"search_results\">\n            {searchResults === undefined && <span>Type to search</span>}\n            {searchResults && searchResults.length === 0 && (\n              <span>No results</span>\n            )}\n            {searchResults &&\n              searchResults.map((item, index) => (\n                <Link\n                  key={item.link + \"-\" + index}\n                  href={`/docs/${item.link}`}\n                  className={docsSearchResultClass(item.type as \"main\" | \"sub\")}\n                  data-type={item.type}\n                  onClick={() => setShowSearch(false)}\n                >\n                  {item.text}\n                </Link>\n              ))}\n          </div>\n        </Dialog.Content>\n      </Dialog.Portal>\n    </>\n  );\n}\n\nexport default DocsSearchModal;\n"
  },
  {
    "path": "src/components/DocsSearchModal/index.ts",
    "content": "export * from \"./DocsSearchModal\";\nexport { default } from \"./DocsSearchModal\";\n"
  },
  {
    "path": "src/components/EditorSSMLDisplay/EditorSSMLDisplay.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport { MicIcon } from \"lucide-react\";\nimport {\n  generate_audio_line,\n  timings_to_text,\n} from \"@/lib/editor/audio/audio_edit_tools\";\nimport type {\n  Audio,\n  StoryElementLine,\n  StoryElementHeader,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport type { EditorStateType } from \"@/app/editor/story/[story]/editor_state\";\n\ninterface EditorSSMLDisplayProps {\n  ssml: Audio[\"ssml\"];\n  element: StoryElementLine | StoryElementHeader;\n  editor?: EditorStateType;\n  onOpenAudioEditor?: (\n    element: StoryElementLine | StoryElementHeader,\n  ) => void | Promise<void>;\n}\n\nexport default function EditorSSMLDisplay({\n  ssml,\n  element,\n  editor,\n  onOpenAudioEditor,\n}: EditorSSMLDisplayProps) {\n  let [loading, setLoading] = React.useState(false);\n  let [error, setError] = React.useState<boolean>(false);\n  let line_id = \"ssml\" + ssml.id;\n\n  async function reload() {\n    setLoading(true);\n    let releaseAnchor: (() => void) | undefined;\n    try {\n      if (!editor) return;\n      const anchor = editor.create_audio_insert_anchor(ssml);\n      if (!anchor) return;\n      releaseAnchor = editor.track_audio_insert_anchor(anchor);\n      let { filename, keypoints } = await generate_audio_line(ssml);\n      let text = timings_to_text({ filename, keypoints });\n      editor.insert_audio_at_anchor(text, anchor);\n    } catch (e) {\n      console.error(\"error\", e);\n      setError(true);\n    } finally {\n      releaseAnchor?.();\n      setLoading(false);\n    }\n  }\n\n  return (\n    <>\n      <br />\n      <span className=\"en mr-[3px] rounded-[5px] bg-[var(--editor-ssml)] px-[5px] py-[2px] text-[0.8em]\">\n        {ssml.speaker}\n      </span>\n      <button\n        onClick={() => {\n          void onOpenAudioEditor?.(element);\n        }}\n        className=\"inline-flex h-[25px] w-[25px] shrink-0 items-center justify-center align-middle\"\n        title=\"Open sound editor\"\n        aria-label=\"Open sound editor\"\n        type=\"button\"\n      >\n        <MicIcon className=\"h-5 w-5\" />\n      </button>\n      {ssml.speaker ? (\n        error ? (\n          <span>\n            <img\n              title=\"error generating audio\"\n              alt=\"error\"\n              src=\"/editor/icons/error.svg\"\n            />\n          </span>\n        ) : (\n          <span\n            title={loading ? \"generating audio...\" : \"regenerate audio\"}\n            id={line_id}\n            className={\n              \"inline-block h-[25px] w-[25px] cursor-pointer bg-contain bg-center bg-no-repeat transition-transform \" +\n              (loading ? \"animate-[spin_2s_linear_infinite]\" : \"\") +\n              (!editor ? \" cursor-default opacity-50\" : \"\")\n            }\n            style={{\n              backgroundImage:\n                'url(\"https://carex.uber.space/stories/old/refresh.png\")',\n              transitionDuration: \"1s\",\n            }}\n            onClick={() => {\n              void reload();\n            }}\n          />\n        )\n      ) : (\n        <span>\n          <img\n            title=\"no speaker defined\"\n            alt=\"error\"\n            src=\"/editor/icons/error.svg\"\n          />\n        </span>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/EditorSSMLDisplay/index.ts",
    "content": "export { default } from \"./EditorSSMLDisplay\";\n"
  },
  {
    "path": "src/components/FadeGlideIn/FadeGlideIn.tsx",
    "content": "import React from \"react\";\nimport { motion } from \"framer-motion\";\nimport useScrollIntoView from \"@/hooks/use-scroll-into-view.hook\";\n\nfunction FadeGlideIn({\n  children,\n  show = true,\n  hidden,\n  disableScroll,\n}: {\n  children: React.ReactNode;\n  show?: boolean;\n  hidden?: boolean;\n  disableScroll?: boolean;\n}) {\n  const ref = useScrollIntoView(show && !hidden && !disableScroll);\n  if (hidden || !show) return null;\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 20 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.4, ease: \"easeOut\" }}\n      style={{\n        willChange: \"opacity, transform\",\n      }}\n      ref={ref}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nexport default FadeGlideIn;\n"
  },
  {
    "path": "src/components/FadeGlideIn/index.ts",
    "content": "export * from \"./FadeGlideIn\";\nexport { default } from \"./FadeGlideIn\";\n"
  },
  {
    "path": "src/components/LocalisationProvider/LocalisationProvider.tsx",
    "content": "\"use server\";\nimport React from \"react\";\nimport { get_localisation_dict } from \"@/lib/get_localisation\";\nimport { LocalisationProviderInner } from \"./LocalisationProviderContext\";\n\nasync function LocalisationProvider({\n  lang,\n  children,\n}: {\n  lang: number;\n  children: React.ReactNode;\n}) {\n  const data = await get_localisation_dict(lang);\n  return (\n    <LocalisationProviderInner data={data}>\n      {children}\n    </LocalisationProviderInner>\n  );\n}\n\nexport default LocalisationProvider;\n"
  },
  {
    "path": "src/components/LocalisationProvider/LocalisationProviderContext.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport get_localisation_func from \"@/lib/get_localisation_func\";\n\nconst localisationContext = React.createContext({} as Record<string, string>);\n\nexport function useLocalisation() {\n  const data = React.useContext(localisationContext);\n  if (!data) return () => \"\";\n  return get_localisation_func(data);\n}\n\nexport function LocalisationProviderInner({\n  data,\n  children,\n}: {\n  data: Record<string, string>;\n  children: React.ReactNode;\n}) {\n  return (\n    <localisationContext.Provider value={data}>\n      {children}\n    </localisationContext.Provider>\n  );\n}\n"
  },
  {
    "path": "src/components/LocalisationProvider/index.ts",
    "content": "export * from \"./LocalisationProvider\";\nexport { default } from \"./LocalisationProvider\";\n"
  },
  {
    "path": "src/components/NavigationModeProvider/NavigationModeProvider.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport { usePathname } from \"next/navigation\";\n\nconst navigationModeContext = React.createContext({\n  type: \"hard\" as \"hard\" | \"soft\",\n});\n\nexport function useNavigationMode() {\n  return React.useContext(navigationModeContext).type;\n}\n\nexport default function NavigationModeProvider({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const [type, setType] = React.useState<\"hard\" | \"soft\">(\"hard\");\n  const initialRender = React.useRef(true);\n  const pathname = usePathname();\n  React.useEffect(() => {\n    if (pathname === null) return;\n    if (!initialRender.current) {\n      setType(\"soft\");\n    } else {\n      setType(\"hard\");\n      initialRender.current = false;\n    }\n  }, [pathname]);\n  return (\n    <navigationModeContext.Provider value={{ type }}>\n      {children}\n    </navigationModeContext.Provider>\n  );\n}\n"
  },
  {
    "path": "src/components/NavigationModeProvider/index.ts",
    "content": "export * from \"./NavigationModeProvider\";\nexport { default } from \"./NavigationModeProvider\";\n"
  },
  {
    "path": "src/components/PlayAudio/PlayAudio.tsx",
    "content": "import React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction PlayAudio({\n  onClick,\n  rtl = false,\n}: {\n  onClick?: () => void;\n  rtl?: boolean;\n}) {\n  if (onClick === undefined) return null;\n  return (\n    <img\n      onClick={onClick}\n      src=\"https://d35aaqx5ub95lt.cloudfront.net/images/d636e9502812dfbb94a84e9dfa4e642d.svg\"\n      className={cn(\n        \"relative top-[2px] mr-2 inline-block h-[23px] w-7 shrink-0 cursor-pointer align-text-bottom\",\n        rtl && \"mr-0 ml-2 scale-x-[-1]\",\n      )}\n      alt=\"speaker\"\n    />\n  );\n}\n\nexport default PlayAudio;\n"
  },
  {
    "path": "src/components/PlayAudio/index.ts",
    "content": "export * from \"./PlayAudio\";\nexport { default } from \"./PlayAudio\";\n"
  },
  {
    "path": "src/components/ProgressBar/ProgressBar.tsx",
    "content": "import React, { CSSProperties } from \"react\";\n\nfunction ProgressBar({\n  progress,\n  length,\n}: {\n  progress: number;\n  length: number;\n}) {\n  return (\n    <div\n      className=\"h-4 w-full overflow-hidden rounded-full bg-[var(--progress-back)]\"\n      role=\"progressbar\"\n      aria-valuenow={progress}\n      aria-valuemin={0}\n      aria-valuemax={length}\n    >\n      <div\n        className=\"relative h-4 w-[var(--width)] rounded-full bg-[var(--progress-inside)] transition-[width] duration-200\"\n        style={{ \"--width\": (progress / length) * 100 + \"%\" } as CSSProperties}\n      >\n        <div className=\"absolute top-1/4 right-2 left-2 h-[30%] rounded-[inherit] bg-[var(--progress-highlight)] opacity-20\" />\n      </div>\n    </div>\n  );\n}\n\nexport default ProgressBar;\n"
  },
  {
    "path": "src/components/ProgressBar/index.ts",
    "content": "export * from \"./ProgressBar\";\nexport { default } from \"./ProgressBar\";\n"
  },
  {
    "path": "src/components/StoryAutoPlay/StoryAutoPlay.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport StoryTextLine from \"../StoryTextLine\";\nimport StoryHeader from \"../StoryHeader\";\nimport StoryHeaderProgress from \"../StoryHeaderProgress\";\nimport Legal from \"../layout/legal\";\nimport type { StoryType } from \"@/components/editor/story/syntax_parser_new\";\nimport type {\n  StoryElement,\n  StoryElementHeader,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport { cn } from \"@/lib/utils\";\n\ninterface StoryAutoPlayProps {\n  story: StoryType & {\n    id: number;\n    set_id?: number;\n    learning_language_rtl?: boolean;\n    learning_language?: string;\n    from_language?: string;\n    course_short?: string;\n  };\n}\n\nconst BLOB_PUBLIC_BASE =\n  \"https://ptoqrnbx8ghuucmt.public.blob.vercel-storage.com/\";\nconst FETCH_RETRIES = 2;\nconst LARGE_MERGE_SEGMENT_THRESHOLD = 60;\n\nconst autoPlaySettings = {\n  hide_questions: true,\n  show_all: true,\n  show_names: false,\n  rtl: false,\n  highlight_name: [],\n  hideNonHighlighted: false,\n  setHighlightName: () => {},\n  setHideNonHighlighted: () => {},\n  show_hints: true,\n  setShowHints: () => {},\n  show_audio: true,\n  setShowAudio: () => {},\n  id: 0,\n  show_title_page: false,\n};\n\ntype AutoPlayableElement = StoryElementHeader | StoryElementLine;\n\ntype TimelineSegment = {\n  lineIndex: number;\n  audioUrl: string;\n  displayElement: AutoPlayableElement;\n  keypoints?: { rangeEnd: number; audioStart: number }[];\n};\n\ntype TimelineMeta = {\n  lineIndex: number;\n  start: number;\n  duration: number;\n  keypoints?: { rangeEnd: number; audioStart: number }[];\n};\n\nfunction getParts(story: StoryType) {\n  const parts: StoryElement[][] = [];\n  let lastId = -1;\n  for (const element of story.elements) {\n    if (element.trackingProperties === undefined) continue;\n    if (lastId !== element.trackingProperties.line_index) {\n      parts.push([]);\n      lastId = element.trackingProperties.line_index;\n    }\n    parts[parts.length - 1].push(element);\n  }\n  return parts;\n}\n\nfunction toAbsoluteAudioUrl(url: string): string | null {\n  if (url.startsWith(\"blob:\")) return url;\n  if (/^https?:\\/\\//.test(url)) return url;\n  return `${BLOB_PUBLIC_BASE}${url.replace(/^\\/+/, \"\")}`;\n}\n\nfunction getElementAudioUrl(element: AutoPlayableElement): string | null {\n  const url =\n    element.type === \"HEADER\"\n      ? element.learningLanguageTitleContent?.audio?.url\n      : element.line?.content?.audio?.url;\n  if (!url) return null;\n  return toAbsoluteAudioUrl(url);\n}\n\nfunction getElementKeypoints(\n  element: AutoPlayableElement,\n): { rangeEnd: number; audioStart: number }[] | undefined {\n  const points =\n    element.type === \"HEADER\"\n      ? element.learningLanguageTitleContent?.audio?.keypoints\n      : element.line?.content?.audio?.keypoints;\n  return points?.length ? points : undefined;\n}\n\nfunction clearHints(element: StoryElement): StoryElement {\n  if (element.type === \"LINE\") {\n    return {\n      ...element,\n      hideRangesForChallenge: undefined,\n      line: {\n        ...element.line,\n        content: {\n          ...element.line.content,\n          hintMap: [],\n          hints_pronunciation: [],\n          audio: undefined,\n        },\n      },\n      audio: undefined,\n    };\n  }\n\n  if (element.type === \"HEADER\") {\n    return {\n      ...element,\n      learningLanguageTitleContent: {\n        ...element.learningLanguageTitleContent,\n        hintMap: [],\n        hints_pronunciation: [],\n        audio: undefined,\n      },\n      audio: undefined,\n    };\n  }\n\n  return element;\n}\n\nfunction findSegmentIndexByTime(meta: TimelineMeta[], time: number): number {\n  if (!meta.length) return -1;\n  for (let i = meta.length - 1; i >= 0; i--) {\n    if (time >= meta[i].start) return i;\n  }\n  return 0;\n}\n\nfunction formatTime(seconds: number): string {\n  const safe = Math.max(0, Math.floor(seconds));\n  const m = Math.floor(safe / 60);\n  const s = safe % 60;\n  return `${m}:${String(s).padStart(2, \"0\")}`;\n}\n\nasync function fetchArrayBufferWithRetry(url: string): Promise<ArrayBuffer> {\n  let lastError: unknown = null;\n\n  for (let attempt = 0; attempt <= FETCH_RETRIES; attempt++) {\n    try {\n      const response = await fetch(url);\n      if (!response.ok) {\n        throw new Error(`Could not download audio (${response.status}).`);\n      }\n      return await response.arrayBuffer();\n    } catch (error) {\n      lastError = error;\n      if (attempt === FETCH_RETRIES) break;\n      await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));\n    }\n  }\n\n  throw lastError instanceof Error\n    ? lastError\n    : new Error(\"Could not download audio after retries.\");\n}\n\nfunction mergeBuffersToWav(buffers: AudioBuffer[]): Blob {\n  if (!buffers.length) {\n    return new Blob([], { type: \"audio/wav\" });\n  }\n\n  const sampleRate = buffers[0].sampleRate;\n  for (let i = 1; i < buffers.length; i++) {\n    if (buffers[i].sampleRate !== sampleRate) {\n      throw new Error(\n        `Mixed sample rates are not supported (${sampleRate} vs ${buffers[i].sampleRate}).`,\n      );\n    }\n  }\n  const channels = Math.max(...buffers.map((b) => b.numberOfChannels));\n  const totalFrames = buffers.reduce((sum, b) => sum + b.length, 0);\n\n  const mergedChannels = Array.from(\n    { length: channels },\n    () => new Float32Array(totalFrames),\n  );\n\n  let writeOffset = 0;\n  for (const buffer of buffers) {\n    for (let channel = 0; channel < channels; channel++) {\n      const src = buffer.getChannelData(\n        Math.min(channel, buffer.numberOfChannels - 1),\n      );\n      mergedChannels[channel].set(src, writeOffset);\n    }\n    writeOffset += buffer.length;\n  }\n\n  const bytesPerSample = 2;\n  const blockAlign = channels * bytesPerSample;\n  const byteRate = sampleRate * blockAlign;\n  const dataSize = totalFrames * blockAlign;\n  const output = new ArrayBuffer(44 + dataSize);\n  const view = new DataView(output);\n\n  let offset = 0;\n  const writeStr = (text: string) => {\n    for (let i = 0; i < text.length; i++) {\n      view.setUint8(offset++, text.charCodeAt(i));\n    }\n  };\n\n  writeStr(\"RIFF\");\n  // WAV/RIFF numeric fields are little-endian by specification.\n  view.setUint32(offset, 36 + dataSize, true);\n  offset += 4;\n  writeStr(\"WAVE\");\n  writeStr(\"fmt \");\n  view.setUint32(offset, 16, true);\n  offset += 4;\n  view.setUint16(offset, 1, true);\n  offset += 2;\n  view.setUint16(offset, channels, true);\n  offset += 2;\n  view.setUint32(offset, sampleRate, true);\n  offset += 4;\n  view.setUint32(offset, byteRate, true);\n  offset += 4;\n  view.setUint16(offset, blockAlign, true);\n  offset += 2;\n  view.setUint16(offset, 16, true);\n  offset += 2;\n  writeStr(\"data\");\n  view.setUint32(offset, dataSize, true);\n  offset += 4;\n\n  for (let i = 0; i < totalFrames; i++) {\n    for (let channel = 0; channel < channels; channel++) {\n      const sample = Math.max(-1, Math.min(1, mergedChannels[channel][i]));\n      view.setInt16(\n        offset,\n        sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff),\n        true,\n      );\n      offset += 2;\n    }\n  }\n\n  return new Blob([output], { type: \"audio/wav\" });\n}\n\nexport default function StoryAutoPlay({ story }: StoryAutoPlayProps) {\n  const parts = React.useMemo(() => getParts(story), [story]);\n  const course =\n    story.course_short ?? `${story.learning_language}-${story.from_language}`;\n\n  const settings = {\n    ...autoPlaySettings,\n    rtl: story.learning_language_rtl ?? false,\n  };\n\n  const timelineSegments = React.useMemo(() => {\n    const entries: TimelineSegment[] = [];\n    for (const part of parts) {\n      for (const element of part) {\n        if (element.type !== \"HEADER\" && element.type !== \"LINE\") continue;\n\n        const audioUrl = getElementAudioUrl(element);\n        if (!audioUrl) continue;\n\n        const keypoints = getElementKeypoints(element);\n        const processed = clearHints(element);\n        if (processed.type !== \"HEADER\" && processed.type !== \"LINE\") continue;\n\n        entries.push({\n          lineIndex: processed.trackingProperties.line_index,\n          audioUrl,\n          keypoints,\n          displayElement: processed,\n        });\n      }\n    }\n    return entries;\n  }, [parts]);\n\n  const audioRef = React.useRef<HTMLAudioElement>(null);\n  const lineRefs = React.useRef<Record<number, HTMLDivElement | null>>({});\n\n  const [mergeState, setMergeState] = React.useState<\n    \"idle\" | \"building\" | \"ready\" | \"error\"\n  >(\"idle\");\n  const [errorMessage, setErrorMessage] = React.useState<string | null>(null);\n  const [mergedSrc, setMergedSrc] = React.useState<string | null>(null);\n  const [timelineMeta, setTimelineMeta] = React.useState<TimelineMeta[]>([]);\n  const [isPlaying, setIsPlaying] = React.useState(false);\n  const [duration, setDuration] = React.useState(0);\n  const [currentTime, setCurrentTime] = React.useState(0);\n  const [activeAudioRange, setActiveAudioRange] = React.useState(99999);\n  const [showLargeMergeWarning, setShowLargeMergeWarning] =\n    React.useState(false);\n\n  const buildMergedAudio = React.useCallback(async () => {\n    if (!timelineSegments.length) {\n      setMergeState(\"error\");\n      setErrorMessage(\"No audio segments found.\");\n      return null;\n    }\n\n    setMergeState(\"building\");\n    setErrorMessage(null);\n\n    const decodeCtx = new AudioContext({ latencyHint: \"playback\" });\n    try {\n      const decodedBuffers: AudioBuffer[] = [];\n      const meta: TimelineMeta[] = [];\n      let start = 0;\n\n      for (const segment of timelineSegments) {\n        const arrayBuffer = await fetchArrayBufferWithRetry(segment.audioUrl);\n        const decoded = await decodeCtx.decodeAudioData(arrayBuffer);\n        decodedBuffers.push(decoded);\n        meta.push({\n          lineIndex: segment.lineIndex,\n          start,\n          duration: decoded.duration,\n          keypoints: segment.keypoints,\n        });\n        start += decoded.duration;\n      }\n\n      const wavBlob = mergeBuffersToWav(decodedBuffers);\n      const nextSrc = URL.createObjectURL(wavBlob);\n\n      setTimelineMeta(meta);\n      setMergedSrc((prev) => {\n        if (prev) URL.revokeObjectURL(prev);\n        return nextSrc;\n      });\n      setDuration(start);\n      setCurrentTime(0);\n      setMergeState(\"ready\");\n      return nextSrc;\n    } catch (error) {\n      setMergeState(\"error\");\n      setErrorMessage(\n        error instanceof Error\n          ? error.message\n          : \"Failed to build merged audio.\",\n      );\n      return null;\n    } finally {\n      await decodeCtx.close();\n    }\n  }, [timelineSegments]);\n\n  React.useEffect(() => {\n    setMergeState(\"idle\");\n    setErrorMessage(null);\n    setTimelineMeta([]);\n    setDuration(0);\n    setCurrentTime(0);\n    setActiveAudioRange(99999);\n    setShowLargeMergeWarning(\n      timelineSegments.length >= LARGE_MERGE_SEGMENT_THRESHOLD,\n    );\n    setMergedSrc((prev) => {\n      if (prev) URL.revokeObjectURL(prev);\n      return null;\n    });\n  }, [timelineSegments]);\n\n  React.useEffect(() => {\n    return () => {\n      if (mergedSrc) URL.revokeObjectURL(mergedSrc);\n    };\n  }, [mergedSrc]);\n\n  const ensureMergedAndPlay = React.useCallback(async () => {\n    let src = mergedSrc;\n    if (!src) {\n      src = await buildMergedAudio();\n    }\n    if (!src || !audioRef.current) return;\n\n    if (audioRef.current.src !== src) {\n      audioRef.current.src = src;\n      audioRef.current.load();\n    }\n\n    try {\n      await audioRef.current.play();\n      setIsPlaying(true);\n    } catch (error) {\n      setIsPlaying(false);\n    }\n  }, [buildMergedAudio, mergedSrc]);\n\n  const pause = React.useCallback(() => {\n    audioRef.current?.pause();\n    setIsPlaying(false);\n  }, []);\n\n  const togglePlayPause = React.useCallback(async () => {\n    if (isPlaying) {\n      pause();\n      return;\n    }\n    await ensureMergedAndPlay();\n  }, [ensureMergedAndPlay, isPlaying, pause]);\n\n  const onSeek = React.useCallback(\n    (event: React.ChangeEvent<HTMLInputElement>) => {\n      const next = Number(event.target.value);\n      if (!Number.isFinite(next)) return;\n      const clamped = Math.max(0, Math.min(next, duration));\n      if (audioRef.current) {\n        audioRef.current.currentTime = clamped;\n      }\n      setCurrentTime(clamped);\n      setActiveAudioRange(99999);\n    },\n    [duration],\n  );\n\n  React.useEffect(() => {\n    const audio = audioRef.current;\n    if (!audio) return;\n\n    const onPlay = () => setIsPlaying(true);\n    const onPause = () => setIsPlaying(false);\n    const onEnded = () => {\n      setIsPlaying(false);\n      setCurrentTime(duration);\n      setActiveAudioRange(99999);\n    };\n    const onTimeUpdate = () => {\n      const t = audio.currentTime;\n      setCurrentTime(t);\n\n      const idx = findSegmentIndexByTime(timelineMeta, t);\n      const segment = idx >= 0 ? timelineMeta[idx] : undefined;\n      if (!segment?.keypoints?.length) {\n        setActiveAudioRange(99999);\n        return;\n      }\n\n      const segmentMs = Math.max(0, (t - segment.start) * 1000);\n      let nextRange = 99999;\n      for (const keypoint of segment.keypoints) {\n        if (segmentMs >= keypoint.audioStart) nextRange = keypoint.rangeEnd;\n        else break;\n      }\n      setActiveAudioRange(nextRange);\n    };\n\n    audio.addEventListener(\"play\", onPlay);\n    audio.addEventListener(\"pause\", onPause);\n    audio.addEventListener(\"ended\", onEnded);\n    audio.addEventListener(\"timeupdate\", onTimeUpdate);\n\n    return () => {\n      audio.removeEventListener(\"play\", onPlay);\n      audio.removeEventListener(\"pause\", onPause);\n      audio.removeEventListener(\"ended\", onEnded);\n      audio.removeEventListener(\"timeupdate\", onTimeUpdate);\n    };\n  }, [duration, timelineMeta]);\n\n  React.useEffect(() => {\n    void ensureMergedAndPlay();\n  }, [ensureMergedAndPlay]);\n\n  const activeLineIndex = React.useMemo(() => {\n    const idx = findSegmentIndexByTime(timelineMeta, currentTime);\n    return idx >= 0 ? timelineMeta[idx].lineIndex : undefined;\n  }, [currentTime, timelineMeta]);\n\n  React.useEffect(() => {\n    if (activeLineIndex === undefined) return;\n    const node = lineRefs.current[activeLineIndex];\n    if (!node || typeof window === \"undefined\") return;\n\n    const rect = node.getBoundingClientRect();\n    const desiredTop = window.innerHeight / 3;\n    const targetY = window.scrollY + rect.top - desiredTop;\n    window.scrollTo({ top: Math.max(0, targetY), behavior: \"smooth\" });\n  }, [activeLineIndex]);\n\n  React.useEffect(() => {\n    if (typeof navigator === \"undefined\" || !(\"mediaSession\" in navigator)) {\n      return;\n    }\n\n    const mediaSession = navigator.mediaSession;\n    mediaSession.playbackState = isPlaying ? \"playing\" : \"paused\";\n    if (typeof MediaMetadata !== \"undefined\") {\n      mediaSession.metadata = new MediaMetadata({\n        title: \"Story Autoplay\",\n        artist: `Duostories ${story.learning_language ?? \"\"}`.trim(),\n        album: \"Duostories\",\n      });\n    }\n\n    mediaSession.setActionHandler(\"play\", () => {\n      void ensureMergedAndPlay();\n    });\n    mediaSession.setActionHandler(\"pause\", () => {\n      pause();\n    });\n\n    return () => {\n      mediaSession.setActionHandler(\"play\", null);\n      mediaSession.setActionHandler(\"pause\", null);\n    };\n  }, [ensureMergedAndPlay, isPlaying, pause, story.learning_language]);\n\n  return (\n    <div className=\"min-h-screen\">\n      <StoryHeaderProgress\n        course={course}\n        setId={story.set_id}\n        progress={currentTime}\n        length={duration || 1}\n      />\n      <audio\n        ref={audioRef}\n        className=\"pointer-events-none fixed top-[-1000px] left-[-1000px] h-px w-px opacity-0\"\n        playsInline\n      />\n      <div className=\"mx-auto max-w-[500px]\">\n        <div className=\"sticky top-[60px] z-[11] grid grid-cols-[auto_1fr_auto] items-center gap-[10px] border-b border-[var(--overview-hr)] bg-[var(--body-background)] px-4 py-[10px] max-[760px]:grid-cols-1\">\n          <button\n            className=\"h-9 min-w-16 cursor-pointer rounded-full border border-[var(--input-border)] bg-[var(--color_base_background)] px-[10px] text-[var(--text-color)] disabled:cursor-not-allowed disabled:text-[var(--color_disabled_color)] disabled:opacity-50\"\n            onClick={() => {\n              void togglePlayPause();\n            }}\n            disabled={\n              mergeState === \"building\" || timelineSegments.length === 0\n            }\n          >\n            {mergeState === \"building\"\n              ? \"Building...\"\n              : isPlaying\n                ? \"Pause\"\n                : \"Play\"}\n          </button>\n          <input\n            className=\"autoplay-slider w-full\"\n            type=\"range\"\n            min={0}\n            max={Math.max(duration, 0.01)}\n            step={0.01}\n            value={Math.min(currentTime, duration || 0)}\n            onChange={onSeek}\n            disabled={mergeState !== \"ready\" || duration <= 0}\n          />\n          <div className=\"min-w-[72px] text-right text-[13px] text-[var(--text-color-dim)] max-[760px]:text-left\">\n            {formatTime(currentTime)} / {formatTime(duration)}\n          </div>\n          {showLargeMergeWarning && mergeState === \"idle\" && (\n            <div className=\"col-span-full text-[12px] text-[var(--text-color-dim)]\">\n              Large story: first play may take longer while audio is merged.\n            </div>\n          )}\n          {mergeState === \"error\" && (\n            <div className=\"col-span-full text-[12px] text-[#b02a2a]\">\n              Could not build merged audio: {errorMessage}\n            </div>\n          )}\n        </div>\n\n        <div\n          className={cn(\n            \"select-none px-4 pt-[85px]\",\n            story.learning_language_rtl && \"[direction:rtl]\",\n          )}\n        >\n          <div className=\"h-[10vh]\" />\n          <Legal />\n          {parts.map((part, partIndex) => (\n            <AutoPlayPart\n              key={partIndex}\n              part={part}\n              settings={settings}\n              activeLineIndex={activeLineIndex}\n              lineRefs={lineRefs}\n              activeAudioRange={activeAudioRange}\n            />\n          ))}\n        </div>\n        <div className=\"h-[20vh]\" />\n      </div>\n    </div>\n  );\n}\n\ninterface AutoPlayPartProps {\n  part: StoryElement[];\n  settings: typeof autoPlaySettings & { rtl: boolean };\n  activeLineIndex?: number;\n  lineRefs: React.MutableRefObject<Record<number, HTMLDivElement | null>>;\n  activeAudioRange: number;\n}\n\nfunction AutoPlayPart({\n  part,\n  settings,\n  activeLineIndex,\n  lineRefs,\n  activeAudioRange,\n}: AutoPlayPartProps) {\n  return (\n    <div className=\"part\">\n      {part.map((element, i) => (\n        <AutoPlayElement\n          key={i}\n          element={element}\n          settings={settings}\n          activeLineIndex={activeLineIndex}\n          lineRefs={lineRefs}\n          activeAudioRange={activeAudioRange}\n        />\n      ))}\n    </div>\n  );\n}\n\ninterface AutoPlayElementProps {\n  element: StoryElement;\n  settings: typeof autoPlaySettings & { rtl: boolean };\n  activeLineIndex?: number;\n  lineRefs: React.MutableRefObject<Record<number, HTMLDivElement | null>>;\n  activeAudioRange: number;\n}\n\nfunction AutoPlayElement({\n  element,\n  settings,\n  activeLineIndex,\n  lineRefs,\n  activeAudioRange,\n}: AutoPlayElementProps) {\n  const processedElement = clearHints(element);\n  const lineIndex = processedElement.trackingProperties?.line_index;\n  const visibleUntil = activeLineIndex ?? 0;\n\n  if (lineIndex !== undefined && lineIndex > visibleUntil) {\n    return null;\n  }\n\n  const isActive = activeLineIndex === lineIndex;\n  const className = cn(\n    \"rounded-xl transition-colors duration-150 ease-in-out\",\n    isActive && \"bg-[rgba(120,200,0,0.12)]\",\n  );\n\n  if (processedElement.type === \"HEADER\") {\n    return (\n      <div\n        className={className}\n        ref={(node) => {\n          if (lineIndex !== undefined) lineRefs.current[lineIndex] = node;\n        }}\n      >\n        <StoryHeader\n          active={false}\n          element={processedElement as StoryElementHeader}\n          settings={settings}\n          hideAudioButton={true}\n          audioRangeOverride={isActive ? activeAudioRange : undefined}\n        />\n      </div>\n    );\n  }\n\n  if (processedElement.type === \"LINE\") {\n    return (\n      <div\n        className={className}\n        ref={(node) => {\n          if (lineIndex !== undefined) lineRefs.current[lineIndex] = node;\n        }}\n      >\n        <StoryTextLine\n          active={false}\n          element={processedElement as StoryElementLine}\n          settings={settings}\n          hideAudioButton={true}\n          audioRangeOverride={isActive ? activeAudioRange : undefined}\n        />\n      </div>\n    );\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "src/components/StoryAutoPlay/index.ts",
    "content": "export { default } from \"./StoryAutoPlay\";\n"
  },
  {
    "path": "src/components/StoryChallengeArrange/StoryChallengeArrange.tsx",
    "content": "import React from \"react\";\nimport StoryQuestionPrompt from \"../StoryQuestionPrompt\";\nimport StoryTextLine from \"../StoryTextLine\";\nimport StoryQuestionArrange from \"../StoryQuestionArrange\";\nimport FadeGlideIn from \"../FadeGlideIn\";\nimport { StorySettings } from \"@/components/StoryProgress\";\nimport {\n  StoryElement,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\n\nfunction StoryChallengeArrange({\n  parts,\n  active,\n  hidden,\n  setButtonStatus,\n  settings,\n}: {\n  parts: StoryElement[];\n  active: boolean;\n  hidden: boolean;\n  setButtonStatus: (status: string) => void;\n  settings: StorySettings;\n}) {\n  const [unhide, setUnhide] = React.useState(0);\n  const id = React.useId();\n\n  if (parts.length !== 3) throw new Error(\"not the right element\");\n  const part_one = parts[0];\n  if (part_one.type !== \"CHALLENGE_PROMPT\")\n    throw new Error(\"not the right element\");\n\n  function advance(i: number, done: boolean) {\n    setUnhide(i);\n\n    if (done) {\n      setButtonStatus(\"right\");\n    }\n  }\n\n  React.useEffect(() => {\n    if (active && settings.hide_questions) {\n      setButtonStatus(\"continue\");\n    }\n  }, [active, setButtonStatus, settings.hide_questions]);\n\n  if (settings.hide_questions) {\n    return (\n      <FadeGlideIn\n        key={`${id}-1`}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryTextLine\n          active={active}\n          element={parts[1] as StoryElementLine}\n          settings={settings}\n        />\n      </FadeGlideIn>\n    );\n  }\n\n  return (\n    <>\n      <FadeGlideIn\n        key={`${id}-1`}\n        show={active || settings.show_all}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryQuestionPrompt question={part_one.prompt} lang={part_one.lang} />\n      </FadeGlideIn>\n      <FadeGlideIn\n        key={`${id}-2`}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryTextLine\n          active={active}\n          element={parts[1] as StoryElementLine}\n          unhide={unhide}\n          settings={settings}\n        />\n      </FadeGlideIn>\n      <FadeGlideIn\n        key={`${id}-3`}\n        show={active || settings.show_all}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryQuestionArrange\n          element={parts[2]}\n          active={active}\n          advance={advance}\n        />\n      </FadeGlideIn>\n    </>\n  );\n}\n\nexport default StoryChallengeArrange;\n"
  },
  {
    "path": "src/components/StoryChallengeArrange/index.ts",
    "content": "export * from \"./StoryChallengeArrange\";\nexport { default } from \"./StoryChallengeArrange\";\n"
  },
  {
    "path": "src/components/StoryChallengeContinuation/StoryChallengeContinuation.tsx",
    "content": "import React from \"react\";\nimport StoryQuestionPrompt from \"../StoryQuestionPrompt\";\nimport StoryTextLine from \"../StoryTextLine\";\nimport StoryQuestionMultipleChoice from \"../StoryQuestionMultipleChoice\";\nimport FadeGlideIn from \"../FadeGlideIn\";\nimport { StorySettings } from \"@/components/StoryProgress\";\nimport type {\n  StoryElement,\n  StoryElementChallengePrompt,\n  StoryElementLine,\n  StoryElementMultipleChoice,\n} from \"@/components/editor/story/syntax_parser_types\";\n\nfunction StoryChallengeContinuation({\n  parts,\n  setButtonStatus,\n  active,\n  hidden,\n  settings,\n}: {\n  parts: StoryElement[];\n  setButtonStatus: (status: string) => void;\n  active: boolean;\n  hidden: boolean;\n  settings: StorySettings;\n}) {\n  const [unhide, setUnhide] = React.useState(0);\n  const id = React.useId();\n  const prompt = parts[0] as StoryElementChallengePrompt;\n  const line = parts[1] as StoryElementLine;\n  const choice = parts[2] as StoryElementMultipleChoice;\n\n  function advance() {\n    setUnhide(-1);\n    setButtonStatus(\"right\");\n  }\n\n  React.useEffect(() => {\n    if (active && settings.hide_questions) {\n      setButtonStatus(\"continue\");\n    }\n  }, [active, setButtonStatus, settings.hide_questions]);\n\n  if (settings.hide_questions) {\n    return (\n      <FadeGlideIn\n        key={`${id}-1`}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryTextLine active={active} element={line} settings={settings} />\n      </FadeGlideIn>\n    );\n  }\n\n  return (\n    <>\n      <FadeGlideIn\n        key={`${id}-1`}\n        show={active || settings.show_all}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryQuestionPrompt question={prompt.prompt} lang={prompt.lang} />\n      </FadeGlideIn>\n      <FadeGlideIn\n        key={`${id}-2`}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryTextLine\n          active={active}\n          element={line}\n          unhide={unhide}\n          settings={settings}\n        />\n      </FadeGlideIn>\n      <FadeGlideIn\n        key={`${id}-3`}\n        show={active || settings.show_all}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryQuestionMultipleChoice\n          element={choice}\n          active={active}\n          advance={advance}\n        />\n      </FadeGlideIn>\n    </>\n  );\n}\n\nexport default StoryChallengeContinuation;\n"
  },
  {
    "path": "src/components/StoryChallengeContinuation/index.ts",
    "content": "export * from \"./StoryChallengeContinuation\";\nexport { default } from \"./StoryChallengeContinuation\";\n"
  },
  {
    "path": "src/components/StoryChallengeMatch/StoryChallengeMatch.tsx",
    "content": "import React from \"react\";\nimport StoryQuestionMatch from \"../StoryQuestionMatch\";\nimport FadeGlideIn from \"../FadeGlideIn\";\nimport { StorySettings } from \"@/components/StoryProgress\";\nimport type {\n  StoryElement,\n  StoryElementMatch,\n} from \"@/components/editor/story/syntax_parser_types\";\n\nfunction StoryChallengeMatch({\n  parts,\n  active,\n  hidden,\n  setButtonStatus,\n  settings,\n}: {\n  parts: StoryElement[];\n  active: boolean;\n  hidden: boolean;\n  setButtonStatus: (status: string) => void;\n  settings: StorySettings;\n}) {\n  const id = React.useId();\n  const element = parts[0] as StoryElementMatch;\n  if (settings.hide_questions) {\n    return null;\n  }\n  return (\n    <FadeGlideIn\n      key={`${id}-1`}\n      show={active || settings.show_all}\n      hidden={hidden}\n      disableScroll={settings.show_all}\n    >\n      <StoryQuestionMatch\n        active={active}\n        element={element}\n        setDone={() => setButtonStatus(\"right\")}\n      />\n    </FadeGlideIn>\n  );\n}\n\nexport default StoryChallengeMatch;\n"
  },
  {
    "path": "src/components/StoryChallengeMatch/index.ts",
    "content": "export * from \"./StoryChallengeMatch\";\nexport { default } from \"./StoryChallengeMatch\";\n"
  },
  {
    "path": "src/components/StoryChallengeMultipleChoice/StoryChallengeMultipleChoice.tsx",
    "content": "import React from \"react\";\nimport StoryTextLine from \"../StoryTextLine\";\nimport StoryQuestionMultipleChoice from \"../StoryQuestionMultipleChoice\";\nimport FadeGlideIn from \"../FadeGlideIn\";\nimport {\n  StoryElement,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport { StorySettings } from \"@/components/StoryProgress\";\n\nfunction StoryChallengeMultipleChoice({\n  parts,\n  partProgress,\n  setButtonStatus,\n  active,\n  hidden,\n  settings,\n}: {\n  parts: StoryElement[];\n  partProgress: number;\n  setButtonStatus: (status: string) => void;\n  active: boolean;\n  hidden: boolean;\n  settings: StorySettings;\n}) {\n  const part_one = parts[0];\n  const part_two = parts[1];\n\n  React.useEffect(() => {\n    if (active && partProgress === 0 && parts.length > 1) {\n      setButtonStatus(settings.hide_questions ? \"continue\" : \"idle\");\n    }\n  }, [\n    active,\n    partProgress,\n    parts.length,\n    setButtonStatus,\n    settings.hide_questions,\n  ]);\n\n  const id = React.useId();\n\n  const show_question =\n    active && (parts.length > 1 ? partProgress === 1 : partProgress === 0);\n\n  if (settings.hide_questions) {\n    if (parts.length === 1) return null;\n    return (\n      <FadeGlideIn\n        key={`${id}-1`}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryTextLine\n          active={active}\n          element={part_one as StoryElementLine}\n          settings={settings}\n        />\n      </FadeGlideIn>\n    );\n  }\n\n  if (parts.length === 1) {\n    if (part_one.type !== \"MULTIPLE_CHOICE\")\n      throw new Error(\"not the right element\");\n    return (\n      <>\n        <FadeGlideIn\n          key={`${id}-1`}\n          show={show_question || settings.show_all}\n          hidden={hidden}\n          disableScroll={settings.show_all}\n        >\n          <StoryQuestionMultipleChoice\n            element={part_one}\n            active={active}\n            advance={() => {\n              setButtonStatus(\"right\");\n            }}\n          />\n        </FadeGlideIn>\n      </>\n    );\n  }\n\n  if (part_two.type !== \"MULTIPLE_CHOICE\")\n    throw new Error(\"not the right element\");\n\n  return (\n    <>\n      <FadeGlideIn\n        key={`${id}-1`}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryTextLine\n          key={part_one.trackingProperties.line_index}\n          active={active && partProgress === 0}\n          element={part_one as StoryElementLine}\n          settings={settings}\n        />\n      </FadeGlideIn>\n      <FadeGlideIn\n        key={`${id}-2`}\n        show={show_question || settings.show_all}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryQuestionMultipleChoice\n          element={part_two}\n          active={active}\n          advance={() => {\n            setButtonStatus(\"right\");\n          }}\n        />\n      </FadeGlideIn>\n    </>\n  );\n}\n\nexport default StoryChallengeMultipleChoice;\n"
  },
  {
    "path": "src/components/StoryChallengeMultipleChoice/index.ts",
    "content": "export * from \"./StoryChallengeMultipleChoice\";\nexport { default } from \"./StoryChallengeMultipleChoice\";\n"
  },
  {
    "path": "src/components/StoryChallengePointToPhrase/StoryChallengePointToPhrase.tsx",
    "content": "import React from \"react\";\nimport StoryTextLine from \"../StoryTextLine\";\nimport StoryQuestionPointToPhrase from \"../StoryQuestionPointToPhrase\";\nimport FadeGlideIn from \"../FadeGlideIn\";\nimport {\n  StoryElement,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport { StorySettings } from \"@/components/StoryProgress\";\n\nfunction StoryChallengePointToPhrase({\n  parts,\n  partProgress,\n  setButtonStatus,\n  active,\n  hidden,\n  settings,\n}: {\n  parts: StoryElement[];\n  partProgress: number;\n  setButtonStatus: (status: string) => void;\n  active: boolean;\n  hidden: boolean;\n  settings: StorySettings;\n}) {\n  React.useEffect(() => {\n    if (!active) return;\n    if (settings.hide_questions) {\n      setButtonStatus(\"continue\");\n      return;\n    }\n    if (partProgress === 0) setButtonStatus(\"idle\");\n  }, [active, partProgress, setButtonStatus, settings.hide_questions]);\n\n  const id = React.useId();\n  const show_question = active && partProgress === 1;\n\n  if (settings.hide_questions) {\n    return (\n      <FadeGlideIn\n        key={`${id}-1`}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryTextLine\n          active={active}\n          element={parts[0] as StoryElementLine}\n          settings={settings}\n        />\n      </FadeGlideIn>\n    );\n  }\n\n  return (\n    <>\n      <FadeGlideIn\n        key={`${id}-1`}\n        show={!show_question}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryTextLine\n          element={parts[0] as StoryElementLine}\n          settings={settings}\n          active={active && partProgress === 0}\n        />\n      </FadeGlideIn>\n      <FadeGlideIn\n        key={`${id}-2`}\n        show={show_question || settings.show_all}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryQuestionPointToPhrase\n          active={active}\n          element={parts[1]}\n          advance={() => setButtonStatus(\"right\")}\n        />\n      </FadeGlideIn>\n    </>\n  );\n}\n\nexport default StoryChallengePointToPhrase;\n"
  },
  {
    "path": "src/components/StoryChallengePointToPhrase/index.ts",
    "content": "export * from \"./StoryChallengePointToPhrase\";\nexport { default } from \"./StoryChallengePointToPhrase\";\n"
  },
  {
    "path": "src/components/StoryChallengeSelectPhrases/StoryChallengeSelectPhrases.tsx",
    "content": "import React from \"react\";\nimport StoryQuestionPrompt from \"../StoryQuestionPrompt\";\nimport StoryTextLine from \"../StoryTextLine\";\nimport StoryQuestionSelectPhrase from \"../StoryQuestionSelectPhrase\";\nimport FadeGlideIn from \"../FadeGlideIn\";\nimport {\n  StoryElement,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport { StorySettings } from \"@/components/StoryProgress\";\n\nfunction StoryChallengeSelectPhrases({\n  parts,\n  setButtonStatus,\n  active,\n  hidden,\n  settings,\n}: {\n  parts: StoryElement[];\n  setButtonStatus: (status: string) => void;\n  active: boolean;\n  hidden: boolean;\n  settings: StorySettings;\n}) {\n  if (parts.length !== 3) throw new Error(\"not the right element\");\n  const part_one = parts[0];\n  if (part_one.type !== \"CHALLENGE_PROMPT\")\n    throw new Error(\"not the right element\");\n  const [unhide, setUnhide] = React.useState(0);\n  const id = React.useId();\n\n  function advance() {\n    setUnhide(-1);\n    setButtonStatus(\"right\");\n  }\n\n  React.useEffect(() => {\n    if (active && settings.hide_questions) {\n      setButtonStatus(\"continue\");\n    }\n  }, [active, setButtonStatus, settings.hide_questions]);\n\n  if (settings.hide_questions) {\n    return (\n      <FadeGlideIn\n        key={`${id}-1`}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryTextLine\n          active={active}\n          element={parts[1] as StoryElementLine}\n          settings={settings}\n        />\n      </FadeGlideIn>\n    );\n  }\n\n  return (\n    <>\n      <FadeGlideIn\n        key={`${id}-1`}\n        show={active || settings.show_all}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryQuestionPrompt question={part_one.prompt} />\n      </FadeGlideIn>\n      <FadeGlideIn\n        key={`${id}-2`}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryTextLine\n          active={active}\n          element={parts[1] as StoryElementLine}\n          unhide={unhide}\n          settings={settings}\n        />\n      </FadeGlideIn>\n      <FadeGlideIn\n        key={`${id}-3`}\n        show={active || settings.show_all}\n        hidden={hidden}\n        disableScroll={settings.show_all}\n      >\n        <StoryQuestionSelectPhrase\n          element={parts[2]}\n          active={active}\n          advance={advance}\n        />\n      </FadeGlideIn>\n    </>\n  );\n}\n\nexport default StoryChallengeSelectPhrases;\n"
  },
  {
    "path": "src/components/StoryChallengeSelectPhrases/index.ts",
    "content": "export * from \"./StoryChallengeSelectPhrases\";\nexport { default } from \"./StoryChallengeSelectPhrases\";\n"
  },
  {
    "path": "src/components/StoryEditorPreview/StoryEditorPreview.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport StoryTextLine from \"../StoryTextLine\";\nimport StoryHeader from \"../StoryHeader\";\nimport StoryQuestionMatch from \"../StoryQuestionMatch\";\nimport StoryQuestionArrange from \"../StoryQuestionArrange\";\nimport StoryQuestionPointToPhrase from \"../StoryQuestionPointToPhrase\";\nimport StoryQuestionSelectPhrase from \"../StoryQuestionSelectPhrase\";\nimport StoryQuestionMultipleChoice from \"../StoryQuestionMultipleChoice\";\nimport StoryQuestionPrompt from \"../StoryQuestionPrompt\";\nimport { useStoryEditorPreferences } from \"@/app/editor/_components/story_editor_preferences\";\nimport type { EditorStateType } from \"@/app/editor/story/[story]/editor_state\";\nimport type { StoryType } from \"@/components/editor/story/syntax_parser_new\";\nimport type {\n  StoryElement,\n  StoryElementError,\n  StoryElementHeader,\n  StoryElementLine,\n  StoryElementMultipleChoice,\n  StoryElementChallengePrompt,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport {\n  getEditorHandlers,\n  type EditorProps,\n} from \"@/lib/editor/editorHandlers\";\nimport { cn } from \"@/lib/utils\";\n\ninterface StoryEditorPreviewProps {\n  story: StoryType & { learning_language_rtl?: boolean };\n  editorState?: EditorStateType;\n  onOpenAudioEditor?: (\n    element: StoryElementLine | StoryElementHeader,\n  ) => void | Promise<void>;\n}\n\nfunction GetParts(story: StoryType) {\n  const parts: StoryElement[][] = [];\n  let last_id = -1;\n  for (const element of story.elements) {\n    if (element.trackingProperties === undefined) {\n      continue;\n    }\n    if (last_id !== element.trackingProperties.line_index) {\n      parts.push([]);\n      last_id = element.trackingProperties.line_index;\n    }\n    parts[parts.length - 1].push(element);\n  }\n  return parts;\n}\n\nexport default function StoryEditorPreview({\n  story,\n  editorState,\n  onOpenAudioEditor,\n}: StoryEditorPreviewProps) {\n  const parts = GetParts(story);\n  // In the editor these toggles control editor-only overlays:\n  // `showHints` reveals inline translations and `showAudio` reveals SSML tools.\n  // The base preview should still look like the live story when they are off.\n  const { showHints, showAudio } = useStoryEditorPreferences();\n\n  return (\n    <div\n      className={cn(\n        \"px-4 pt-[85px] select-none\",\n        story.learning_language_rtl && \"[direction:rtl]\",\n      )}\n    >\n      {parts.map((part, i) => (\n        <EditorPart\n          key={i}\n          part={part}\n          editorState={editorState}\n          rtl={story.learning_language_rtl ?? false}\n          showHints={showHints}\n          showAudio={showAudio}\n          onOpenAudioEditor={onOpenAudioEditor}\n        />\n      ))}\n    </div>\n  );\n}\n\ninterface EditorPartProps {\n  part: StoryElement[];\n  editorState?: EditorStateType;\n  rtl: boolean;\n  showHints: boolean;\n  showAudio: boolean;\n  onOpenAudioEditor?: (\n    element: StoryElementLine | StoryElementHeader,\n  ) => void | Promise<void>;\n}\n\nfunction EditorPart({\n  part,\n  editorState,\n  rtl,\n  showHints,\n  showAudio,\n  onOpenAudioEditor,\n}: EditorPartProps) {\n  const lastElement = part[part.length - 1];\n  const trackingProps = lastElement.trackingProperties as {\n    challenge_type?: string;\n    line_index: number;\n  };\n  const challenge_type = trackingProps?.challenge_type;\n\n  return (\n    <div className=\"part\" data-challengetype={challenge_type}>\n      {part.map((element, i) => (\n        <EditorElement\n          key={i}\n          element={element}\n          editorState={editorState}\n          rtl={rtl}\n          showHints={showHints}\n          showAudio={showAudio}\n          onOpenAudioEditor={onOpenAudioEditor}\n        />\n      ))}\n    </div>\n  );\n}\n\ninterface EditorElementProps {\n  element: StoryElement;\n  editorState?: EditorStateType;\n  rtl: boolean;\n  showHints: boolean;\n  showAudio: boolean;\n  onOpenAudioEditor?: (\n    element: StoryElementLine | StoryElementHeader,\n  ) => void | Promise<void>;\n}\n\nfunction EditorElement({\n  element,\n  editorState,\n  rtl,\n  showHints,\n  showAudio,\n  onOpenAudioEditor,\n}: EditorElementProps) {\n  // Keep the underlying story rendering aligned with the live experience.\n  // Editor toggles are passed separately as overrides below so they only affect\n  // editor-specific hint/audio details instead of hiding live story UI.\n  const editorSettings = {\n    hide_questions: false,\n    show_all: true,\n    show_names: false,\n    rtl,\n    highlight_name: [],\n    hideNonHighlighted: false,\n    setHighlightName: () => {},\n    setHideNonHighlighted: () => {},\n    show_hints: true,\n    setShowHints: () => {},\n    show_audio: true,\n    setShowAudio: () => {},\n    id: 0,\n    show_title_page: false,\n  };\n\n  if (element.type === \"HEADER\") {\n    return (\n      <StoryHeader\n        active={true}\n        element={element as StoryElementHeader}\n        settings={editorSettings}\n        editorState={editorState}\n        editorShowTranslationsOverride={showHints}\n        editorShowAudioDetailsOverride={showAudio}\n        onOpenAudioEditor={onOpenAudioEditor}\n      />\n    );\n  }\n\n  if (element.type === \"LINE\") {\n    return (\n      <StoryTextLine\n        active={true}\n        element={element as StoryElementLine}\n        settings={editorSettings}\n        editorState={editorState}\n        editorShowTranslationsOverride={showHints}\n        editorShowAudioDetailsOverride={showAudio}\n        onOpenAudioEditor={onOpenAudioEditor}\n      />\n    );\n  }\n\n  if (element.type === \"MULTIPLE_CHOICE\") {\n    return (\n      <EditorQuestionWrapper element={element} editorState={editorState}>\n        <StoryQuestionMultipleChoice\n          element={element as StoryElementMultipleChoice}\n          active={false}\n          advance={() => {}}\n        />\n      </EditorQuestionWrapper>\n    );\n  }\n\n  if (element.type === \"POINT_TO_PHRASE\") {\n    return (\n      <EditorQuestionWrapper element={element} editorState={editorState}>\n        <StoryQuestionPointToPhrase\n          element={element}\n          active={false}\n          advance={() => {}}\n        />\n      </EditorQuestionWrapper>\n    );\n  }\n\n  if (element.type === \"SELECT_PHRASE\") {\n    return (\n      <EditorQuestionWrapper element={element} editorState={editorState}>\n        <StoryQuestionSelectPhrase\n          element={element}\n          active={false}\n          advance={() => {}}\n        />\n      </EditorQuestionWrapper>\n    );\n  }\n\n  if (element.type === \"ARRANGE\") {\n    return (\n      <EditorQuestionWrapper element={element} editorState={editorState}>\n        <StoryQuestionArrange\n          element={element}\n          active={false}\n          advance={() => {}}\n        />\n      </EditorQuestionWrapper>\n    );\n  }\n\n  if (element.type === \"MATCH\") {\n    return (\n      <EditorQuestionWrapper element={element} editorState={editorState}>\n        <StoryQuestionMatch\n          active={false}\n          element={element}\n          setDone={() => {}}\n        />\n      </EditorQuestionWrapper>\n    );\n  }\n\n  if (element.type === \"CHALLENGE_PROMPT\") {\n    return (\n      <EditorChallengePrompt\n        element={element as StoryElementChallengePrompt}\n        editorState={editorState}\n        rtl={rtl}\n      />\n    );\n  }\n\n  if (element.type === \"ERROR\") {\n    return <EditorError element={element} editorState={editorState} />;\n  }\n\n  return null;\n}\n\nfunction EditorError({\n  element,\n  editorState,\n}: {\n  element: StoryElementError;\n  editorState?: EditorStateType;\n}) {\n  const editorProps: EditorProps = {\n    editorState,\n    editorBlock: element.editor,\n  };\n  const { onClick } = getEditorHandlers(editorProps);\n  const title =\n    element.errorKind === \"unknown_block\"\n      ? \"Unknown Block\"\n      : element.errorKind === \"invalid_line\"\n        ? \"Unexpected Line\"\n        : \"Parse Error\";\n\n  return (\n    <div\n      className=\"my-3 cursor-pointer rounded-[10px] border border-[#d97706] border-l-[5px] border-l-[#b45309] bg-[#fff7ed] px-[14px] py-3 text-[#7c2d12]\"\n      onClick={onClick}\n      data-lineno={element.editor?.block_start_no}\n    >\n      <div className=\"mb-2 flex items-center justify-between gap-3\">\n        <span className=\"text-[0.92rem] font-bold tracking-[0.02em] uppercase\">\n          {title}\n        </span>\n        {element.lineNumber ? (\n          <span className=\"shrink-0 rounded-full bg-[#fed7aa] px-2 py-[2px] text-[0.8rem] font-semibold\">\n            Line {element.lineNumber}\n          </span>\n        ) : null}\n      </div>\n      <div className=\"text-[0.98rem] font-semibold\">{element.text}</div>\n      {element.sourceLine ? (\n        <code className=\"mt-[10px] block overflow-x-auto rounded-lg bg-[#ffedd5] px-[10px] py-2 text-[0.9rem] whitespace-pre-wrap text-[#9a3412]\">\n          {element.sourceLine}\n        </code>\n      ) : null}\n      {element.details ? (\n        <div className=\"mt-2 text-[0.9rem]\">{element.details}</div>\n      ) : null}\n    </div>\n  );\n}\n\ninterface EditorQuestionWrapperProps {\n  element: Exclude<StoryElement, { type: \"ERROR\" }>;\n  editorState?: EditorStateType;\n  children: React.ReactNode;\n}\n\nfunction EditorQuestionWrapper({\n  element,\n  editorState,\n  children,\n}: EditorQuestionWrapperProps) {\n  const editorProps: EditorProps = {\n    editorState,\n    editorBlock: element.editor,\n  };\n  const { onClick } = getEditorHandlers(editorProps);\n\n  return (\n    <div onClick={onClick} data-lineno={element.editor?.block_start_no}>\n      {children}\n    </div>\n  );\n}\n\ninterface EditorChallengePromptProps {\n  element: StoryElementChallengePrompt;\n  editorState?: EditorStateType;\n  rtl: boolean;\n}\n\nfunction EditorChallengePrompt({\n  element,\n  editorState,\n  rtl,\n}: EditorChallengePromptProps) {\n  const editorProps: EditorProps = {\n    editorState,\n    editorBlock: element.editor,\n  };\n  const { onClick } = getEditorHandlers(editorProps);\n\n  return (\n    <div\n      className={cn(\"my-4\", element.lang, rtl && \"[direction:rtl]\")}\n      onClick={onClick}\n      data-lineno={element.editor?.block_start_no}\n    >\n      <StoryQuestionPrompt question={element.prompt.text} lang={element.lang} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/StoryEditorPreview/index.ts",
    "content": "export { default } from \"./StoryEditorPreview\";\n"
  },
  {
    "path": "src/components/StoryFinishedScreen/StoryFinishedScreen.tsx",
    "content": "import React from \"react\";\nimport useScrollIntoView from \"@/hooks/use-scroll-into-view.hook\";\nimport { useLocalisation } from \"../LocalisationProvider/LocalisationProviderContext\";\nimport { StoryData } from \"@/app/(stories)/story/[story_id]/getStory\";\n\nfunction StoryFinishedScreen({\n  story,\n  disableScroll,\n}: {\n  story: StoryData;\n  disableScroll?: boolean;\n}) {\n  const localisation = useLocalisation();\n  const ref = useScrollIntoView(!disableScroll);\n\n  return (\n    <div\n      ref={ref}\n      id=\"finishedPage\"\n      className=\"mb-[100px] flex min-h-[calc(100vh-100px)] w-full items-center justify-center border-t-2 border-t-[var(--overview-hr)] px-4 pt-8 pb-6 max-[500px]:pt-6\"\n      data-hidden={false}\n      data-cy=\"finished\"\n    >\n      <div className=\"w-full max-w-[420px] text-center\">\n        <div className=\"relative inline-block h-[200px] w-[200px] max-[500px]:h-[170px] max-[500px]:w-[170px]\">\n          {/* add the three blinking stars */}\n          <div>\n            <div\n              className=\"absolute top-5 left-[-30px] h-[20.4px] w-[20.4px] rounded-[3.3px] bg-[var(--finished-star-gold)] opacity-50 [transform:rotate(-45deg)_scale(1)]\"\n              style={{ animation: \"story-finished-star 2s 0.1s\" }}\n            />\n            <div\n              className=\"absolute right-[-15px] bottom-[-20px] h-[19.2px] w-[19.2px] rounded-[3.3px] bg-[var(--finished-star-gold)] opacity-50 [transform:rotate(-45deg)_scale(1)]\"\n              style={{ animation: \"story-finished-star 2s 0.3s\" }}\n            />\n            <div\n              className=\"absolute top-[-10px] left-0 h-[12.2px] w-[12.2px] rounded-[3.3px] bg-[var(--finished-star-gold)] opacity-50 [transform:rotate(-45deg)_scale(1)]\"\n              style={{ animation: \"story-finished-star 2s 0.2s\" }}\n            />\n          </div>\n          {/* the icon of the story which changes from color to golden */}\n          <div className=\"relative inline-block h-full w-full overflow-visible p-0\">\n            <img\n              src={story.illustrations.active}\n              className=\"absolute top-0 left-0 h-full w-full\"\n              alt=\"\"\n            />\n            <img\n              src={story.illustrations.gilded}\n              className=\"absolute top-0 left-0 h-full w-full opacity-100\"\n              style={{ animation: \"story-finished-fade-in 2s\" }}\n              alt=\"\"\n            />\n          </div>\n        </div>\n        {/* the text showing that the story is done */}\n        <h2>{localisation(\"story_finished\")}</h2>\n        <p>\n          {localisation(\"story_finished_subtitle\", {\n            $story_title: story.from_language_name,\n          }) || `You finished ${story.from_language_name}`}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport default StoryFinishedScreen;\n"
  },
  {
    "path": "src/components/StoryFinishedScreen/index.ts",
    "content": "export * from \"./StoryFinishedScreen\";\nexport { default } from \"./StoryFinishedScreen\";\n"
  },
  {
    "path": "src/components/StoryFooter/StoryFooter.tsx",
    "content": "import React from \"react\";\nimport Button from \"@/components/ui/button\";\nimport { useLocalisation } from \"../LocalisationProvider/LocalisationProviderContext\";\nimport { cn } from \"@/lib/utils\";\n\nconst footerIconStyle = {\n  backgroundImage:\n    \"url(//d35aaqx5ub95lt.cloudfront.net/images/icon-sprite8.svg)\",\n  backgroundPosition: \"-166px -90px\",\n};\n\nconst footerBaseClassName =\n  \"fixed bottom-0 left-0 z-10 flex h-[140px] w-full items-center border-t-2 border-[var(--header-border)] bg-[var(--body-background)] p-[30px] max-[800px]:h-auto max-[800px]:p-[18px_16px] max-[500px]:justify-stretch max-[500px]:border-t-0 max-[500px]:p-4\";\n\nconst widthWrapperBaseClassName =\n  \"mx-auto flex w-full max-w-[921px] items-center justify-end max-[500px]:[&_button]:w-full\";\n\nfunction Message({ children }: { children: React.ReactNode }) {\n  return (\n    <div className=\"text-[calc(25/16*1rem)] font-bold text-[var(--footer-right-color)]\">\n      {children}\n    </div>\n  );\n}\n\nfunction Check() {\n  return (\n    <div className=\"inline-block h-20 w-20 shrink-0 rounded-[98px] bg-[var(--footer-icon-backgroud)] animate-[story-footer-check-pop_0.4s_ease-in-out_forwards] max-[500px]:hidden\">\n      <span\n        aria-hidden=\"true\"\n        className=\"mt-[27px] ml-5 block h-[31px] w-[41px] bg-no-repeat\"\n        style={footerIconStyle}\n      />\n    </div>\n  );\n}\n\nfunction StoryFooter({\n  buttonStatus,\n  onClick,\n  onBackToOverview,\n  finishedLabel,\n  nextStoryPreview,\n  learningLanguageName,\n  showFinishedPrimaryAction = true,\n}: {\n  buttonStatus: string;\n  onClick: () => void;\n  onBackToOverview?: () => void | Promise<void>;\n  finishedLabel?: string;\n  nextStoryPreview?: {\n    id: number;\n    title: string;\n    active: string;\n    gilded: string;\n  } | null;\n  learningLanguageName?: string;\n  showFinishedPrimaryAction?: boolean;\n}) {\n  const localisation = useLocalisation();\n\n  const onContinueClick = React.useCallback(() => {\n    if (typeof window !== \"undefined\") {\n      window.sessionStorage.setItem(\"story_autoplay_ts\", String(Date.now()));\n    }\n    onClick();\n  }, [onClick]);\n\n  if (buttonStatus === \"...\") {\n    return (\n      <div className={footerBaseClassName}>\n        <div className={widthWrapperBaseClassName}>\n          <Button key={\"c\"} onClick={onContinueClick}>\n            {\"...\"}\n          </Button>\n        </div>\n      </div>\n    );\n  }\n\n  if (buttonStatus === \"finished\") {\n    return (\n      <div className={footerBaseClassName}>\n        <div\n          className={cn(\n            widthWrapperBaseClassName,\n            \"grid justify-stretch gap-4 [grid-template-columns:minmax(170px,220px)_minmax(0,1fr)_minmax(170px,220px)] max-[800px]:grid-cols-1 max-[800px]:gap-3\",\n          )}\n        >\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            className=\"mt-px min-h-14 max-[800px]:order-1 max-[800px]:w-full\"\n            onClick={() => {\n              void onBackToOverview?.();\n            }}\n          >\n            Back to overview\n          </Button>\n          <div className=\"flex min-w-0 items-center justify-center max-[800px]:order-2\">\n            {nextStoryPreview ? (\n              <div className=\"w-full min-w-0 min-[801px]:relative\">\n                <div className=\"mb-2 text-center text-[calc(12/16*1rem)] font-bold tracking-[0.08em] text-[var(--title-color-dim)] uppercase min-[801px]:absolute min-[801px]:top-[-24px] min-[801px]:left-0 min-[801px]:mb-0 min-[801px]:w-full\">\n                  Up next\n                </div>\n                <div className=\"relative mx-auto flex min-h-[76px] max-w-[360px] items-center gap-3 overflow-visible rounded-[18px] border-2 border-[var(--overview-hr)] bg-[color:color-mix(in_srgb,var(--body-background)_90%,white_10%)] py-[10px] pr-3 pl-[18px]\">\n                  <div className=\"ml-[-6px] h-[52px] w-[52px] shrink-0 overflow-visible\">\n                    <img\n                      alt=\"\"\n                      className=\"block h-full w-full object-cover\"\n                      src={nextStoryPreview.active}\n                    />\n                  </div>\n                  <div className=\"min-w-0\">\n                    <div className=\"overflow-hidden text-ellipsis whitespace-nowrap text-[calc(17/16*1rem)] leading-[1.2] font-bold text-[var(--text-color-dim)]\">\n                      {nextStoryPreview.title}\n                    </div>\n                    <div className=\"mt-0.5 text-[calc(14/16*1rem)] text-[var(--title-color-dim)]\">\n                      {learningLanguageName\n                        ? `Continue in ${learningLanguageName}`\n                        : \"Next story in this course\"}\n                    </div>\n                  </div>\n                </div>\n              </div>\n            ) : (\n              <div className=\"min-h-[76px]\" />\n            )}\n          </div>\n          {showFinishedPrimaryAction ? (\n            <div className=\"max-[800px]:order-3 max-[800px]:w-full max-[800px]:[&_button]:w-full\">\n              <Button key={\"c\"} onClick={onContinueClick}>\n                {finishedLabel ?? localisation(\"button_finished\") ?? \"finished\"}\n              </Button>\n            </div>\n          ) : (\n            <div className=\"min-h-14 max-[800px]:order-3\" />\n          )}\n        </div>\n      </div>\n    );\n  }\n\n  if (buttonStatus === \"wait\") {\n    return (\n      <div className={footerBaseClassName}>\n        <div className={widthWrapperBaseClassName}>\n          <Button key={\"c\"} disabled onClick={onContinueClick}>\n            {localisation(\"button_continue\") || \"continue\"}\n          </Button>\n        </div>\n      </div>\n    );\n  }\n  if (buttonStatus !== \"right\") {\n    return (\n      <div className={footerBaseClassName}>\n        <div className={widthWrapperBaseClassName}>\n          <Button key={\"c\"} onClick={onContinueClick}>\n            {localisation(\"button_continue\") || \"continue\"}\n          </Button>\n        </div>\n      </div>\n    );\n  }\n  return (\n    <div\n      className={cn(\n        footerBaseClassName,\n        \"border-[var(--footer-right-background)] bg-[var(--footer-right-background)] text-[var(--footer-right-color)]\",\n      )}\n    >\n      <div\n        className={cn(\n          widthWrapperBaseClassName,\n          \"justify-between max-[500px]:relative\",\n        )}\n      >\n        <div className=\"mr-auto flex items-center gap-4 max-[500px]:absolute max-[500px]:-top-[76px] max-[500px]:right-[-16px] max-[500px]:bottom-0 max-[500px]:left-[-16px] max-[500px]:z-[-1] max-[500px]:animate-[story-footer-banner-slide_0.2s_cubic-bezier(0.35,1.8,0.35,0.83)_forwards] max-[500px]:items-start max-[500px]:bg-[var(--footer-right-background)] max-[500px]:px-8 max-[500px]:py-4\">\n          <Check />\n          <Message>\n            {localisation(\"story_correct\") || \"You are correct\"}\n          </Message>\n        </div>\n        <div className=\"max-[500px]:w-full max-[500px]:[&_button]:w-full\">\n          <Button key={\"c\"} onClick={onContinueClick}>\n            {localisation(\"button_continue\") || \"continue\"}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default StoryFooter;\n"
  },
  {
    "path": "src/components/StoryFooter/index.ts",
    "content": "export * from \"./StoryFooter\";\nexport { default } from \"./StoryFooter\";\n"
  },
  {
    "path": "src/components/StoryHeader/StoryHeader.tsx",
    "content": "import React from \"react\";\nimport useAudio from \"../StoryTextLine/use-audio.hook\";\nimport PlayAudio from \"../PlayAudio\";\nimport StoryLineHints from \"../StoryLineHints\";\nimport StoryTextLineSimple from \"../StoryTextLineSimple\";\nimport EditorSSMLDisplay from \"../EditorSSMLDisplay\";\nimport {\n  StoryElementHeader,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport { StorySettings } from \"@/components/StoryProgress\";\nimport type { EditorStateType } from \"@/app/editor/story/[story]/editor_state\";\nimport {\n  getEditorHandlers,\n  type EditorProps,\n} from \"@/lib/editor/editorHandlers\";\n\nfunction StoryHeader({\n  active,\n  element,\n  settings,\n  editorState,\n  editorShowTranslationsOverride,\n  editorShowAudioDetailsOverride,\n  onOpenAudioEditor,\n  audioRangeOverride,\n  hideAudioButton = false,\n}: {\n  active: boolean;\n  element: StoryElementHeader;\n  settings: StorySettings;\n  editorState?: EditorStateType;\n  editorShowTranslationsOverride?: boolean;\n  editorShowAudioDetailsOverride?: boolean;\n  onOpenAudioEditor?: (\n    element: StoryElementLine | StoryElementHeader,\n  ) => void | Promise<void>;\n  audioRangeOverride?: number;\n  hideAudioButton?: boolean;\n}) {\n  const editorProps: EditorProps = {\n    editorState,\n    editorBlock: element.editor,\n  };\n  const { onClick } = getEditorHandlers(editorProps);\n  const [audioRange, playAudio, ref, url] = useAudio(\n    element,\n    active,\n    settings.show_audio,\n  );\n  const effectiveAudioRange = audioRangeOverride ?? audioRange;\n  const isRtl = settings.rtl || element.lang === \"rtl\";\n  const showEditorAudioDetails =\n    editorShowAudioDetailsOverride ?? settings.show_audio;\n\n  const hideRangesForChallenge = undefined;\n\n  if (settings?.show_names) {\n    const name = \"Narrator\";\n    if (!settings?.highlight_name.includes(name) && settings.hideNonHighlighted)\n      return null;\n    return (\n      <StoryTextLineSimple\n        speaker={\"Narrator\"}\n        highlight={settings?.highlight_name.includes(name)}\n        id={settings?.id + \"-\" + 0}\n      >\n        {element.learningLanguageTitleContent.text}\n      </StoryTextLineSimple>\n    );\n  }\n\n  return (\n    <div\n      className={`text-center ${element.lang}`}\n      style={{ textAlign: \"center\" }}\n      onClick={onClick}\n      data-lineno={element?.editor?.block_start_no}\n    >\n      <div>\n        <img\n          alt=\"title image\"\n          className=\"mx-auto block h-[175px] w-[175px]\"\n          src={element.illustrationUrl}\n        />\n      </div>\n      <h1 className=\"m-0 text-[25px] leading-[34px] font-bold\">\n        {url && (\n          <audio ref={ref}>\n            <source src={url} type=\"audio/mp3\" />\n          </audio>\n        )}\n        {!hideAudioButton && settings.show_audio && (\n          <PlayAudio onClick={playAudio} rtl={isRtl} />\n        )}\n        <StoryLineHints\n          showHints={settings.show_hints}\n          showTranslationsInline={editorShowTranslationsOverride}\n          audioRange={effectiveAudioRange}\n          hideRangesForChallenge={hideRangesForChallenge}\n          content={element.learningLanguageTitleContent}\n          editorState={editorState}\n        />\n        {showEditorAudioDetails &&\n          element.audio &&\n          (editorState || onOpenAudioEditor) && (\n            <EditorSSMLDisplay\n              ssml={element.audio.ssml}\n              element={element}\n              editor={editorState}\n              onOpenAudioEditor={onOpenAudioEditor}\n            />\n          )}\n      </h1>\n    </div>\n  );\n}\n\nexport default StoryHeader;\n"
  },
  {
    "path": "src/components/StoryHeader/index.ts",
    "content": "export * from \"./StoryHeader\";\nexport { default } from \"./StoryHeader\";\n"
  },
  {
    "path": "src/components/StoryHeaderProgress/StoryHeaderProgress.tsx",
    "content": "\"use client\";\nimport React from \"react\";\nimport Link from \"next/link\";\nimport ProgressBar from \"../ProgressBar\";\nimport VisuallyHidden from \"../VisuallyHidden\";\n\nconst closeIconStyle = {\n  backgroundImage:\n    \"url(//d35aaqx5ub95lt.cloudfront.net/images/icon-sprite8.svg)\",\n  backgroundPosition: \"-373px -154px\",\n};\n\ninterface StoryHeaderProgressProps {\n  course: string;\n  setId?: number;\n  progress?: number;\n  length?: number;\n  editHref?: string;\n}\n\nfunction PencilIcon() {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"h-6 w-6\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M4 20L8.5 18.9L18.4 9C19.2 8.2 19.2 6.9 18.4 6.1L17.9 5.6C17.1 4.8 15.8 4.8 15 5.6L5.1 15.5L4 20Z\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M13.5 7L17 10.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction StoryHeaderProgress({\n  course,\n  setId,\n  progress,\n  length,\n  editHref,\n}: StoryHeaderProgressProps) {\n  const courseHref = (setId ?? 0) > 0 ? `/${course}#${setId}` : `/${course}`;\n\n  return (\n    <div className=\"sticky top-0 z-[1] mx-auto flex max-w-[1000px] items-center gap-4 bg-[var(--body-background)] px-10 py-10 max-[500px]:px-5 max-[500px]:py-[17px]\">\n      <Link\n        className=\"inline-block h-[18px] w-[18px] shrink-0 align-middle bg-no-repeat\"\n        data-cy=\"quit\"\n        href={courseHref}\n        style={closeIconStyle}\n      >\n        <VisuallyHidden>Back to Course Page</VisuallyHidden>\n      </Link>\n      {progress !== undefined && length !== undefined && (\n        <ProgressBar progress={progress} length={length} />\n      )}\n      {editHref ? (\n        <Link\n          href={editHref}\n          className=\"ml-2 inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[var(--header-border)] bg-[var(--body-background)] text-[var(--text-color-dim)] transition-colors duration-100 hover:border-[color:color-mix(in_srgb,var(--link-blue)_22%,var(--header-border))] hover:bg-[color:color-mix(in_srgb,var(--overview-hr)_35%,var(--body-background))] hover:text-[var(--text-color)] active:bg-[color:color-mix(in_srgb,var(--overview-hr)_55%,var(--body-background))] max-[500px]:h-10 max-[500px]:w-10\"\n        >\n          <PencilIcon />\n          <VisuallyHidden>Edit story</VisuallyHidden>\n        </Link>\n      ) : null}\n    </div>\n  );\n}\n\nexport default StoryHeaderProgress;\n"
  },
  {
    "path": "src/components/StoryHeaderProgress/index.ts",
    "content": "export { default } from \"./StoryHeaderProgress\";\n"
  },
  {
    "path": "src/components/StoryLineHints/StoryLineHints.tsx",
    "content": "import React, { CSSProperties } from \"react\";\nimport { ContentWithHints } from \"@/components/editor/story/syntax_parser_types\";\nimport type { EditorStateType } from \"@/app/editor/story/[story]/editor_state\";\nimport { cn } from \"@/lib/utils\";\n\nconst underlineBaseClass =\n  \"bg-[position:0_100%] bg-repeat-x bg-[length:5px_2px] leading-[2em] pb-[5px]\";\nconst revealedUnderlineStyle: CSSProperties = {\n  backgroundImage:\n    \"linear-gradient(to right, var(--underline-dashed) 60%, rgba(255, 255, 255, 0) 0%)\",\n};\nconst hiddenUnderlineStyle: CSSProperties = {\n  backgroundImage:\n    \"linear-gradient(to right, var(--underline-solid) 60%, var(--underline-solid) 60%)\",\n};\nconst editorHintContainerStyle: CSSProperties = {\n  borderInlineStart: \"1px solid #bfbfbf\",\n  paddingInlineStart: \"5px\",\n};\nconst editorHintTextStyle: CSSProperties = {\n  marginInlineStart: \"-4px\",\n  paddingInlineStart: \"6px\",\n};\n\nfunction splitTextTokens(text: string, keep_tilde = true) {\n  if (!text) return [];\n  if (keep_tilde)\n    //return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}]+)/)\n    return text.split(/([\\s\\\\¡!\"#$%&*,./:;<=>¿?@^_`{|}]+)/);\n  //return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}~]+)/)\n  else return text.split(/([\\s\\\\¡!\"#$%&*,./:;<=>¿?@^_`{|}~]+)/);\n}\n\nfunction Tooltip({\n  className,\n  children,\n  interactive = false,\n  ...delegated\n}: {\n  className?: string;\n  children: React.ReactNode;\n  interactive?: boolean;\n} & React.HTMLAttributes<HTMLSpanElement>) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n\n  function positionTooltip() {\n    if (!ref.current) return;\n    const tooltipElement = ref.current.children[1];\n    if (!(tooltipElement instanceof HTMLElement)) return;\n\n    // Calculate the position of the tooltip\n    const tooltipRect = tooltipElement.getBoundingClientRect();\n\n    let offset = 0;\n    if (tooltipElement.style.left.split(\" \").length >= 2) {\n      offset = parseInt(\n        tooltipElement.style.left.split(\" \")[2].split(\"px)\")[0],\n      );\n    }\n    // Check if the tooltip would be cut off on the right\n    if (tooltipRect.right + offset > window.innerWidth) {\n      tooltipElement.style.left = `calc(50% + ${\n        window.innerWidth - tooltipRect.right - offset\n      }px)`;\n    }\n    // check if the tooltip would be cuf off on the left\n    else if (tooltipRect.left - offset < 0) {\n      tooltipElement.style.left = `calc(50% + ${-tooltipRect.left + offset}px)`;\n    } else {\n      tooltipElement.style.left = `50%`;\n    }\n  }\n  return (\n    <span\n      onMouseEnter={positionTooltip}\n      onFocus={positionTooltip}\n      ref={ref}\n      className={className}\n      tabIndex={interactive ? 0 : undefined}\n      {...delegated}\n    >\n      {children}\n    </span>\n  );\n}\n\nfunction StoryLineHints({\n  content,\n  showHints = true,\n  showTranslationsInline,\n  audioRange,\n  hideRangesForChallenge,\n  unhide,\n  editorState,\n}: {\n  content: ContentWithHints;\n  showHints?: boolean;\n  showTranslationsInline?: boolean;\n  audioRange?: number;\n  hideRangesForChallenge?: { start: number; end: number }[];\n  unhide?: number;\n  editorState?: EditorStateType;\n}) {\n  if (!content) return <>Empty</>;\n  const visibleContent = showHints\n    ? content\n    : {\n        ...content,\n        hintMap: [],\n        hints: undefined,\n        hints_pronunciation: undefined,\n      };\n  let hideRangesForChallengeEntry = hideRangesForChallenge\n    ? hideRangesForChallenge[0]\n    : hideRangesForChallenge;\n\n  if (hideRangesForChallengeEntry) {\n    if (unhide === -1) hideRangesForChallengeEntry = undefined;\n    else if (unhide && unhide > hideRangesForChallengeEntry.start)\n      hideRangesForChallengeEntry = {\n        start: unhide,\n        end: Math.max(hideRangesForChallengeEntry.end, unhide),\n      };\n  }\n  const editor = editorState;\n\n  const showTrans = showTranslationsInline ?? false;\n\n  function getOverlap(\n    start1: number,\n    end1: number,\n    start2: number,\n    end2: number,\n  ) {\n    if (start2 === end2) return false;\n    if (start2 === undefined || end2 === undefined) return false;\n    if (start1 <= start2 && start2 < end1) return true;\n    return start2 <= start1 && start1 < end2;\n  }\n\n  function addWord2(start: number, end: number) {\n    const was_hidden_for_challenge = hideRangesForChallenge?.some((range) =>\n      getOverlap(start, end, range.start, range.end),\n    );\n    let is_hidden: boolean | undefined | \"editor\" =\n      hideRangesForChallengeEntry !== undefined &&\n      getOverlap(\n        start,\n        end,\n        hideRangesForChallengeEntry.start,\n        hideRangesForChallengeEntry.end,\n      )\n        ? true\n        : undefined;\n    if (is_hidden && editor) is_hidden = \"editor\";\n    const style: CSSProperties = {};\n    //TODO\n    //if(is_hidden && window.view)\n    //    style.color = \"#afafaf\";\n    if (audioRange && audioRange < start) style.opacity = 0.5;\n    if (was_hidden_for_challenge && !is_hidden) {\n      Object.assign(style, revealedUnderlineStyle);\n    }\n    if (is_hidden) {\n      Object.assign(style, hiddenUnderlineStyle);\n    }\n\n    const returns = [\n      <span\n        className={cn(\n          \"select-text\",\n          (was_hidden_for_challenge || is_hidden) && underlineBaseClass,\n          is_hidden === true && \"select-none text-[var(--body-background)]\",\n          is_hidden === \"editor\" && \"opacity-70\",\n        )}\n        key={start + \" \" + end}\n        style={style}\n        data-hidden={is_hidden}\n        data-revealed={\n          was_hidden_for_challenge && !is_hidden ? true : undefined\n        }\n      >\n        {visibleContent.text.substring(start, end)}\n      </span>,\n    ];\n    if (visibleContent.text.substring(start, end).indexOf(\"\\n\") !== -1)\n      returns.push(<br key={start + \" \" + end + \" br\"} />);\n    // add the span and optionally add a line break\n    return returns;\n  }\n\n  function addSplitWord(start: number, end: number) {\n    let parts = splitTextTokens(visibleContent.text.substring(start, end));\n    if (parts[0] === \"\") parts.splice(0, 1);\n    if (parts[parts.length - 1] === \"\") parts.pop();\n\n    if (parts.length === 1) {\n      return addWord2(start, end);\n      //addWord(dom, start, end);\n      //return dom;\n    }\n    let elements = [];\n    for (let p of parts) {\n      for (let w of addWord2(start, start + p.length)) elements.push(w);\n      start += p.length;\n    }\n    // <span className=\"word\">{content.text.substring(text_pos, hint.rangeFrom)}</span>\n    return elements;\n  }\n\n  let elements = [];\n  let text_pos = 0;\n  // iterate over all hints\n  for (let hint of visibleContent.hintMap) {\n    // add the text since the last hint\n    if (hint.rangeFrom > text_pos)\n      elements.push(addSplitWord(text_pos, hint.rangeFrom));\n    //addSplitWord(dom.append(\"span\").attr(\"class\", \"word\"), text_pos, hint.rangeFrom);\n\n    // add the text with the hint\n    let is_hidden =\n      hideRangesForChallengeEntry !== undefined &&\n      getOverlap(\n        hint.rangeFrom,\n        hint.rangeTo,\n        hideRangesForChallengeEntry.start,\n        hideRangesForChallengeEntry.end,\n      )\n        ? true\n        : undefined;\n    if (editor) is_hidden = false;\n    const hint_translation = visibleContent.hints?.[hint.hintIndex];\n    const hint_pronunciation =\n      visibleContent.hints_pronunciation?.[hint.hintIndex];\n    const has_any_hint = Boolean(hint_translation || hint_pronunciation);\n    const has_translation_hint = Boolean(hint_translation);\n    const isInteractive = !is_hidden && !showTrans && has_translation_hint;\n    const was_hidden_for_challenge = hideRangesForChallenge?.some((range) =>\n      getOverlap(hint.rangeFrom, hint.rangeTo + 1, range.start, range.end),\n    );\n    const word_content = hint_pronunciation ? (\n      <ruby className=\"group/ruby relative inline-block [ruby-position:over]\">\n        <span>{addSplitWord(hint.rangeFrom, hint.rangeTo + 1)}</span>\n        <rt\n          className={cn(\n            \"pointer-events-none invisible absolute bottom-[calc(100%-6px)] left-1/2 -translate-x-1/2 whitespace-nowrap text-[0.62em] leading-none opacity-0 transition-opacity duration-200\",\n            !is_hidden &&\n              \"group-hover/tooltip:visible group-hover/tooltip:opacity-95\",\n            !is_hidden &&\n              \"group-focus-within/tooltip:visible group-focus-within/tooltip:opacity-95\",\n            showTrans &&\n              \"group-hover/editorhint:visible group-hover/editorhint:opacity-95\",\n            showTrans &&\n              \"group-focus-within/editorhint:visible group-focus-within/editorhint:opacity-95\",\n            \"group-hover/ruby:visible group-hover/ruby:opacity-95\",\n            \"group-focus-within/ruby:visible group-focus-within/ruby:opacity-95\",\n          )}\n        >\n          {hint_pronunciation}\n        </rt>\n      </ruby>\n    ) : (\n      <span>{addSplitWord(hint.rangeFrom, hint.rangeTo + 1)}</span>\n    );\n    const hintContainerClassName = is_hidden\n      ? \"\"\n      : showTrans\n        ? has_any_hint\n          ? \"group/editorhint inline-flex grow flex-col\"\n          : \"\"\n        : has_translation_hint\n          ? cn(\n              \"group/tooltip relative\",\n              underlineBaseClass,\n              \"[--story-hint-underline:var(--underline-dashed)] bg-[image:linear-gradient(to_right,var(--story-hint-underline)_60%,rgba(255,255,255,0)_0%)] hover:[--story-hint-underline:var(--underline-dashed-highlight)] focus-within:[--story-hint-underline:var(--underline-dashed-highlight)] focus:outline-none focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-[var(--tooltip-border)] focus-visible:outline-offset-2\",\n            )\n          : \"\";\n    const hintTextClassName = showTrans\n      ? cn(\n          \"bg-[var(--editor-hints-background)] text-[0.9em] italic opacity-50\",\n          visibleContent.lang_hints,\n        )\n      : cn(\n          \"pointer-events-none invisible absolute bottom-[125%] left-1/2 z-10 mb-[10px] block w-auto -translate-x-1/2 whitespace-nowrap rounded-[14px] border-2 border-[var(--tooltip-border)] bg-[var(--tooltip-backgroud)] px-[17px] pt-[7px] pb-[6px] text-center text-[19px] font-normal not-italic text-[var(--tooltip-color)] opacity-0 transition-opacity duration-300 after:absolute after:top-full after:left-1/2 after:z-10 after:-mt-[6px] after:-ml-[5px] after:h-[10px] after:w-[10px] after:rotate-[-45deg] after:border-[2px] after:border-[transparent_transparent_var(--tooltip-border)_var(--tooltip-border)] after:bg-[var(--tooltip-backgroud)] after:content-[''] group-hover/tooltip:visible group-hover/tooltip:opacity-100 group-focus-within/tooltip:visible group-focus-within/tooltip:opacity-100\",\n          hint_translation &&\n            hint_pronunciation &&\n            \"[transform:translate(-50%,-8px)]\",\n          visibleContent.lang_hints,\n        );\n\n    elements.push(\n      <Tooltip\n        key={hint.rangeFrom + \" \" + hint.rangeTo + 1}\n        className={cn(\"select-text\", hintContainerClassName)}\n        interactive={isInteractive}\n        style={showTrans && has_any_hint ? editorHintContainerStyle : undefined}\n        data-revealed={\n          was_hidden_for_challenge && !is_hidden ? true : undefined\n        }\n      >\n        {word_content}\n        {showTrans ? (\n          has_any_hint ? (\n            <span className={hintTextClassName} style={editorHintTextStyle}>\n              {hint_translation ? <span>{hint_translation}</span> : null}\n              {hint_pronunciation ? (\n                <span className=\"mt-0.5 block opacity-90\">\n                  {hint_pronunciation}\n                </span>\n              ) : null}\n            </span>\n          ) : null\n        ) : has_translation_hint ? (\n          <span className={hintTextClassName}>\n            <span>{hint_translation}</span>\n          </span>\n        ) : null}\n      </Tooltip>,\n    );\n    //addSplitWord(dom.append(\"span\").attr(\"class\", \"word tooltip\"), hint.rangeFrom, hint.rangeTo+1)\n    //    .append(\"span\").attr(\"class\", \"tooltiptext\").text(content.hints[hint.hintIndex]);\n    // advance the position\n    text_pos = hint.rangeTo + 1;\n  }\n  // add the text after the last hint\n  if (text_pos < visibleContent.text.length)\n    elements.push(addSplitWord(text_pos, visibleContent.text.length));\n  //            addSplitWord(dom.append(\"span\").attr(\"class\", \"word\"), text_pos, content.text.length);\n\n  return elements;\n}\nexport default StoryLineHints;\n"
  },
  {
    "path": "src/components/StoryLineHints/index.ts",
    "content": "export * from \"./StoryLineHints\";\nexport { default } from \"./StoryLineHints\";\n"
  },
  {
    "path": "src/components/StoryProgress/StoryProgress.tsx",
    "content": "import React from \"react\";\nimport { AnimatePresence } from \"framer-motion\";\nimport StoryChallengeMultipleChoice from \"../StoryChallengeMultipleChoice\";\nimport StoryChallengeContinuation from \"../StoryChallengeContinuation\";\nimport StoryChallengeMatch from \"../StoryChallengeMatch\";\nimport StoryChallengeArrange from \"../StoryChallengeArrange\";\nimport StoryChallengePointToPhrase from \"../StoryChallengePointToPhrase\";\nimport StoryChallengeSelectPhrases from \"../StoryChallengeSelectPhrases\";\nimport FadeGlideIn from \"../FadeGlideIn\";\nimport StoryTextLine from \"../StoryTextLine\";\nimport StoryHeader from \"../StoryHeader\";\nimport StoryHeaderProgress from \"../StoryHeaderProgress\";\nimport StoryFooter from \"../StoryFooter\";\nimport StoryFinishedScreen from \"../StoryFinishedScreen\";\nimport StoryTitlePage from \"../StoryTitlePage\";\nimport { playSoundEffect } from \"@/lib/sound-effects\";\nimport { StoryType } from \"@/components/editor/story/syntax_parser_new\";\nimport {\n  StoryElement,\n  StoryElementHeader,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport { StoryData } from \"@/app/(stories)/story/[story_id]/getStory\";\nimport { isTypingTarget } from \"@/lib/is-typing-target\";\nimport { cn } from \"@/lib/utils\";\n\nfunction getComponent(parts: StoryElement[]) {\n  const last_part = parts[parts.length - 1];\n  if (parts[0].type === \"HEADER\") return Header;\n  if (parts[0].trackingProperties?.challenge_type === \"arrange\")\n    return StoryChallengeArrange;\n  if (last_part.type === \"POINT_TO_PHRASE\") return StoryChallengePointToPhrase;\n  if (\n    last_part.type === \"MULTIPLE_CHOICE\" &&\n    last_part.trackingProperties.challenge_type === \"multiple-choice\"\n  )\n    return StoryChallengeMultipleChoice;\n  if (parts[0].trackingProperties?.challenge_type === \"continuation\")\n    return StoryChallengeContinuation;\n  if (parts[0].trackingProperties?.challenge_type === \"select-phrases\")\n    return StoryChallengeSelectPhrases;\n  if (parts[0].trackingProperties?.challenge_type === \"match\")\n    return StoryChallengeMatch;\n\n  return Line;\n}\n\nfunction Header({\n  parts,\n  active,\n  hidden,\n  setButtonStatus,\n  settings,\n}: {\n  parts: StoryElement[];\n  active: boolean;\n  hidden: boolean;\n  setButtonStatus: (status: string) => void;\n  settings: StorySettings;\n}) {\n  React.useEffect(() => {\n    if (active) setButtonStatus(\"continue\");\n  }, [active, setButtonStatus]);\n\n  return (\n    <FadeGlideIn hidden={hidden} disableScroll={settings.show_all}>\n      <StoryHeader\n        active={active}\n        element={parts[0] as StoryElementHeader}\n        settings={settings}\n      />\n    </FadeGlideIn>\n  );\n}\n\nfunction Line({\n  parts,\n  active,\n  hidden,\n  setButtonStatus,\n  settings,\n}: {\n  parts: StoryElement[];\n  active: boolean;\n  hidden: boolean;\n  setButtonStatus: (status: string) => void;\n  settings: StorySettings;\n}) {\n  React.useEffect(() => {\n    if (active) setButtonStatus(\"continue\");\n  }, [active, setButtonStatus]);\n  const element = parts[0];\n  if (element.type === \"LINE\") {\n    return (\n      <FadeGlideIn hidden={hidden} disableScroll={settings.show_all}>\n        <StoryTextLine active={active} element={element} settings={settings} />\n      </FadeGlideIn>\n    );\n  }\n  return <div>error</div>;\n}\n\nfunction GetParts(story: StoryType) {\n  const parts: StoryElement[][] = [];\n  let last_id = -1;\n  for (let element of story.elements) {\n    if (element.trackingProperties === undefined) {\n      continue;\n    }\n    if (last_id !== element.trackingProperties.line_index) {\n      parts.push([]);\n      last_id = element.trackingProperties.line_index;\n    }\n    if (\n      element.type === \"MULTIPLE_CHOICE\" &&\n      (parts.at(-1)?.length ?? 0) > 1 &&\n      element.trackingProperties.challenge_type === \"multiple-choice\"\n    )\n      parts.push([]);\n    parts[parts.length - 1].push(element);\n  }\n  for (let i = 0; i < parts.length; i++) {\n    for (let j = 0; j < parts[i].length; j++) {\n      parts[i][j].trackingProperties.line_index = i;\n    }\n  }\n  return parts;\n}\n\nfunction getCharacter(parts: StoryElement[]) {\n  for (let element of parts) {\n    if (element.type === \"LINE\" && element.line.type == \"CHARACTER\") {\n      const value = element.line.characterName || element.line.characterId;\n      if (value) return value;\n    }\n  }\n}\n\nfunction shouldSkipStoryPart(\n  parts: StoryElement[],\n  hideQuestions: boolean,\n): boolean {\n  return (\n    hideQuestions &&\n    parts[0].type === \"MATCH\" &&\n    parts[0].trackingProperties?.challenge_type === \"match\"\n  );\n}\n\nfunction getNextVisibleStoryProgress(\n  partsList: StoryElement[][],\n  currentStoryProgress: number,\n  hideQuestions: boolean,\n): number {\n  let nextStoryProgress = currentStoryProgress + 1;\n  while (\n    nextStoryProgress < partsList.length &&\n    shouldSkipStoryPart(partsList[nextStoryProgress], hideQuestions)\n  ) {\n    nextStoryProgress += 1;\n  }\n  return nextStoryProgress;\n}\n\nfunction getVisibleStoryLength(\n  partsList: StoryElement[][],\n  hideQuestions: boolean,\n): number {\n  let visibleLength = 0;\n  for (const parts of partsList) {\n    if (!shouldSkipStoryPart(parts, hideQuestions)) {\n      visibleLength += 1;\n    }\n  }\n  return visibleLength;\n}\n\nfunction getVisibleStoryProgress(\n  partsList: StoryElement[][],\n  storyProgress: number,\n  hideQuestions: boolean,\n): number {\n  if (storyProgress >= partsList.length) {\n    return getVisibleStoryLength(partsList, hideQuestions);\n  }\n\n  let visibleProgress = 0;\n  for (let index = 0; index <= storyProgress; index += 1) {\n    if (!shouldSkipStoryPart(partsList[index], hideQuestions)) {\n      visibleProgress += 1;\n    }\n  }\n\n  return Math.max(visibleProgress - 1, 0);\n}\n\nexport type StorySettings = {\n  hide_questions: boolean;\n  show_all: boolean;\n  show_names: boolean;\n  rtl: boolean;\n  highlight_name: string[];\n  hideNonHighlighted: boolean;\n  setHighlightName: (name: string[]) => void;\n  setHideNonHighlighted: React.Dispatch<React.SetStateAction<boolean>>;\n  show_hints: boolean;\n  setShowHints: React.Dispatch<React.SetStateAction<boolean>>;\n  show_audio: boolean;\n  setShowAudio: React.Dispatch<React.SetStateAction<boolean>>;\n  id: number;\n  show_title_page: boolean;\n};\n\nfunction getInitialStoryProgress({\n  partsList,\n  initialFocusLine,\n  showTitlePage,\n  hideQuestions,\n}: {\n  partsList?: StoryElement[][];\n  initialFocusLine?: number;\n  showTitlePage: boolean;\n  hideQuestions: boolean;\n}) {\n  if (!partsList || partsList.length === 0) {\n    return showTitlePage ? -1 : 0;\n  }\n  const fallbackProgress = showTitlePage ? -1 : 0;\n  if (!initialFocusLine || initialFocusLine <= 0) {\n    if (fallbackProgress < 0) return fallbackProgress;\n    return getNextVisibleStoryProgress(\n      partsList,\n      fallbackProgress - 1,\n      hideQuestions,\n    );\n  }\n\n  const focusedIndex = partsList.findIndex((parts) =>\n    parts.some(\n      (element) => element.editor?.block_start_no === initialFocusLine,\n    ),\n  );\n  const initialProgress = focusedIndex >= 0 ? focusedIndex : fallbackProgress;\n\n  if (initialProgress < 0) return initialProgress;\n  return getNextVisibleStoryProgress(\n    partsList,\n    initialProgress - 1,\n    hideQuestions,\n  );\n}\n\nfunction getInitialPartProgress({\n  partsList,\n  initialFocusLine,\n  storyProgress,\n}: {\n  partsList?: StoryElement[][];\n  initialFocusLine?: number;\n  storyProgress: number;\n}) {\n  if (\n    !partsList ||\n    storyProgress < 0 ||\n    !initialFocusLine ||\n    initialFocusLine <= 0\n  ) {\n    return 0;\n  }\n\n  const currentParts = partsList[storyProgress];\n  if (!currentParts || currentParts.length < 2) return 0;\n  const lastPart = currentParts[currentParts.length - 1];\n  if (\n    lastPart.type !== \"MULTIPLE_CHOICE\" &&\n    lastPart.type !== \"POINT_TO_PHRASE\"\n  )\n    return 0;\n\n  return lastPart.editor?.block_start_no === initialFocusLine ? 1 : 0;\n}\n\nfunction getVisibleEditorLine({\n  currentPart,\n  partProgress,\n}: {\n  currentPart?: StoryElement[];\n  partProgress: number;\n}) {\n  if (!currentPart || currentPart.length === 0) return undefined;\n\n  const lastElement = currentPart[currentPart.length - 1];\n  if (\n    (lastElement.type === \"MULTIPLE_CHOICE\" ||\n      lastElement.type === \"POINT_TO_PHRASE\") &&\n    currentPart.length > 1 &&\n    partProgress > 0\n  ) {\n    return lastElement.editor?.block_start_no;\n  }\n\n  return currentPart\n    .map((element) => element.editor?.block_start_no)\n    .find((lineNumber): lineNumber is number => typeof lineNumber === \"number\");\n}\n\nfunction getCurrentVisiblePart({\n  partsList,\n  storyProgress,\n  hideQuestions,\n}: {\n  partsList?: StoryElement[][];\n  storyProgress: number;\n  hideQuestions: boolean;\n}) {\n  if (!partsList || partsList.length === 0 || storyProgress < 0) {\n    return undefined;\n  }\n\n  const maxIndex = Math.min(storyProgress, partsList.length - 1);\n  for (let index = maxIndex; index >= 0; index -= 1) {\n    if (!shouldSkipStoryPart(partsList[index], hideQuestions)) {\n      return partsList[index];\n    }\n  }\n\n  return undefined;\n}\n\nfunction StoryProgress({\n  story,\n  parts_list: providedPartsList,\n  editHrefBase,\n  initialFocusLine,\n  settings,\n  onEnd,\n  onBackToOverview,\n  finishedLabel,\n  nextStoryPreview,\n  showFinishedPrimaryAction,\n}: {\n  story?: StoryData;\n  parts_list?: StoryElement[][];\n  editHrefBase?: string;\n  initialFocusLine?: number;\n  settings: StorySettings;\n  onEnd: () => void;\n  onBackToOverview?: () => void | Promise<void>;\n  finishedLabel?: string;\n  nextStoryPreview?: {\n    id: number;\n    title: string;\n    active: string;\n    gilded: string;\n  } | null;\n  showFinishedPrimaryAction?: boolean;\n}) {\n  const parts_list = React.useMemo(() => {\n    if (providedPartsList) return providedPartsList;\n    if (story) return GetParts(story);\n    return undefined;\n  }, [providedPartsList, story]);\n  const initialStoryProgress = getInitialStoryProgress({\n    partsList: parts_list,\n    initialFocusLine,\n    showTitlePage: settings.show_title_page,\n    hideQuestions: settings.hide_questions,\n  });\n  const initialPartProgress = getInitialPartProgress({\n    partsList: parts_list,\n    initialFocusLine,\n    storyProgress: initialStoryProgress,\n  });\n  const [partProgress, setPartProgress] = React.useState(initialPartProgress);\n  const [storyProgress, setStoryProgress] =\n    React.useState(initialStoryProgress);\n  const [buttonStatus, setButtonStatus] = React.useState(() => {\n    if (initialStoryProgress === -1) return \"continue\";\n    if (parts_list && initialStoryProgress >= parts_list.length)\n      return \"finished\";\n    return \"wait\";\n  });\n  const previousButtonStatus = React.useRef(buttonStatus);\n\n  React.useEffect(() => {\n    if (previousButtonStatus.current !== buttonStatus) {\n      if (buttonStatus === \"right\") playSoundEffect(\"right\");\n      if (buttonStatus === \"finished\") playSoundEffect(\"done\");\n    }\n    previousButtonStatus.current = buttonStatus;\n  }, [buttonStatus]);\n\n  const queueAutoplayForNextLine = React.useCallback(() => {\n    if (typeof window === \"undefined\") return;\n    window.sessionStorage.setItem(\"story_autoplay_ts\", String(Date.now()));\n  }, []);\n\n  const shouldQueueAutoplay =\n    buttonStatus === \"continue\" ||\n    buttonStatus === \"right\" ||\n    buttonStatus === \"finished\";\n\n  React.useEffect(() => {\n    if (\n      !parts_list ||\n      storyProgress < 0 ||\n      storyProgress >= parts_list.length ||\n      !shouldSkipStoryPart(parts_list[storyProgress], settings.hide_questions)\n    ) {\n      return;\n    }\n\n    const nextStoryProgress = getNextVisibleStoryProgress(\n      parts_list,\n      storyProgress,\n      settings.hide_questions,\n    );\n    setPartProgress(0);\n    setStoryProgress(nextStoryProgress);\n    setButtonStatus(\n      nextStoryProgress >= parts_list.length ? \"finished\" : \"wait\",\n    );\n  }, [parts_list, settings.hide_questions, storyProgress]);\n\n  const next = React.useCallback(async () => {\n    if (!parts_list) return;\n    if (buttonStatus === \"finished\") {\n      setButtonStatus(\"...\");\n      await onEnd();\n      return;\n    }\n    if (buttonStatus === \"wait\") return;\n    if (buttonStatus === \"idle\") {\n      setButtonStatus(\"wait\");\n      return setPartProgress(partProgress + 1);\n    }\n    if (buttonStatus === \"continue\" || buttonStatus === \"right\") {\n      const nextStoryProgress = getNextVisibleStoryProgress(\n        parts_list,\n        storyProgress,\n        settings.hide_questions,\n      );\n      setPartProgress(0);\n      setStoryProgress(nextStoryProgress);\n      if (nextStoryProgress >= parts_list.length) setButtonStatus(\"finished\");\n      else setButtonStatus(\"wait\");\n    }\n  }, [\n    buttonStatus,\n    onEnd,\n    partProgress,\n    parts_list,\n    settings.hide_questions,\n    storyProgress,\n  ]);\n\n  React.useEffect(() => {\n    function onKeyDown(event: KeyboardEvent) {\n      if (\n        event.key !== \" \" ||\n        event.repeat ||\n        isTypingTarget(event.target, { includeButtons: true })\n      ) {\n        return;\n      }\n\n      event.preventDefault();\n      if (shouldQueueAutoplay) {\n        queueAutoplayForNextLine();\n      }\n      void next();\n    }\n\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [next, queueAutoplayForNextLine, shouldQueueAutoplay]);\n\n  const currentPart = getCurrentVisiblePart({\n    partsList: parts_list,\n    storyProgress,\n    hideQuestions: settings.hide_questions,\n  });\n  const currentEditorLine = getVisibleEditorLine({\n    currentPart,\n    partProgress,\n  });\n  const editHref =\n    editHrefBase && currentEditorLine\n      ? `${editHrefBase}?line=${currentEditorLine}`\n      : editHrefBase;\n\n  React.useEffect(() => {\n    if (\n      typeof window === \"undefined\" ||\n      !story ||\n      storyProgress < 0 ||\n      currentEditorLine === undefined\n    ) {\n      return;\n    }\n\n    const url = new URL(window.location.href);\n    url.searchParams.set(\"line\", String(currentEditorLine));\n\n    const nextUrl = `${url.pathname}${url.search}${url.hash}`;\n    const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;\n    if (nextUrl === currentUrl) return;\n\n    window.history.replaceState(window.history.state, \"\", nextUrl);\n  }, [currentEditorLine, story, storyProgress]);\n\n  if (!story || !parts_list) return null;\n\n  const visibleStoryLength = getVisibleStoryLength(\n    parts_list,\n    settings.hide_questions,\n  );\n  const visibleStoryProgress =\n    storyProgress === -1\n      ? undefined\n      : getVisibleStoryProgress(\n          parts_list,\n          storyProgress,\n          settings.hide_questions,\n        );\n\n  function getIndex(parts: StoryElement[]) {\n    return parts[0].trackingProperties.line_index || 0;\n  }\n\n  const part_list_with_component = [];\n  const character_list: string[] = [\"Narrator\"];\n  for (let parts of parts_list) {\n    const character = getCharacter(parts);\n    if (character && !character_list.includes(`${character}`)) {\n      character_list.push(`${character}`);\n    }\n    const hidden = !(storyProgress >= getIndex(parts) || settings.show_all);\n    if (1) {\n      //storyProgress >= getIndex(parts) || settings.show_all) {\n      if (shouldSkipStoryPart(parts, settings.hide_questions)) continue;\n      part_list_with_component.push({\n        parts,\n        id: getIndex(parts),\n        hidden,\n        Component: getComponent(parts),\n      });\n    }\n  }\n\n  return (\n    <>\n      <div>\n        {!settings.show_all && (\n          <StoryHeaderProgress\n            course={story.course_short}\n            setId={story.set_id}\n            progress={visibleStoryProgress}\n            length={storyProgress === -1 ? undefined : visibleStoryLength}\n            editHref={editHref}\n          />\n        )}\n        {storyProgress === -1 && !settings.show_all && (\n          <StoryTitlePage story={story} next={next} />\n        )}\n        <div\n          className={cn(\n            \"mx-auto max-w-[500px] p-4 print:w-full print:max-w-full\",\n            settings.rtl && \"[direction:rtl]\",\n          )}\n          data-rtl={settings.rtl ? \"true\" : undefined}\n        >\n          {settings.show_names && (\n            <>\n              <NameButtons\n                character_list={character_list}\n                highlight_name={settings.highlight_name}\n                setHighlightName={settings.setHighlightName}\n                setHideNonHighlighted={settings.setHideNonHighlighted}\n              />\n              <h1>{story.from_language_name}</h1>\n            </>\n          )}\n          <AnimatePresence>\n            {part_list_with_component.map(\n              ({ Component, id, parts, hidden }) => {\n                const active =\n                  storyProgress === getIndex(parts) && !settings.show_all;\n                return (\n                  <Component\n                    key={id}\n                    parts={parts}\n                    partProgress={partProgress}\n                    setButtonStatus={\n                      active\n                        ? setButtonStatus\n                        : () => console.log(\"not allowed\")\n                    }\n                    active={active}\n                    settings={settings}\n                    hidden={hidden}\n                  ></Component>\n                );\n              },\n            )}\n          </AnimatePresence>\n          <div className=\"h-[33vh]\" />\n          {storyProgress === parts_list.length && (\n            <StoryFinishedScreen\n              story={story}\n              disableScroll={settings.show_all}\n            />\n          )}\n        </div>\n        {!settings.show_all && storyProgress !== -1 && (\n          <StoryFooter\n            buttonStatus={buttonStatus}\n            onClick={next}\n            onBackToOverview={onBackToOverview}\n            finishedLabel={finishedLabel}\n            nextStoryPreview={nextStoryPreview}\n            learningLanguageName={story.learning_language_long}\n            showFinishedPrimaryAction={showFinishedPrimaryAction}\n          />\n        )}\n      </div>\n    </>\n  );\n}\n\nfunction NameButtons({\n  character_list,\n  highlight_name,\n  setHighlightName,\n  setHideNonHighlighted,\n}: {\n  character_list: string[];\n  highlight_name: string[];\n  setHighlightName: (name: string[]) => void;\n  setHideNonHighlighted: React.Dispatch<React.SetStateAction<boolean>>;\n}) {\n  return (\n    <>\n      <div className=\"print:hidden\">\n        {character_list.map((character) => (\n          <button\n            key={character}\n            onClick={() => {\n              if (highlight_name.includes(character)) {\n                const newList = highlight_name.filter((v) => v != character);\n                setHighlightName(newList);\n              } else {\n                const newList = [...highlight_name, character];\n                setHighlightName(newList);\n              }\n            }}\n          >\n            {character}\n          </button>\n        ))}\n        <button onClick={() => setHideNonHighlighted((i) => !i)}>\n          Hide Others\n        </button>\n      </div>\n    </>\n  );\n}\n\nexport default StoryProgress;\n"
  },
  {
    "path": "src/components/StoryProgress/index.ts",
    "content": "export * from \"./StoryProgress\";\nexport { default } from \"./StoryProgress\";\n"
  },
  {
    "path": "src/components/StoryQuestionArrange/StoryQuestionArrange.tsx",
    "content": "import React from \"react\";\nimport WordButton from \"../WordButton\";\nimport { StoryElement } from \"@/components/editor/story/syntax_parser_types\";\nimport { playSoundEffect } from \"@/lib/sound-effects\";\n\n/*\nThe ARRANGE question\nIt consists of buttons that the learner needs to click in the right order.\n\n[ARRANGE]\n> Tap what you hear\nSpeaker560: ¡[(Necesito) (las~llaves) (de) (mi) (carro)!]\n~              I~need     the~keys     of   my   car\n */\n\nfunction StoryQuestionArrange({\n  element,\n  active,\n  advance,\n}: {\n  element: StoryElement;\n  active: boolean;\n  advance: (i: number, done: boolean) => void;\n}) {\n  if (element.type !== \"ARRANGE\") throw new Error(\"not the right element\");\n  const characterPositions = element.characterPositions;\n  if (characterPositions == undefined) throw new Error(\"not the right element\");\n  const [done, setDone] = React.useState(false);\n\n  let [buttonState, click] = useArrangeButtons(\n    element.phraseOrder,\n    () => {}, //controls.right,\n    () => {}, //controls.wrong,\n    (i) => {\n      setDone(true);\n      //if (!editor)\n      advance(characterPositions[i], i === element.phraseOrder.length - 1);\n    },\n    active, //active,\n  );\n\n  return (\n    <div style={{ textAlign: \"center\" }}>\n      <div>\n        {element.selectablePhrases.map((phrase, index) => (\n          <WordButton\n            key={index}\n            data-cy=\"arrange-button\"\n            data-index={element.phraseOrder[index]}\n            status={[\"undefined\", \"off\", \"wrong\"][buttonState[index]]}\n            onClick={() => click(index)}\n          >\n            {phrase}\n          </WordButton>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction useArrangeButtons(\n  order: number[],\n  callRight: () => void,\n  callWrong: () => void,\n  callAdvance: (i: number) => void,\n  active: boolean,\n) {\n  let [buttonState, setButtonState] = React.useState([\n    ...new Array(order.length),\n  ]);\n  let [position, setPosition] = React.useState(0);\n\n  let click = React.useCallback(\n    (index: number) => {\n      if (buttonState[index] === 1) return;\n\n      if (position === order[index]) {\n        if (position === order.length - 1) callRight();\n        callAdvance(position);\n        setButtonState((buttonState) =>\n          buttonState.map((v, i) => (i === index ? 1 : v)),\n        );\n        setPosition(position + 1);\n      } else {\n        setTimeout(() => {\n          setButtonState((buttonState) =>\n            buttonState.map((v, i) => (i === index && v === 2 ? 0 : v)),\n          );\n        }, 820);\n        setButtonState((buttonState) =>\n          buttonState.map((v, i) => (i === index ? 2 : v)),\n        );\n        playSoundEffect(\"wrong\");\n        callWrong();\n      }\n    },\n    [buttonState, position, order, callRight, callWrong, callAdvance],\n  );\n\n  let key_event_handler = React.useCallback(\n    (e: KeyboardEvent) => {\n      let value = parseInt(e.key) - 1;\n      if (value < order.length) click(value);\n    },\n    [click, order.length],\n  );\n  React.useEffect(() => {\n    if (active) {\n      window.addEventListener(\"keypress\", key_event_handler);\n      return () => window.removeEventListener(\"keypress\", key_event_handler);\n    }\n  }, [key_event_handler, active]);\n\n  return [buttonState, click] as const;\n}\n\nexport default StoryQuestionArrange;\n"
  },
  {
    "path": "src/components/StoryQuestionArrange/index.ts",
    "content": "export * from \"./StoryQuestionArrange\";\nexport { default } from \"./StoryQuestionArrange\";\n"
  },
  {
    "path": "src/components/StoryQuestionMatch/StoryQuestionMatch.tsx",
    "content": "import React from \"react\";\nimport { produce } from \"immer\";\nimport { shuffle } from \"@/lib/shuffle\";\nimport { playSoundEffect } from \"@/lib/sound-effects\";\nimport { isTypingTarget } from \"@/lib/is-typing-target\";\nimport StoryQuestionPrompt from \"../StoryQuestionPrompt\";\nimport WordButton from \"../WordButton\";\nimport { StoryElement } from \"@/components/editor/story/syntax_parser_types\";\n\ntype WordState = \"idle\" | \"selected\" | \"right\" | \"wrong\";\n\ninterface Word {\n  value: string;\n  state: WordState;\n  index: number;\n  key: string;\n}\n\ninterface State {\n  lists: Word[][];\n}\n\ntype SelectionOutcome = \"noop\" | \"selected\" | \"right\" | \"wrong\";\n\ninterface SelectionResult {\n  nextState: State;\n  outcome: SelectionOutcome;\n}\n\ntype Action =\n  | {\n      type: \"select\";\n      listIndex: number;\n      wordIndex: number;\n      key: string;\n    }\n  | {\n      type: \"shuffle\";\n    };\n\n/*\nThe MATCH question.\nIt consists of two columns of buttons. The learner needs to find the right pars.\n\n[MATCH]\n> Tap the pairs\n- estás <> you are\n- mucho <> a lot\n- es <> is\n- las llaves <> the keys\n- la <> the\n */\n\nfunction applySelection(currentState: State, action: Action): SelectionResult {\n  let outcome: SelectionOutcome = \"noop\";\n\n  const nextState = produce(currentState, (draftState) => {\n    switch (action.type) {\n      case \"select\": {\n        // the word in the other group\n        const selectedWord = draftState.lists[[1, 0][action.listIndex]].find(\n          (word) => word.state === \"selected\",\n        );\n        // the selected word in the current group\n        const selectedWordSame = draftState.lists[action.listIndex].find(\n          (word) => word.state === \"selected\",\n        );\n        // the newly selected word\n        const newSelectedWord =\n          draftState.lists[action.listIndex][action.wordIndex];\n\n        // if the newly selected word is already done, skip\n        if (newSelectedWord.state === \"right\") {\n          return;\n        }\n\n        // if there is a word selected in the current group, we change the selection\n        if (selectedWordSame) {\n          // unselect the old word\n          selectedWordSame.state = \"idle\";\n\n          // if it's the same word, return\n          if (selectedWordSame.index === newSelectedWord.index) {\n            return;\n          }\n        }\n\n        // if it's the only selected word, select it\n        if (!selectedWord) {\n          newSelectedWord.state = \"selected\";\n          outcome = \"selected\";\n        }\n        // if the words match\n        else if (selectedWord.index === newSelectedWord.index) {\n          selectedWord.state = \"right\";\n          newSelectedWord.state = \"right\";\n          outcome = \"right\";\n        }\n        // if the words do not match\n        else {\n          selectedWord.state = \"wrong\";\n          selectedWord.key = action.key;\n          newSelectedWord.state = \"wrong\";\n          newSelectedWord.key = action.key;\n          outcome = \"wrong\";\n        }\n\n        break;\n      }\n    }\n  });\n\n  return { nextState, outcome };\n}\n\nfunction reducer(currentState: State, action: Action) {\n  if (action.type === \"shuffle\") {\n    return shuffle_lists(currentState);\n  }\n  return applySelection(currentState, action).nextState;\n}\n\nfunction shuffle_lists(state: State) {\n  return { lists: state.lists.map((element) => shuffle(element)) };\n}\n\nfunction getNumberIndex(key: string) {\n  if (key === \"0\") return 9;\n  const parsed = Number.parseInt(key, 10);\n  if (Number.isNaN(parsed) || parsed < 1 || parsed > 9) return undefined;\n  return parsed - 1;\n}\n\nfunction StoryQuestionMatch({\n  /*progress,*/ element,\n  active,\n  setDone,\n}: {\n  element: StoryElement;\n  active: boolean;\n  setDone: () => void;\n}) {\n  if (element.type !== \"MATCH\") throw new Error(\"not the right element\");\n  const animationKeyRef = React.useRef(0);\n  const [state, dispatch]: [State, React.Dispatch<Action>] = React.useReducer(\n    reducer,\n    {\n      lists: [\n        element.fallbackHints.map((e, i) => ({\n          value: e.phrase,\n          state: \"idle\" as const,\n          index: i,\n          key: `left-${i}`,\n        })),\n        element.fallbackHints.map((e, i) => ({\n          value: e.translation,\n          state: \"idle\" as const,\n          index: i,\n          key: `right-${i}`,\n        })),\n      ],\n    },\n  );\n\n  React.useEffect(() => {\n    dispatch({ type: \"shuffle\" });\n  }, []);\n\n  const selectWord = React.useCallback(\n    (listIndex: number, wordIndex: number) => {\n      const action = {\n        type: \"select\",\n        listIndex,\n        wordIndex,\n        key: `interaction-${animationKeyRef.current++}`,\n      } as const;\n      const { outcome } = applySelection(state, action);\n\n      if (outcome === \"wrong\") {\n        playSoundEffect(\"wrong\");\n      }\n\n      dispatch(action);\n    },\n    [state],\n  );\n\n  React.useEffect(() => {\n    const all_right = state.lists[0].every((word) => word.state === \"right\");\n    if (all_right) setDone();\n  }, [state, setDone]);\n  React.useEffect(() => {\n    function onKeyDown(event: KeyboardEvent) {\n      if (event.repeat || !active || isTypingTarget(event.target)) return;\n\n      const wordIndex = getNumberIndex(event.key);\n      if (wordIndex === undefined) return;\n\n      const selectedLeft = state.lists[0].some(\n        (word) => word.state === \"selected\",\n      );\n      const selectedRight = state.lists[1].some(\n        (word) => word.state === \"selected\",\n      );\n      const listIndex = selectedLeft && !selectedRight ? 1 : 0;\n      const word = state.lists[listIndex][wordIndex];\n\n      if (!word || word.state === \"right\") return;\n\n      event.preventDefault();\n      selectWord(listIndex, wordIndex);\n    }\n\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [active, selectWord, state]);\n\n  return (\n    <div>\n      <StoryQuestionPrompt\n        question={element.prompt}\n        lang={element.lang_question}\n      />\n      <div className=\"flex gap-5\">\n        {state.lists.map((list, listIndex) => (\n          <div key={listIndex} className=\"flex flex-col gap-[10px]\">\n            {list.map((word, wordIndex) => (\n              <WordButton\n                key={word.key}\n                className=\"m-0 w-full\"\n                innerClassName=\"w-full px-[15px] py-2\"\n                status={word.state}\n                onClick={() => selectWord(listIndex, wordIndex)}\n              >\n                {word.value}\n              </WordButton>\n            ))}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport default StoryQuestionMatch;\n"
  },
  {
    "path": "src/components/StoryQuestionMatch/index.ts",
    "content": "export * from \"./StoryQuestionMatch\";\nexport { default } from \"./StoryQuestionMatch\";\n"
  },
  {
    "path": "src/components/StoryQuestionMultipleChoice/StoryQuestionMultipleChoice.tsx",
    "content": "import React from \"react\";\n\n//import useChoiceButtons from \"./questions_useChoiceButtons\";\n//import { EditorHook } from \"../editor_hooks\";\nimport StoryLineHints from \"../StoryLineHints\";\n//import { EditorContext, StoryContext } from \"../story\";\nimport StoryQuestionPrompt from \"../StoryQuestionPrompt\";\nimport CheckButton from \"../CheckButton\";\nimport { useChoiceButtons } from \"@/hooks/use-choice-buttons.hook\";\nimport { StoryElementMultipleChoice } from \"@/components/editor/story/syntax_parser_types\";\nimport { cn } from \"@/lib/utils\";\n\n/*\nThe MULTIPLE_CHOICE question.\nThe learner has to find the right answer from different answers which have a checkbox.\n\n[MULTIPLE_CHOICE]\n> Priti was so tired that…\n- …she fell asleep in the kitchen.\n+ …she put salt in her coffee.\n- …she put her keys in her coffee.\n\nThe CONTINUATION question\nIt also uses the multiple choice component. The learner has to find out how to continue the sentence. Similar to the\nSELECT_PHRASE question, but here the learner does not hear the hidden part of the sentence.\n */\n\nfunction StoryQuestionMultipleChoice({\n  element,\n  active,\n  advance,\n}: {\n  element: StoryElementMultipleChoice;\n  active: boolean;\n  advance: () => void;\n}) {\n  //const [done, setDone] = React.useState(false);\n\n  // get button states and a click function\n  let [buttonState, click] = useChoiceButtons(\n    element.answers.length,\n    element.correctAnswerIndex,\n    () => {\n      advance();\n      //setDone(true);\n    },\n    () => {},\n    active,\n  );\n\n  function getColorText(state: string) {\n    if (state === \"false\" || state === \"done\")\n      return \"my-5 flex items-center gap-[18px] text-[var(--color_disabled_color)]\";\n    return \"my-5 flex items-center gap-[18px]\";\n  }\n\n  return (\n    <div className={element.lang}>\n      {/* Display the question if a question is there */}\n      {element.question && <StoryQuestionPrompt question={element.question} />}\n      {/* Display the answers */}\n      <ul className=\"list-none p-0 text-[#4b4b4b]\">\n        {element.answers.map((answer, index) => (\n          /* on answer field */\n          <li\n            key={index}\n            className={getColorText(buttonState[index])}\n            onClick={() => click(index)}\n          >\n            {/* with a button and a text */}\n            <CheckButton type={buttonState[index]} />\n            <div>\n              {typeof answer == \"string\" ? (\n                answer\n              ) : (\n                <StoryLineHints content={answer} />\n              )}\n            </div>\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n\nexport default StoryQuestionMultipleChoice;\n"
  },
  {
    "path": "src/components/StoryQuestionMultipleChoice/index.ts",
    "content": "export * from \"./StoryQuestionMultipleChoice\";\nexport { default } from \"./StoryQuestionMultipleChoice\";\n"
  },
  {
    "path": "src/components/StoryQuestionPointToPhrase/StoryQuestionPointToPhrase.tsx",
    "content": "import React, { Fragment } from \"react\";\nimport StoryQuestionPrompt from \"../StoryQuestionPrompt\";\nimport WordButton from \"../WordButton\";\nimport { useChoiceButtons } from \"@/hooks/use-choice-buttons.hook\";\nimport { StoryElement } from \"@/components/editor/story/syntax_parser_types\";\n\n/*\nThe POINT_TO_PHRASE question\nThe sentence is first presented to the learner like a normal line, then the learner is asked which word\nof the line has a meaning asked for by the question. The right answer is marked with the + sign.\n\n[POINT_TO_PHRASE]\n> Choose the option that means \"tired.\"\nSpeaker560: (Perdón), mi amor, (estoy) (+cansada). ¡(Trabajo) mucho!\n~            sorry    my love   I~am     tired       I~work   a~lot\n\n */\n\nfunction StoryQuestionPointToPhrase({\n  element,\n  active,\n  advance,\n}: {\n  element: StoryElement;\n  active: boolean;\n  advance: () => void;\n}) {\n  if (element.type !== \"POINT_TO_PHRASE\")\n    throw new Error(\"not the right element\");\n  //const controls = React.useContext(StoryContext);\n  //const editor = React.useContext(EditorContext);\n\n  //const [done, setDone] = React.useState(false);\n  //const active1 = progress === element.trackingProperties.line_index;\n  //const active2 = progress - 0.5 === element.trackingProperties.line_index;\n\n  //let hidden = !active2 ? styles_common.hidden : \"\";\n  /*\n  useEffect(() => {\n    if (active1) {\n      controls.setProgressStep(0.5);\n    }\n    if (active2) {\n      controls.setProgressStep(0.5);\n      if (!done) controls.block_next();\n    }\n  }, [active1, active2, done]);\n*/\n  // connect the editor functions\n  //let onClick;\n  //[hidden, onClick] = EditorHook(hidden, element.editor, editor);\n\n  // find which parts of the text should be converted to buttons\n  let button_indices: { [key: string]: number } = {};\n  for (let [index, part] of Object.entries(element.transcriptParts))\n    if (part.selectable)\n      button_indices[index] = Object.keys(button_indices).length;\n\n  // get button states and a click function\n  let [buttonState, click] = useChoiceButtons(\n    element.transcriptParts.length,\n    element.correctAnswerIndex,\n    () => {\n      advance();\n      /*if (!editor) {\n        //props.setUnhide(props.element.trackingProperties.line_index);\n        controls.right();\n        setDone(true);\n      }*/\n    },\n    () => {}, //controls.wrong,\n    active, //active2 && !done,\n  );\n  return (\n    <div>\n      {/* display the question */}\n      <StoryQuestionPrompt\n        question={element.question}\n        lang={element.lang_question}\n      />\n      {/* display the text */}\n      <div className={element.lang}>\n        {element.transcriptParts.map((part, index) => (\n          <Fragment key={index}>\n            {\n              /* is the text selectable? */\n              part.selectable ? (\n                /* then display a button */\n                <WordButton\n                  status={buttonState[button_indices[index]]}\n                  data-cy=\"point-button\"\n                  onClick={() => click(button_indices[index])}\n                >\n                  {part.text.replace(/\\{.*?}/g, \"\")}\n                </WordButton>\n              ) : (\n                /* if it is not selectable just display the text */\n                <span>{part.text}</span>\n              )\n            }\n            {part.text.indexOf(\"\\n\") !== -1 ? <br /> : <></>}\n          </Fragment>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport default StoryQuestionPointToPhrase;\n"
  },
  {
    "path": "src/components/StoryQuestionPointToPhrase/index.ts",
    "content": "export * from \"./StoryQuestionPointToPhrase\";\nexport { default } from \"./StoryQuestionPointToPhrase\";\n"
  },
  {
    "path": "src/components/StoryQuestionPrompt/StoryQuestionPrompt.tsx",
    "content": "import React from \"react\";\n\nimport StoryLineHints from \"../StoryLineHints\";\nimport { ContentWithHints } from \"@/components/editor/story/syntax_parser_types\";\n\nfunction StoryQuestionPrompt({\n  question,\n  lang,\n}: {\n  question: string | ContentWithHints;\n  lang?: string;\n}) {\n  if (question === undefined) return null;\n  if (typeof question === \"string\")\n    return <div className={lang}>{question}</div>;\n  return (\n    <div className={lang}>\n      <StoryLineHints content={question} />\n    </div>\n  );\n}\n\nexport default StoryQuestionPrompt;\n"
  },
  {
    "path": "src/components/StoryQuestionPrompt/index.ts",
    "content": "export * from \"./StoryQuestionPrompt\";\nexport { default } from \"./StoryQuestionPrompt\";\n"
  },
  {
    "path": "src/components/StoryQuestionSelectPhrase/StoryQuestionSelectPhrase.tsx",
    "content": "import React from \"react\";\n\nimport WordButton from \"../WordButton\";\nimport { useChoiceButtons } from \"@/hooks/use-choice-buttons.hook\";\nimport { StoryElement } from \"@/components/editor/story/syntax_parser_types\";\n\n/*\nThe SELECT_PHRASE question.\nThe learner has to listen what is spoken and find the right answer. The answers are buttons and do not contain\ntranslations as the focus (in contrast to CONTINUATION) is on listening and not on content.\n\n[SELECT_PHRASE]\n> Select the missing phrase\nSpeaker507: Hoy   tengo  [un~partido~importante].\n~           today I~have  an~important~game\n+ un partido importante\n- un batido importante\n- una parte imponente\n */\n\nfunction StoryQuestionSelectPhrase({\n  element,\n  active,\n  advance,\n}: {\n  element: StoryElement;\n  active: boolean;\n  advance: () => void;\n}) {\n  if (element.type !== \"SELECT_PHRASE\")\n    throw new Error(\"not the right element\");\n  // get button states and a click function\n  let [buttonState, click] = useChoiceButtons(\n    element.answers.length,\n    element.correctAnswerIndex,\n    () => {\n      /*if (!editor) {\n          if (setUnhide) setUnhide(-1);\n          setDone(true);\n          if (controls?.right) controls?.right();\n        }*/\n      advance();\n    },\n    () => {}, //controls?.wrong || (() => {}),\n    active, //active && !done,\n  );\n\n  function getState(index: number) {\n    const status = buttonState[index];\n    if (status === \"right\") return \"right-stay\";\n    return status;\n  }\n  return (\n    <div>\n      <div className=\"flex flex-col items-stretch\">\n        {/* display the buttons */}\n        {element.answers.map((answer, index) => (\n          /* one answer button */\n          <WordButton\n            key={index}\n            className=\"mb-[10px] ml-0 block w-full select-none\"\n            innerClassName=\"px-6 pt-[14px] pb-[11px] max-[480px]:px-[15px] max-[480px]:py-2\"\n            status={getState(index)}\n            data-cy=\"select-button\"\n            onClick={() => click(index)}\n          >\n            {typeof answer != \"string\"\n              ? answer.text.replace(/\\{.*?}/g, \"\")\n              : answer.replace(/\\{.*?}/g, \"\")}\n          </WordButton>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport default StoryQuestionSelectPhrase;\n"
  },
  {
    "path": "src/components/StoryQuestionSelectPhrase/index.ts",
    "content": "export * from \"./StoryQuestionSelectPhrase\";\nexport { default } from \"./StoryQuestionSelectPhrase\";\n"
  },
  {
    "path": "src/components/StoryTextLine/StoryTextLine.tsx",
    "content": "import React from \"react\";\nimport useAudio from \"./use-audio.hook\";\nimport StoryLineHints from \"../StoryLineHints\";\nimport PlayAudio from \"../PlayAudio\";\nimport StoryTextLineSimple from \"../StoryTextLineSimple\";\nimport EditorSSMLDisplay from \"../EditorSSMLDisplay\";\nimport {\n  StoryElementHeader,\n  StoryElementLine,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport { StorySettings } from \"@/components/StoryProgress\";\nimport type { EditorStateType } from \"@/app/editor/story/[story]/editor_state\";\nimport {\n  getEditorHandlers,\n  type EditorProps,\n} from \"@/lib/editor/editorHandlers\";\nimport { cn } from \"@/lib/utils\";\n\nfunction StoryTextLine({\n  active,\n  element,\n  unhide = 999999,\n  settings,\n  editorState,\n  editorShowTranslationsOverride,\n  editorShowAudioDetailsOverride,\n  onOpenAudioEditor,\n  audioRangeOverride,\n  hideAudioButton = false,\n}: {\n  active: boolean;\n  element: StoryElementLine;\n  unhide?: number;\n  settings: StorySettings;\n  editorState?: EditorStateType;\n  editorShowTranslationsOverride?: boolean;\n  editorShowAudioDetailsOverride?: boolean;\n  onOpenAudioEditor?: (\n    element: StoryElementLine | StoryElementHeader,\n  ) => void | Promise<void>;\n  audioRangeOverride?: number;\n  hideAudioButton?: boolean;\n}) {\n  const editorProps: EditorProps = {\n    editorState,\n    editorBlock: element.editor,\n  };\n  const { onClick } = getEditorHandlers(editorProps);\n  const [audioRange, playAudio, ref, url] = useAudio(\n    element,\n    active,\n    settings.show_audio,\n  );\n  const effectiveAudioRange = audioRangeOverride ?? audioRange;\n  const isRtl = settings.rtl || element.lang === \"rtl\";\n  const showEditorAudioDetails =\n    editorShowAudioDetailsOverride ?? settings.show_audio;\n  const titleClassName = \"m-0 text-[25px] leading-[34px] font-bold\";\n  const phraseClassName = \"my-5 flex flex-nowrap items-start\";\n  const bubbleClassName = cn(\n    \"relative inline-block w-max max-w-[80%] rounded-[0_14px_14px_14px] border-2 border-[var(--color_base_border)] bg-[var(--color_base_background)] px-3 py-[10px]\",\n    \"before:absolute before:top-[-2px] before:left-[-14px] before:content-[''] before:border-r-[12px] before:border-b-[12px] before:border-r-[var(--color_base_border)] before:border-b-transparent\",\n    \"after:absolute after:top-0 after:left-[-9px] after:content-[''] after:border-r-[12px] after:border-b-[12px] after:border-r-[var(--color_base_background)] after:border-b-transparent\",\n    isRtl &&\n      \"rounded-tl-[14px] rounded-tr-none before:left-auto before:right-[-14px] before:border-r-0 before:border-l-[12px] before:border-l-[var(--color_base_border)] after:left-auto after:right-[-9px] after:border-r-0 after:border-l-[12px] after:border-l-[var(--color_base_background)]\",\n  );\n\n  if (element.line === undefined) return <></>;\n\n  const hideRangesForChallenge = element.hideRangesForChallenge;\n\n  if (settings?.show_names) {\n    const name =\n      (element.line.type == \"CHARACTER\" &&\n        (element.line.characterName || element.line.characterId.toString())) ||\n      \"Narrator\";\n    if (!settings?.highlight_name.includes(name) && settings.hideNonHighlighted)\n      return null;\n    return (\n      <>\n        <StoryTextLineSimple\n          speaker={name}\n          highlight={settings?.highlight_name.includes(name)}\n          id={settings?.id + \"-\" + element?.trackingProperties?.line_index}\n        >\n          {element.line.content.text}\n        </StoryTextLineSimple>\n      </>\n    );\n  }\n\n  if (element.line.type === \"TITLE\")\n    return (\n      <div\n        key={element.trackingProperties.line_index}\n        className={`${titleClassName} ${element.lang}`}\n        onClick={onClick}\n        data-lineno={element?.editor?.block_start_no}\n      >\n        <span className={titleClassName}>\n          {url && (\n            <audio ref={ref}>\n              <source src={url} type=\"audio/mp3\" />\n            </audio>\n          )}\n          {!hideAudioButton && settings.show_audio && (\n            <PlayAudio onClick={playAudio} rtl={isRtl} />\n          )}\n          <StoryLineHints\n            showHints={settings.show_hints}\n            showTranslationsInline={editorShowTranslationsOverride}\n            audioRange={effectiveAudioRange}\n            hideRangesForChallenge={hideRangesForChallenge}\n            content={element.line.content}\n            editorState={editorState}\n          />\n        </span>\n      </div>\n    );\n  else if (\n    element.line.type === \"CHARACTER\" &&\n    element.line.avatarUrl != undefined\n  )\n    return (\n      <div\n        key={element.trackingProperties.line_index}\n        className={`${phraseClassName} ${element.lang}`}\n        onClick={onClick}\n        data-lineno={element?.editor?.block_start_no}\n      >\n        <img\n          className={cn(\n            \"mr-[18px] flex h-[50px] w-[50px] flex-[0_0_50px]\",\n            isRtl && \"mr-0 ml-3 scale-x-[-1]\",\n          )}\n          src={element.line.avatarUrl}\n          alt=\"head\"\n        />\n        <span className={bubbleClassName}>\n          {url && (\n            <audio ref={ref}>\n              <source src={url} type=\"audio/mp3\" />\n            </audio>\n          )}\n          {!hideAudioButton && settings.show_audio && (\n            <PlayAudio onClick={playAudio} rtl={isRtl} />\n          )}\n          <StoryLineHints\n            showHints={settings.show_hints}\n            showTranslationsInline={editorShowTranslationsOverride}\n            audioRange={effectiveAudioRange}\n            hideRangesForChallenge={hideRangesForChallenge}\n            unhide={unhide}\n            content={element.line.content}\n            editorState={editorState}\n          />\n          {showEditorAudioDetails &&\n            element.line.content.audio &&\n            (editorState || onOpenAudioEditor) && (\n              <EditorSSMLDisplay\n                ssml={element.line.content.audio.ssml}\n                element={element}\n                editor={editorState}\n                onOpenAudioEditor={onOpenAudioEditor}\n              />\n            )}\n        </span>\n      </div>\n    );\n  else\n    return (\n      <div\n        key={element.trackingProperties.line_index}\n        className={`${phraseClassName} ${element.lang}`}\n        onClick={onClick}\n        data-lineno={element?.editor?.block_start_no}\n      >\n        <span>\n          {url && (\n            <audio ref={ref}>\n              <source src={url} type=\"audio/mp3\" />\n            </audio>\n          )}\n          {!hideAudioButton && settings.show_audio && (\n            <PlayAudio onClick={playAudio} rtl={isRtl} />\n          )}\n          <StoryLineHints\n            showHints={settings.show_hints}\n            showTranslationsInline={editorShowTranslationsOverride}\n            audioRange={effectiveAudioRange}\n            hideRangesForChallenge={hideRangesForChallenge}\n            unhide={unhide}\n            content={element.line.content}\n            editorState={editorState}\n          />\n          {showEditorAudioDetails &&\n            element.line.content.audio &&\n            (editorState || onOpenAudioEditor) && (\n              <EditorSSMLDisplay\n                ssml={element.line.content.audio.ssml}\n                element={element}\n                editor={editorState}\n                onOpenAudioEditor={onOpenAudioEditor}\n              />\n            )}\n        </span>\n      </div>\n    );\n}\n\nexport default StoryTextLine;\n"
  },
  {
    "path": "src/components/StoryTextLine/index.ts",
    "content": "export * from \"./StoryTextLine\";\nexport { default } from \"./StoryTextLine\";\n"
  },
  {
    "path": "src/components/StoryTextLine/use-audio.hook.ts",
    "content": "import React from \"react\";\nimport type {\n  StoryElementLine,\n  StoryElementHeader,\n  Audio,\n} from \"@/components/editor/story/syntax_parser_types\";\n\ndeclare global {\n  interface Window {\n    playing_audio?: Array<() => void>;\n  }\n}\n\ntype UseAudioElement = StoryElementLine | StoryElementHeader;\n\nexport default function useAudio(\n  element: UseAudioElement,\n  active: boolean,\n  enabled = true,\n) {\n  const [audioRange, setAudioRange] = React.useState(99999);\n  const audio: Audio | undefined =\n    element.type === \"LINE\"\n      ? element.line?.content?.audio\n      : element.learningLanguageTitleContent?.audio;\n  const ref = React.useRef<HTMLAudioElement>(null);\n\n  const playAudio = React.useCallback(async () => {\n    if (!enabled || !audio?.url || !ref.current) return;\n\n    const audioObject = ref.current;\n\n    // Stop any currently playing audio\n    if (window.playing_audio?.length) {\n      window.playing_audio.forEach((cancel) => cancel());\n    }\n\n    window.playing_audio = [];\n\n    try {\n      audioObject.pause();\n      audioObject.load();\n      audioObject.currentTime = 0;\n      await audioObject.play();\n    } catch (e) {\n      if (e instanceof DOMException && e.name === \"AbortError\") {\n        return;\n      }\n      console.error(\"Failed to play audio:\", e);\n      return;\n    }\n\n    const timeouts: NodeJS.Timeout[] = [];\n\n    // Set up keypoint timeouts (if available for word highlighting)\n    audio.keypoints?.forEach((keypoint) => {\n      const timeout = setTimeout(() => {\n        setAudioRange(keypoint.rangeEnd);\n      }, keypoint.audioStart);\n      timeouts.push(timeout);\n    });\n\n    // Set up completion timeout\n    const completionTimeout = window.setTimeout(\n      () => {\n        setAudioRange(9999);\n        // Auto-advance logic would go here\n      },\n      audioObject.duration * 1000 - 150,\n    );\n\n    // Cleanup function\n    const cancel = () => {\n      timeouts.forEach(clearTimeout);\n      clearTimeout(completionTimeout);\n      setAudioRange(99999);\n      audioObject.pause();\n    };\n\n    window.playing_audio?.push(cancel);\n\n    return cancel;\n  }, [audio, enabled]);\n\n  React.useEffect(() => {\n    if (!enabled) return;\n    if (!active) return;\n    if (typeof window === \"undefined\") return;\n\n    if (element.type !== \"HEADER\" && element.type !== \"LINE\") return;\n\n    const raw = window.sessionStorage.getItem(\"story_autoplay_ts\");\n    if (!raw) return;\n    const ts = Number(raw);\n    if (!Number.isFinite(ts)) return;\n    if (Date.now() - ts > 10_000) return;\n\n    window.sessionStorage.removeItem(\"story_autoplay_ts\");\n    playAudio();\n\n    return () => {\n      // Clean up any pending timeouts if component unmounts\n      if (window.playing_audio?.length) {\n        window.playing_audio.forEach((cancel) => cancel());\n      }\n    };\n  }, [active, element.type, enabled, playAudio]);\n\n  if (!audio?.url) {\n    return [audioRange, undefined, ref, undefined] as const;\n  }\n\n  const audioUrl =\n    audio.url.startsWith(\"blob\") || audio.url.startsWith(\"http\")\n      ? audio.url\n      : `https://ptoqrnbx8ghuucmt.public.blob.vercel-storage.com/${audio.url}`;\n\n  return [audioRange, playAudio, ref, audioUrl] as const;\n}\n"
  },
  {
    "path": "src/components/StoryTextLineSimple/StoryTextLineSimple.tsx",
    "content": "import React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction StoryTextLineSimple({\n  speaker,\n  highlight,\n  id,\n  children,\n}: {\n  speaker: string;\n  highlight: boolean;\n  id: string;\n  children: React.ReactNode;\n}) {\n  const className = cn(\n    \"my-4 flex items-baseline gap-4\",\n    highlight &&\n      \"bg-[#e6f484] [-webkit-print-color-adjust:exact] [print-color-adjust:exact]\",\n  );\n\n  return (\n    <div className={className}>\n      <span className=\"inline-block w-[100px] shrink-0 text-right font-bold\">\n        {speaker}:\n      </span>\n      <span className=\"flex-1\"> {children}</span>\n      <span className=\"text-right font-mono font-bold\">{id}</span>\n    </div>\n  );\n}\n\nexport default StoryTextLineSimple;\n"
  },
  {
    "path": "src/components/StoryTextLineSimple/index.ts",
    "content": "export * from \"./StoryTextLineSimple\";\nexport { default } from \"./StoryTextLineSimple\";\n"
  },
  {
    "path": "src/components/StoryTitlePage/StoryTitlePage.tsx",
    "content": "import React from \"react\";\nimport Button from \"@/components/ui/button\";\nimport { useLocalisation } from \"../LocalisationProvider/LocalisationProviderContext\";\nimport { StoryType } from \"@/components/editor/story/syntax_parser_new\";\n\nfunction StoryTitlePage({\n  story,\n  next,\n}: {\n  story: StoryType;\n  next: () => void;\n}) {\n  const header = story.elements[0];\n  const localisation = useLocalisation();\n\n  if (header.type != \"HEADER\")\n    throw new Error(\"story needs to start with header\");\n\n  return (\n    <div className=\"pointer-events-none fixed inset-0 flex w-full flex-col items-center justify-center\">\n      <div className=\"w-full text-center\">\n        <img\n          width=\"180\"\n          className=\"mx-auto block\"\n          src={header.illustrationUrl}\n          alt={\"title image\"}\n        />\n      </div>\n      <div className=\"mt-[18px] mb-9 w-full text-center text-[25px] font-bold text-[#4b4b4b]\">\n        {header.learningLanguageTitleContent.text}\n      </div>\n      <div className=\"pointer-events-auto w-full text-center\">\n        <Button variant=\"primary\" onClick={next}>\n          {localisation(\"button_start_story\") || \"Start the Story\"}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nexport default StoryTitlePage;\n"
  },
  {
    "path": "src/components/StoryTitlePage/index.ts",
    "content": "export * from \"./StoryTitlePage\";\nexport { default } from \"./StoryTitlePage\";\n"
  },
  {
    "path": "src/components/VisuallyHidden/VisuallyHidden.tsx",
    "content": "// Uses from Josh Comeau\n// https://www.joshwcomeau.com/snippets/react-components/visually-hidden/\n\nimport React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface VisuallyHiddenProps extends React.HTMLAttributes<HTMLSpanElement> {\n  children: React.ReactNode;\n}\n\nconst VisuallyHidden = ({\n  children,\n  className,\n  ...delegated\n}: VisuallyHiddenProps) => {\n  const [forceShow, setForceShow] = React.useState(false);\n  React.useEffect(() => {\n    if (process.env.NODE_ENV !== \"production\") {\n      const handleKeyDown = (ev: KeyboardEvent) => {\n        if (ev.key === \"Alt\") {\n          setForceShow(true);\n        }\n      };\n      const handleKeyUp = (ev: KeyboardEvent) => {\n        if (ev.key === \"Alt\") {\n          setForceShow(false);\n        }\n      };\n      window.addEventListener(\"keydown\", handleKeyDown);\n      window.addEventListener(\"keyup\", handleKeyUp);\n      return () => {\n        window.removeEventListener(\"keydown\", handleKeyDown);\n        window.removeEventListener(\"keyup\", handleKeyUp);\n      };\n    }\n  }, []);\n  if (forceShow) {\n    return <>{children}</>;\n  }\n  return (\n    <span className={cn(\"sr-only\", className)} {...delegated}>\n      {children}\n    </span>\n  );\n};\nexport default VisuallyHidden;\n"
  },
  {
    "path": "src/components/VisuallyHidden/index.ts",
    "content": "export * from \"./VisuallyHidden\";\nexport { default } from \"./VisuallyHidden\";\n"
  },
  {
    "path": "src/components/WordButton/WordButton.tsx",
    "content": "import React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction WordButton({\n  status,\n  children,\n  className,\n  innerClassName,\n  ...delegated\n}: {\n  status: string;\n  children: React.ReactNode;\n  innerClassName?: string;\n} & React.ButtonHTMLAttributes<HTMLButtonElement>) {\n  const isOff = status === \"off\";\n  const outerClassName = cn(\n    \"m-1 inline-block rounded-[14px] border-0 bg-[var(--color_base_border)] p-0 text-[19px] leading-[1.45] text-[var(--color_base_color)]\",\n    status === \"off\" &&\n      \"cursor-auto bg-[var(--color_base_border)] text-transparent\",\n    status === \"selected\" &&\n      \"bg-[var(--color_selected_border-color)] text-[var(--color_selected_color)]\",\n    status === \"wrong\" && \"animate-[story-wordbutton-wrong_0.8s]\",\n    status === \"right\" &&\n      \"animate-[story-wordbutton-right-to-disabled_1.5s_linear_both]\",\n    status === \"false\" &&\n      \"animate-[story-wordbutton-false-to-disabled_1.5s_both]\",\n    status === \"done\" &&\n      \"bg-[var(--color_disabled_border-color)] text-[var(--color_disabled_color)]\",\n    status === \"right-stay\" &&\n      \"bg-[var(--color_right_border-color)] text-[var(--color_right_color)]\",\n    className,\n  );\n  const innerSpanClassName = cn(\n    \"block translate-y-[-2px] rounded-[inherit] border-2 border-[var(--color_base_border)] bg-[var(--color_base_background)] px-[15px] py-2 transition-[transform,background-color,border-color] duration-500\",\n    status === \"off\" &&\n      \"border-[var(--color_base_border)] bg-[var(--color_base_border)]\",\n    status === \"selected\" &&\n      \"border-[var(--color_selected_border-color)] bg-[var(--color_selected_background)]\",\n    status === \"wrong\" && \"animate-[story-wordbutton-wrong-inner_0.8s]\",\n    status === \"right\" &&\n      \"animate-[story-wordbutton-right-to-disabled-inner_1.5s_linear_both] translate-y-0\",\n    status === \"false\" &&\n      \"animate-[story-wordbutton-false-to-disabled-inner_1.5s_both] translate-y-0\",\n    status === \"done\" &&\n      \"border-[var(--color_disabled_border-color)] bg-[var(--color_disabled_background)]\",\n    status === \"right-stay\" &&\n      \"border-[var(--color_right_border-color)] bg-[var(--color_right_background)]\",\n    innerClassName,\n  );\n\n  return (\n    <button\n      {...delegated}\n      disabled={isOff}\n      className={outerClassName}\n      data-status={status}\n    >\n      <span className={innerSpanClassName}>{children}</span>\n    </button>\n  );\n}\n\nexport default WordButton;\n"
  },
  {
    "path": "src/components/WordButton/index.ts",
    "content": "export * from \"./WordButton\";\nexport { default } from \"./WordButton\";\n"
  },
  {
    "path": "src/components/auth/styles.ts",
    "content": "export const authHeadingClass = \"m-0 text-[calc(32/16*1rem)] font-bold\";\n\nexport const authParagraphClass = \"m-0\";\n\nexport const authAlertErrorClass =\n  \"block w-full rounded-[10px] bg-[var(--error-red)] p-[10px] text-white\";\n\nexport const authAlertInfoClass =\n  \"block w-full rounded-[10px] bg-[var(--button-blue-background)] p-[10px] text-white\";\n\nexport const authPrimaryBlueActionClass =\n  \"mt-2 mb-2 flex w-full items-center justify-center rounded-[15px] border-[var(--button-blue-border)] border-b-4 [border-left-width:var(--button-side-border)] [border-right-width:var(--button-side-border)] [border-top-width:var(--button-side-border)] bg-[var(--button-blue-background)] px-[30px] py-[13px] text-center text-[1rem] font-bold uppercase text-white no-underline transition-[box-shadow,transform] duration-100 hover:brightness-110\";\n\nexport const authInlineLinkClass =\n  \"m-0 w-auto cursor-pointer border-none bg-transparent text-[1em] font-bold text-[var(--link-blue)] underline underline-offset-2\";\n"
  },
  {
    "path": "src/components/editor/story/cast.tsx",
    "content": "import React from \"react\";\nimport Link from \"next/link\";\n\nexport default function Cast(props: {\n  id: number;\n  cast: Record<\n    string,\n    { id: string; link: string; speaker: string; name: string }\n  >;\n  short: string;\n}) {\n  let cast = [];\n  let no_speaker_count = 0;\n  for (let id in props.cast) {\n    cast.push(props.cast[id]);\n    if (!props.cast[id].speaker) no_speaker_count += 1;\n  }\n  return (\n    <section>\n      <h2 className=\"my-[0.83em] text-[1.5em] font-bold leading-[1.2]\">Cast</h2>\n      <table className=\"border-collapse\">\n        <tbody>\n          {cast.map((character, i) => (\n            <Character key={i} character={character} />\n          ))}\n        </tbody>\n      </table>\n      {no_speaker_count ? (\n        <p className=\"my-[1em] leading-[1.2]\">\n          {no_speaker_count} characters do not have a speaker voice assigned. Go\n          to the{\" \"}\n          <Link\n            className=\"underline underline-offset-2\"\n            target=\"_blank\"\n            href={\"/editor/course/\" + props.short + \"/voices\"}\n          >\n            Character-Editor\n          </Link>{\" \"}\n          to add the voices.\n        </p>\n      ) : (\n        <p className=\"my-[1em] leading-[1.2]\">\n          To change voices or names go to the{\" \"}\n          <Link\n            className=\"underline underline-offset-2\"\n            target=\"_blank\"\n            href={\"/editor/course/\" + props.short + \"/voices\"}\n          >\n            Character-Editor\n          </Link>\n          .\n        </p>\n      )}\n      <p className=\"my-[1em] leading-[1.2]\">\n        Use these links to share this story with other contributors to{\" \"}\n        <Link\n          className=\"underline underline-offset-2\"\n          href={`/story/${props.id}`}\n          target={\"_blank\"}\n        >\n          test\n        </Link>{\" \"}\n        or{\" \"}\n        <Link\n          className=\"underline underline-offset-2\"\n          href={`/story/${props.id}/test`}\n          target={\"_blank\"}\n        >\n          review\n        </Link>{\" \"}\n        the story. (or review{\" \"}\n        <Link\n          className=\"underline underline-offset-2\"\n          href={`/story/${props.id}/test?hide_questions=true`}\n          target={\"_blank\"}\n        >\n          without the exercises\n        </Link>\n        ){\" \"}\n        <Link\n          className=\"underline underline-offset-2\"\n          href={`/story/${props.id}/script`}\n          target={\"_blank\"}\n        >\n          Story Script\n        </Link>\n      </p>\n    </section>\n  );\n}\n\nfunction Character(props: {\n  character: { id: string; link: string; name: string; speaker: string };\n}) {\n  let character = props.character;\n  return (\n    <tr className=\"align-middle\">\n      <td className=\"py-1 pr-2 text-right align-middle leading-[1.2]\">\n        {character.id}\n      </td>\n      <td className=\"py-1 pr-3 align-middle\">\n        <img\n          alt={\"speaker head\"}\n          className=\"h-[50px] w-[50px]\"\n          src={character.link}\n        />\n      </td>\n      <td className=\"py-1 pr-3 align-middle leading-[1.2]\">{character.name}</td>\n      <td className=\"py-1 align-middle\">\n        <span className=\"mr-[3px] inline-block rounded bg-[var(--editor-ssml)] px-[5px] py-[2px] text-[0.8em]\">\n          {character.speaker}\n        </span>\n      </td>\n    </tr>\n  );\n}\n"
  },
  {
    "path": "src/components/editor/story/editor-resize.ts",
    "content": "\"use no memo\";\nimport React from \"react\";\n\nexport default function useResizeEditor(\n  editor: HTMLElement | null,\n  preview: HTMLElement | null,\n  p: SVGElement | null,\n) {\n  const initDrag = React.useCallback(\n    (e: MouseEvent) => {\n      if (!editor || !preview) return;\n\n      let startX = 0;\n      let startWidth = 0;\n      let startWidth2: number;\n\n      const doDrag = (e: MouseEvent) => {\n        if (!editor || !preview) return;\n        editor.style.width = startWidth + e.clientX - startX + \"px\";\n        preview.style.width = startWidth2 - e.clientX + startX + \"px\";\n        window.dispatchEvent(new CustomEvent(\"resize\"));\n      };\n\n      const stopDrag = () => {\n        document.documentElement.removeEventListener(\n          \"mousemove\",\n          doDrag,\n          false,\n        );\n        document.documentElement.removeEventListener(\n          \"mouseup\",\n          stopDrag,\n          false,\n        );\n      };\n\n      startX = e.clientX;\n      const editorWidth = document.defaultView?.getComputedStyle(editor).width;\n      const previewWidth =\n        document.defaultView?.getComputedStyle(preview).width;\n\n      if (!editorWidth || !previewWidth) return;\n\n      startWidth = parseInt(editorWidth, 10);\n      startWidth2 = parseInt(previewWidth, 10);\n\n      document.documentElement.addEventListener(\"mousemove\", doDrag, false);\n      document.documentElement.addEventListener(\"mouseup\", stopDrag, false);\n    },\n    [editor, preview],\n  );\n\n  React.useEffect(() => {\n    if (!p) return;\n\n    p.style.cursor = \"col-resize\";\n    p.addEventListener(\"mousedown\", initDrag);\n\n    return () => p.removeEventListener(\"mousedown\", initDrag);\n  }, [p, initDrag]);\n}\n"
  },
  {
    "path": "src/components/editor/story/inline_tts.ts",
    "content": "export type InlineTtsReplacement = {\n  index: number;\n  word: string;\n  alias: string;\n  alphabet?: string;\n};\n\ntype InlineTtsError = {\n  message: string;\n  segment: string;\n  start: number;\n  end: number;\n};\n\nconst punctuationChars =\n  \"\\\\/¡!\\\"'`#$%&*,.:;<=>¿?@^_`{|}…\" + \"。、，！？；：（）～—·《…》〈…〉﹏……——\";\nconst inlineTtsBoundaryPunctuation = punctuationChars.replace(/[{}]/g, \"\");\nconst inlineTtsBoundaryRegex = new RegExp(\n  `[\\\\s${inlineTtsBoundaryPunctuation}]`,\n);\n\nfunction isInlineTtsSegmentBoundary(char: string | undefined) {\n  return (\n    char === undefined ||\n    char === \"|\" ||\n    char === \"[\" ||\n    char === \"]\" ||\n    char.match(inlineTtsBoundaryRegex) !== null\n  );\n}\n\nfunction createInlineTtsError(\n  message: string,\n  segment: string,\n  start: number,\n  end: number,\n): InlineTtsError {\n  return { message, segment, start, end };\n}\n\nexport function formatInlineTtsError(\n  error: InlineTtsError,\n  lineNumber?: number,\n) {\n  const prefix = lineNumber ? `Line ${lineNumber}: ` : \"\";\n  return `${prefix}${error.message}: \"${error.segment}\".`;\n}\n\nexport function scanInlineTts(text: string) {\n  let normalizedText = \"\";\n  const replacements: InlineTtsReplacement[] = [];\n  const errors: InlineTtsError[] = [];\n  let i = 0;\n\n  while (i < text.length) {\n    const char = text[i];\n    if (isInlineTtsSegmentBoundary(char)) {\n      normalizedText += char;\n      i += 1;\n      continue;\n    }\n\n    const segmentStart = i;\n    let braceDepth = 0;\n    while (i < text.length) {\n      const currentChar = text[i];\n      if (currentChar === \"{\") {\n        braceDepth += 1;\n        i += 1;\n        continue;\n      }\n      if (currentChar === \"}\") {\n        braceDepth = Math.max(0, braceDepth - 1);\n        i += 1;\n        continue;\n      }\n      if (braceDepth === 0 && isInlineTtsSegmentBoundary(currentChar)) {\n        break;\n      }\n      i += 1;\n    }\n\n    const segment = text.substring(segmentStart, i);\n    const openCount = [...segment.matchAll(/{/g)].length;\n    const closeCount = [...segment.matchAll(/}/g)].length;\n\n    if (openCount === 0 && closeCount === 0) {\n      normalizedText += segment;\n      continue;\n    }\n    if (openCount === 0) {\n      errors.push(\n        createInlineTtsError(\n          'Inline TTS replacement is missing \"{\"',\n          segment,\n          segmentStart,\n          i,\n        ),\n      );\n      normalizedText += segment;\n      continue;\n    }\n    if (closeCount === 0) {\n      errors.push(\n        createInlineTtsError(\n          'Inline TTS replacement is missing \"}\"',\n          segment,\n          segmentStart,\n          i,\n        ),\n      );\n      normalizedText += segment;\n      continue;\n    }\n    if (openCount > 1 || closeCount > 1) {\n      errors.push(\n        createInlineTtsError(\n          \"Multiple inline TTS replacements in one segment are not allowed\",\n          segment,\n          segmentStart,\n          i,\n        ),\n      );\n      normalizedText += segment;\n      continue;\n    }\n\n    const openIndex = segment.indexOf(\"{\");\n    const closeIndex = segment.indexOf(\"}\");\n    if (closeIndex < openIndex) {\n      errors.push(\n        createInlineTtsError(\n          'Inline TTS replacement is missing \"{\"',\n          segment,\n          segmentStart,\n          i,\n        ),\n      );\n      normalizedText += segment;\n      continue;\n    }\n    const word = segment.substring(0, openIndex);\n    if (!word) {\n      errors.push(\n        createInlineTtsError(\n          'Inline TTS replacement needs text before \"{\"',\n          segment,\n          segmentStart,\n          i,\n        ),\n      );\n      normalizedText += segment;\n      continue;\n    }\n\n    const replacementRaw = segment.substring(openIndex + 1, closeIndex);\n    if (!replacementRaw) {\n      errors.push(\n        createInlineTtsError(\n          \"Inline TTS replacement needs spoken text inside braces\",\n          segment,\n          segmentStart,\n          i,\n        ),\n      );\n      normalizedText += segment;\n      continue;\n    }\n\n    let alias = replacementRaw;\n    let alphabet: string | undefined;\n    const alphabetSeparator = replacementRaw.lastIndexOf(\":\");\n    if (alphabetSeparator !== -1) {\n      alias = replacementRaw.substring(0, alphabetSeparator);\n      alphabet = replacementRaw.substring(alphabetSeparator + 1);\n      if (!alphabet) {\n        errors.push(\n          createInlineTtsError(\n            \"Inline TTS replacement needs a pronunciation alphabet after ':'\",\n            segment,\n            segmentStart,\n            i,\n          ),\n        );\n        normalizedText += segment;\n        continue;\n      }\n    }\n\n    if (!alias) {\n      errors.push(\n        createInlineTtsError(\n          \"Inline TTS replacement needs spoken text inside braces\",\n          segment,\n          segmentStart,\n          i,\n        ),\n      );\n      normalizedText += segment;\n      continue;\n    }\n\n    replacements.push({\n      index: normalizedText.length,\n      word: word.replace(/~/g, \" \"),\n      alias: alias.replace(/~/g, \" \"),\n      alphabet,\n    });\n    normalizedText += word + segment.substring(closeIndex + 1);\n  }\n\n  return { normalizedText, replacements, errors };\n}\n"
  },
  {
    "path": "src/components/editor/story/parser.test.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert/strict\";\n\nimport { __testTokenizeLines } from \"./parser\";\nimport { processStoryFile } from \"./syntax_parser_new\";\n\nfunction getLineTokenTexts(tokens: Array<{ text: string; style: string }>) {\n  return tokens.map((token) => token.text);\n}\n\nconst testAvatars = {\n  0: {\n    id: 0,\n    avatar_id: 0,\n    language_id: 0,\n    name: \"Narrator\",\n    link: \"\",\n    speaker: \"ja-JP-Wavenet-C\",\n  },\n  414: {\n    id: 414,\n    avatar_id: 414,\n    language_id: 0,\n    name: \"Junior\",\n    link: \"\",\n    speaker: \"ja-JP-Wavenet-C\",\n  },\n};\n\nfunction parseLine(\n  text: string,\n  from_language = \"en\",\n  learning_language = \"ja\",\n) {\n  const [story] = processStoryFile(\n    text,\n    0,\n    testAvatars,\n    { learning_language, from_language },\n    \"\",\n  );\n  return story.elements[0];\n}\n\nfunction parseStory(\n  text: string,\n  from_language = \"en\",\n  learning_language = \"ja\",\n) {\n  const [story] = processStoryFile(\n    text,\n    0,\n    testAvatars,\n    { learning_language, from_language },\n    \"\",\n  );\n  return story;\n}\n\ntest(\"ARRANGE splits spaced button chunks for syntax highlighting\", () => {\n  const lines = __testTokenizeLines(`[ARRANGE]\n> Tap what you hear\nSpeaker414: [(Nej)… (Du har) (mange)]`);\n\n  const speakerLineTokens = lines[2] ?? [];\n\n  assert.deepEqual(getLineTokenTexts(speakerLineTokens), [\n    \"Speaker414:\",\n    \" \",\n    \"[\",\n    \"(\",\n    \"Nej\",\n    \")\",\n    \"…\",\n    \" \",\n    \"(\",\n    \"Du\",\n    \" \",\n    \"har\",\n    \")\",\n    \" \",\n    \"(\",\n    \"mange\",\n    \")\",\n    \"]\",\n  ]);\n\n  const duToken = speakerLineTokens.find((token) => token.text === \"Du\");\n  const harToken = speakerLineTokens.find((token) => token.text === \"har\");\n  assert.ok(duToken);\n  assert.ok(harToken);\n  assert.notEqual(duToken.style, harToken.style);\n  assert.ok([\"number\", \"meta\"].includes(duToken.style));\n  assert.ok([\"labelName\", \"comment\"].includes(harToken.style));\n});\n\ntest(\"ARRANGE keeps tilde-connected button chunk as one token\", () => {\n  const lines = __testTokenizeLines(`[ARRANGE]\n> Tap what you hear\nSpeaker414: [(Nej)… (Du~har) (mange)]`);\n\n  const speakerLineTokens = lines[2] ?? [];\n  const tokenTexts = getLineTokenTexts(speakerLineTokens);\n\n  assert.ok(tokenTexts.includes(\"Du~har\"));\n  assert.ok(!tokenTexts.includes(\"Du\"));\n  assert.ok(!tokenTexts.includes(\"har\"));\n});\ntest(\"LINE syntax highlighting marks invalid inline TTS replacements\", () => {\n  const lines = __testTokenizeLines(`[LINE]\nSpeaker414: foo{} bar`);\n\n  const speakerLineTokens = lines[1] ?? [];\n  const invalidToken = speakerLineTokens.find(\n    (token) => token.text === \"foo{}\",\n  );\n\n  assert.ok(invalidToken);\n  assert.equal(invalidToken.style, \"deleted\");\n});\n\ntest(\"LINE syntax highlighting does not mark valid inline TTS replacements\", () => {\n  const lines = __testTokenizeLines(`[LINE]\nSpeaker414: foo{f u:ipa} bar`);\n\n  const speakerLineTokens = lines[1] ?? [];\n  const braceTokens = speakerLineTokens.filter(\n    (token) => token.text.includes(\"{\") || token.text.includes(\"}\"),\n  );\n\n  assert.ok(braceTokens.length > 0);\n  for (const token of braceTokens) {\n    assert.notEqual(token.style, \"deleted\");\n  }\n});\n\ntest(\"LINE syntax highlighting does not mark valid inline TTS replacements with suffixes\", () => {\n  const lines = __testTokenizeLines(`[LINE]\nSpeaker414: 家{やー}んかい 向かとーん{んかとーん}。`);\n\n  const speakerLineTokens = lines[1] ?? [];\n  const braceTokens = speakerLineTokens.filter(\n    (token) => token.text.includes(\"{\") || token.text.includes(\"}\"),\n  );\n\n  assert.ok(braceTokens.length > 0);\n  for (const token of braceTokens) {\n    assert.notEqual(token.style, \"deleted\");\n  }\n});\n\ntest(\"LINE rejects ambiguous inline TTS replacements inside a token\", () => {\n  const element = parseLine(`[LINE]\nSpeaker414: ジュニア! 今日{ちゅー}|や  何{ぬー} 食{か}み欲{ぶ}さん|なー?\n~            Junior   today     's what   to~want~to~eat        (friendly~question)`);\n\n  assert.equal(element?.type, \"ERROR\");\n  assert.match(\n    element?.text ?? \"\",\n    /Multiple inline TTS replacements in one segment are not allowed: \"食\\{か\\}み欲\\{ぶ\\}さん\"/,\n  );\n});\n\ntest('LINE keeps a valid inline TTS replacement before \"|\"', () => {\n  const element = parseLine(`[LINE]\nSpeaker414: ジュニア! 今日{ちゅー}|や?\n~            Junior   today`);\n\n  assert.equal(element?.type, \"LINE\");\n  assert.equal(element?.line.content.text, \"ジュニア! 今日⁠や?\");\n  assert.match(\n    element?.audio?.ssml.text ?? \"\",\n    /<sub alias=\"ちゅー\"><mark name=\"8\"\\/>今日<\\/sub><mark name=\"10\"\\/>⁠や\\?/,\n  );\n});\n\ntest(\"LINE keeps a valid inline TTS replacement with suffix text\", () => {\n  const element = parseLine(`[LINE]\nSpeaker414: リノー 家{やー}んかい 向かとーん{んかとーん}。\n~            Lin~is house/home~{やー} to(wards) is~heading~(into)~{んかとーん}`);\n\n  assert.equal(element?.type, \"LINE\");\n  assert.equal(element?.line.content.text, \"リノー 家んかい 向かとーん。\");\n  assert.match(\n    element?.audio?.ssml.text ?? \"\",\n    /<sub alias=\"やー\">.*家<\\/sub>.*んかい/,\n  );\n});\n\ntest(\"LINE keeps multiple valid inline TTS replacements on one line\", () => {\n  const element = parseLine(`[LINE]\nSpeaker414: 今日{ちゅー} 何{ぬー}?\n~            today    what`);\n\n  assert.equal(element?.type, \"LINE\");\n  assert.equal(element?.line.content.text, \"今日 何?\");\n  assert.match(\n    element?.audio?.ssml.text ?? \"\",\n    /<sub alias=\"ちゅー\"><mark name=\"2\"\\/>今日<\\/sub> <sub alias=\"ぬー\"><mark name=\"4\"\\/>何<\\/sub>\\?/,\n  );\n});\n\ntest(\"LINE keeps valid inline IPA replacements\", () => {\n  const element = parseLine(`[LINE]\nSpeaker414: foo{f u:ipa}\n~            bar`);\n\n  assert.equal(element?.type, \"LINE\");\n  assert.equal(element?.line.content.text, \"foo\");\n  assert.match(\n    element?.audio?.ssml.text ?? \"\",\n    /<phoneme alphabet=\"ipa\" ph=\"f u\"><mark name=\"2\"\\/>foo<\\/phoneme>/,\n  );\n});\n\ntest('LINE rejects inline TTS replacements without a closing \"}\"', () => {\n  const element = parseLine(`[LINE]\nSpeaker414: foo{bar\n~            baz`);\n\n  assert.equal(element?.type, \"ERROR\");\n  assert.match(\n    element?.text ?? \"\",\n    /Inline TTS replacement is missing \"\\}\": \"foo\\{bar\"/,\n  );\n});\n\ntest('LINE rejects inline TTS replacements without text before \"{\"', () => {\n  const element = parseLine(`[LINE]\nSpeaker414: {bar}\n~            baz`);\n\n  assert.equal(element?.type, \"ERROR\");\n  assert.match(\n    element?.text ?? \"\",\n    /Inline TTS replacement needs text before \"\\{\": \"\\{bar\\}\"/,\n  );\n});\n\ntest(\"LINE rejects inline TTS replacements with an empty alias\", () => {\n  const element = parseLine(`[LINE]\nSpeaker414: foo{}\n~            bar`);\n\n  assert.equal(element?.type, \"ERROR\");\n  assert.equal(element?.errorKind, \"parse\");\n  assert.equal(element?.lineNumber, 2);\n  assert.equal(element?.sourceLine, \"Speaker414: foo{}\");\n  assert.match(\n    element?.text ?? \"\",\n    /Inline TTS replacement needs spoken text inside braces: \"foo\\{\\}\"/,\n  );\n});\n\ntest(\"unknown blocks expose structured editor error metadata\", () => {\n  const element = parseLine(`[NOT_A_BLOCK]\nSpeaker414: hello`);\n\n  assert.equal(element?.type, \"ERROR\");\n  assert.equal(element?.errorKind, \"unknown_block\");\n  assert.equal(element?.lineNumber, 1);\n  assert.equal(element?.sourceLine, \"[NOT_A_BLOCK]\");\n  assert.equal(element?.text, 'Unknown block type \"NOT_A_BLOCK\"');\n});\n\ntest(\"unknown blocks only emit one error and skip to the next valid block\", () => {\n  const [story] = processStoryFile(\n    `[LINEX]\n> ザリー|とぅ{トゥ} リリー|や バス|んかい 乗とーん{ぬとーん}ん。\n~ Zari and Lily are   bus on riding~{ぬとーん}\n$8287/14b74d63.mp3\n\n[LINE]\nSpeaker418: リリー、 クラス|んかい 転校生|ぬ っ来ゃん{っちゃん}|さ！\n~            Lily  (our)~class in new~student~{てんこうせい} is came~{っちゃん} (sentence~ending)`,\n    0,\n    testAvatars,\n    { learning_language: \"ja\", from_language: \"en\" },\n    \"\",\n  );\n\n  assert.equal(story.elements.length, 2);\n  assert.equal(story.elements[0]?.type, \"ERROR\");\n  assert.equal(story.elements[0]?.errorKind, \"unknown_block\");\n  assert.match(story.elements[0]?.details ?? \"\", /Ignored 3 lines/);\n  assert.equal(story.elements[1]?.type, \"LINE\");\n});\n\ntest(\"block parse errors only emit one error and skip to the next valid block\", () => {\n  const story = parseStory(\n    `[SELECT_PHRASE]\n> Select the missing phrase\n> [ピッツリア 食{か}み欲{ぶ}さん]。\n~  pizza to~want~to~eat\n- ピッツリア 誰ち 持っちょーん\n+ ピッツリア 二ち 持っちょーん\n- ピッツリア 二ち 持ちょーん\n\n[LINE]\nSpeaker414: 今日{ちゅー} 何{ぬー}?\n~            today    what`,\n  );\n\n  assert.equal(story.elements.length, 2);\n  assert.equal(story.elements[0]?.type, \"ERROR\");\n  assert.equal(story.elements[0]?.errorKind, \"parse\");\n  assert.match(\n    story.elements[0]?.text ?? \"\",\n    /Multiple inline TTS replacements in one segment are not allowed: \"食\\{か\\}み欲\\{ぶ\\}さん\"/,\n  );\n  assert.equal(story.elements[1]?.type, \"LINE\");\n  assert.equal(story.elements[1]?.line.content.text, \"今日 何?\");\n});\n\ntest(\"editor block anchors use the block header line and keep the text line active\", () => {\n  const story = parseStory(`[DATA]\nfromLanguageName=Good Morning\nicon=783305780a6dad8e0e4eb34109d948e6a5fc2c35\nset=1|1\n\n[HEADER]\n> うきみそーちー\n~ good~morning\n$8275/691934ea.mp3;5,50;1,600\n\n[LINE]\nSpeaker414: うきみそーちー、 プリティー!\n~            good~morning  Priti\n$8275/8e8657d3.mp3;5,50;2,550;1,438;7,162;0,525`);\n\n  assert.equal(story.elements[0]?.type, \"HEADER\");\n  assert.deepEqual(story.elements[0]?.editor, {\n    block_start_no: 6,\n    start_no: 6,\n    end_no: 11,\n    active_no: 7,\n  });\n\n  assert.equal(story.elements[1]?.type, \"LINE\");\n  assert.deepEqual(story.elements[1]?.editor, {\n    block_start_no: 11,\n    start_no: 11,\n    end_no: 15,\n    active_no: 12,\n  });\n});\n\ntest(\"curly brace TTS override keeps the full tilde-connected phrase\", () => {\n  const [story] = processStoryFile(\n    `[DATA]\nicon_0=test\nspeaker_0=en-US-Test\n\n[LINE]\nSpeaker0: show~word{speak~word}`,\n    0,\n    {},\n    {\n      learning_language: \"en\",\n      from_language: \"fr\",\n    },\n    \"\",\n  );\n\n  const line = story.elements[0];\n  assert.equal(line?.type, \"LINE\");\n  if (line?.type !== \"LINE\") return;\n\n  assert.equal(line.line.content.text, \"show word\");\n  assert.match(\n    line.line.content.audio?.ssml.text ?? \"\",\n    /<sub alias=\"speak word\">show word<\\/sub>/,\n  );\n});\n\ntest(\"curly brace TTS override still works for single words\", () => {\n  const [story] = processStoryFile(\n    `[DATA]\nicon_0=test\nspeaker_0=en-US-Test\n\n[LINE]\nSpeaker0: foo{bar}`,\n    0,\n    {},\n    {\n      learning_language: \"en\",\n      from_language: \"fr\",\n    },\n    \"\",\n  );\n\n  const line = story.elements[0];\n  assert.equal(line?.type, \"LINE\");\n  if (line?.type !== \"LINE\") return;\n\n  assert.equal(line.line.content.text, \"foo\");\n  assert.match(\n    line.line.content.audio?.ssml.text ?? \"\",\n    /<sub alias=\"bar\">foo<\\/sub>/,\n  );\n});\n"
  },
  {
    "path": "src/components/editor/story/parser.ts",
    "content": "/*\nThis parser does the syntax highlighting for the editor\n */\n\nimport { StreamLanguage, StringStream } from \"@codemirror/language\";\nimport { scanInlineTts } from \"./inline_tts\";\n\n// Ideally this should be proper tag definitions instead of mapping them to arbitrary tag symbols\n// I just did not find out yet how to define custom tag symbols\nconst STATE_DEFAULT = \"atom\";\n\nconst STATE_DATA_KEY = \"heading\";\nconst STATE_DATA_VALUE = \"name\";\n\nconst STATE_TRANS_EVEN = \"propertyName\";\nconst STATE_TRANS_ODD = \"macroName\";\nconst STATE_TEXT_EVEN = \"tagName\";\nconst STATE_TEXT_ODD = \"name\";\n\nconst STATE_TEXT_HIDE_EVEN = \"className\";\nconst STATE_TEXT_HIDE_ODD = \"typeName\";\nconst STATE_TEXT_HIDE_NEUTRAL = \"changed\";\n\nconst STATE_TEXT_BUTTON_EVEN = \"number\";\nconst STATE_TEXT_BUTTON_ODD = \"labelName\";\n\nconst STATE_TEXT_HIDE_BUTTON_EVEN = \"meta\";\nconst STATE_TEXT_HIDE_BUTTON_ODD = \"comment\";\nconst STATE_TEXT_BUTTON_RIGHT_EVEN = \"modifier\";\n\nconst STATE_BLOCK_TYPE = \"keyword\";\nconst STATE_SPEAKER_TYPE = STATE_DATA_KEY;\n\nconst STATE_ERROR = \"deleted\";\nconst STATE_TODO = \"annotation\";\n\nconst STATE_AUDIO = \"color\";\nconst STATE_COMMENT = \"comment\"; //\"t.strong\";\n\nconst chalky = \"#e5c07b\",\n  coral = \"#e06c75\",\n  cyan = \"#56b6c2\",\n  invalid = \"#ffffff\",\n  ivory = \"#abb2bf\",\n  stone = \"#7d8799\", // Brightened compared to original to increase contrast\n  malibu = \"#61afef\",\n  sage = \"#98c379\",\n  whiskey = \"#d19a66\",\n  violet = \"#c678dd\";\n/*\n    darkBackground = \"#21252b\",\n    highlightBackground = \"#2c313a\",\n    background = \"#282c34\",\n    tooltipBackground = \"#353a42\",\n    selection = \"#3E4451\",\n    cursor = \"#528bff\"\n */\n\nconst color_even = \"#009623\",\n  color_odd = \"#00389d\";\n\nimport { tags } from \"@lezer/highlight\";\nimport { HighlightStyle } from \"@codemirror/language\";\n\nlet myHighlightStyle = HighlightStyle.define([\n  // STATE_TRANS_EVEN\n  {\n    tag: tags.propertyName,\n    color: color_even,\n    fontStyle: \"italic\",\n    opacity: 0.5,\n  },\n  // STATE_TRANS_ODD\n  { tag: tags.macroName, color: color_odd, fontStyle: \"italic\", opacity: 0.5 },\n  // STATE_TEXT_EVEN\n  { tag: tags.tagName, color: color_even },\n  // STATE_TEXT_ODD\n  { tag: tags.name, color: color_odd },\n\n  // STATE_TEXT_HIDE_EVEN\n  {\n    tag: tags.className,\n    color: color_even,\n    opacity: 0.4,\n    borderBottom: \"2px solid black\",\n  }, // textDecoration: \"underline\",\n  // STATE_TEXT_HIDE_ODD\n  {\n    tag: tags.typeName,\n    color: color_odd,\n    opacity: 0.4,\n    borderBottom: \"2px solid black\",\n  },\n  // STATE_TEXT_HIDE_NEUTRAL\n  { tag: tags.changed, opacity: 0.4, borderBottom: \"2px solid black\" },\n\n  // STATE_TEXT_BUTTON_EVEN\n  {\n    tag: tags.number,\n    color: color_even,\n    background: \"#c8c8c8\",\n    borderRadius: \"10px\",\n  },\n  // STATE_TEXT_BUTTON_ODD\n  {\n    tag: tags.labelName,\n    color: color_odd,\n    background: \"#c8c8c8\",\n    borderRadius: \"10px\",\n  },\n\n  // STATE_TEXT_HIDE_BUTTON_EVEN\n  {\n    tag: tags.meta,\n    color: color_even,\n    borderBottom: \"2px solid black\",\n    background: \"#c8c8c8\",\n    borderRadius: \"10px\",\n    opacity: 0.4,\n  },\n  // STATE_TEXT_HIDE_BUTTON_ODD\n  {\n    tag: tags.comment,\n    color: color_odd,\n    borderBottom: \"2px solid black\",\n    background: \"#c8c8c8\",\n    borderRadius: \"10px\",\n    opacity: 0.4,\n  },\n  // STATE_TEXT_BUTTON_RIGHT_EVEN\n  {\n    tag: tags.modifier,\n    color: \"black\",\n    background: \"#9bd297\",\n    borderRadius: \"10px\",\n  },\n\n  // STATE_BLOCK_TYPE\n  { tag: tags.keyword, color: violet },\n  // STATE_ERROR\n  {\n    tag: [tags.deleted, tags.character],\n    color: coral,\n    textDecoration: \"line-through\",\n  },\n  {\n    tag: [tags.function(tags.variableName)],\n    color: malibu,\n  },\n  // STATE_AUDIO\n  {\n    tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],\n    color: whiskey,\n  },\n  {\n    tag: [tags.definition(tags.name), tags.separator],\n    color: ivory,\n  },\n  // STATE_TODO\n  {\n    tag: [tags.annotation, tags.self, tags.namespace],\n    background: coral,\n    color: ivory,\n  },\n  {\n    tag: [\n      tags.operator,\n      tags.operatorKeyword,\n      tags.url,\n      tags.escape,\n      tags.regexp,\n      tags.link,\n      tags.special(tags.string),\n    ],\n    color: cyan,\n  },\n\n  {\n    tag: tags.strong,\n    color: stone,\n  },\n  {\n    tag: tags.emphasis,\n    fontStyle: \"italic\",\n  },\n  {\n    tag: tags.strikethrough,\n    textDecoration: \"line-through\",\n  },\n  {\n    tag: tags.link,\n    color: stone,\n    textDecoration: \"underline\",\n  },\n  {\n    tag: tags.heading,\n    fontWeight: \"bold\",\n    color: coral,\n  },\n  {\n    tag: tags.atom,\n  },\n  {\n    tag: [tags.bool, tags.special(tags.variableName)],\n    color: whiskey,\n  },\n  {\n    tag: [tags.processingInstruction, tags.string, tags.inserted],\n    color: sage,\n  },\n  {\n    tag: tags.invalid,\n    color: invalid,\n  },\n]);\n\nimport { syntaxHighlighting } from \"@codemirror/language\";\nexport let highlightStyle = syntaxHighlighting(myHighlightStyle);\n\nfunction parserTextWithTranslation(\n  stream: StringStream,\n  state: State,\n  allow_hide: boolean = false,\n  allow_buttons: number = 0,\n) {\n  if (stream.match(/[ |]+/)) {\n    state.odd = !state.odd;\n    if (state.bracket && allow_hide) return STATE_TEXT_HIDE_NEUTRAL;\n    return STATE_DEFAULT;\n  }\n  if (allow_hide) {\n    if (stream.eat(\"[\")) {\n      state.bracket = true;\n      return STATE_DEFAULT;\n    }\n    if (stream.eat(\"]\")) {\n      state.bracket = false;\n      return STATE_DEFAULT;\n    }\n  }\n  if (allow_buttons === 2)\n    if (\n      state.in_button &&\n      state.button_right &&\n      stream.match(/[^ |$\\]\\[()]+/)\n    ) {\n      return STATE_TEXT_BUTTON_RIGHT_EVEN;\n    }\n  if (allow_buttons) {\n    if (!state.in_button && stream.eat(\"(\")) {\n      state.in_button = true;\n      state.button_right = allow_buttons === 2 && Boolean(stream.eat(\"+\"));\n      return STATE_DEFAULT;\n    }\n    if (state.in_button && stream.eat(\")\")) {\n      state.in_button = false;\n      state.button_right = false;\n      return STATE_DEFAULT;\n    }\n  }\n\n  if (\n    (!allow_buttons && stream.match(/[^ |$\\]\\[]+/)) ||\n    (allow_buttons && stream.match(/[^ |$\\]\\[()]+/))\n  ) {\n    if (hasInlineTtsHighlightError(state, stream.start, stream.pos)) {\n      return STATE_ERROR;\n    }\n\n    if (allow_buttons && state.in_button) {\n      if (state.bracket && allow_hide) {\n        if (state.odd) return STATE_TEXT_HIDE_BUTTON_ODD;\n        return STATE_TEXT_HIDE_BUTTON_EVEN;\n      }\n      if (state.odd) return STATE_TEXT_BUTTON_ODD;\n      return STATE_TEXT_BUTTON_EVEN;\n    }\n\n    if (state.bracket && allow_hide) {\n      if (state.odd) return STATE_TEXT_HIDE_ODD;\n      return STATE_TEXT_HIDE_EVEN;\n    }\n\n    if (state.odd) return STATE_TEXT_ODD;\n    return STATE_TEXT_EVEN;\n  }\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\nfunction parserTranslation(stream: StringStream, state: State) {\n  if (stream.match(/[ |]+/)) {\n    state.odd = !state.odd;\n    return STATE_DEFAULT;\n  }\n  if (stream.match(/[^ |$\\]\\[]+/)) {\n    if (state.odd) return STATE_TRANS_ODD;\n    return STATE_TRANS_EVEN;\n  }\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\nfunction parserPair(stream: StringStream, state: State) {\n  if (state.odd === false) {\n    stream.match(/.*(?=<>)/);\n    state.odd = true;\n    return STATE_TEXT_ODD;\n  } else if (stream.eat(\"<>\")) return STATE_DEFAULT;\n  else {\n    stream.skipToEnd();\n    return STATE_TRANS_ODD;\n  }\n}\n\nfunction parseBlockData(stream: StringStream, state: State) {\n  if (stream.match(/[^=]+/)) {\n    if (state.odd) {\n      state.odd = false;\n      return STATE_DATA_VALUE;\n    }\n    return STATE_DATA_KEY;\n  }\n  if (stream.eat(\"=\")) {\n    state.odd = true;\n    return STATE_DEFAULT;\n  }\n\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\nfunction parseBlockHeader(stream: StringStream, state: State) {\n  if (stream.sol()) {\n    if (state.block.line === 0 && stream.eat(\">\")) {\n      startLine(state, 1, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    const hintPrefix = state.block.allow_trans && stream.eat(/[~^]/);\n    if (hintPrefix) {\n      startLine(state, 1, true, hintPrefix === \"~\" ? \"trans\" : \"pron\", true);\n      return STATE_DEFAULT;\n    }\n    if (state.block.allow_audio && stream.eat(\"$\")) {\n      startLine(state);\n      stream.skipToEnd();\n      return STATE_AUDIO;\n    }\n  }\n  if (state.block.line_type === \"text\")\n    return parserTextWithTranslation(stream, state);\n  if (state.block.line_type === \"trans\" || state.block.line_type === \"pron\")\n    return parserTranslation(stream, state);\n\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\nfunction parseBlockLine(stream: StringStream, state: State) {\n  if (stream.sol()) {\n    if (state.block.line === 0 && stream.eat(\">\")) {\n      startLine(state, 1, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    if (state.block.line === 0 && stream.match(/\\S+:/)) {\n      startLine(state, 1, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_SPEAKER_TYPE;\n    }\n    const hintPrefix = state.block.allow_trans && stream.eat(/[~^]/);\n    if (hintPrefix) {\n      startLine(state, 1, true, hintPrefix === \"~\" ? \"trans\" : \"pron\", true);\n      return STATE_DEFAULT;\n    }\n    if (state.block.allow_audio && stream.eat(\"$\")) {\n      startLine(state);\n      stream.skipToEnd();\n      return STATE_AUDIO;\n    }\n\n    stream.skipToEnd();\n    return STATE_ERROR;\n  }\n  if (state.block.line_type === \"text\")\n    return parserTextWithTranslation(stream, state);\n  if (state.block.line_type === \"trans\" || state.block.line_type === \"pron\")\n    return parserTranslation(stream, state);\n\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\nfunction startLine(\n  state: State,\n  line: number = 0,\n  allow_trans: boolean = false,\n  line_type: string = \"\",\n  allow_audio: boolean = false,\n) {\n  let block = { ...state.block };\n  if (line) block.line = line;\n  if (\n    allow_audio === undefined &&\n    state.block.allow_trans &&\n    state.block.allow_audio\n  )\n    allow_audio = state.block.allow_audio;\n  block.allow_audio = allow_audio;\n  block.allow_trans = allow_trans;\n  state.odd = false;\n  block.line_type = line_type;\n  state.block = block;\n  state.inline_tts_invalid_ranges = [];\n}\n\nfunction setInlineTtsHighlightRanges(\n  stream: StringStream,\n  state: State,\n  enabled: boolean,\n) {\n  if (!enabled) {\n    state.inline_tts_invalid_ranges = [];\n    return;\n  }\n  const { errors } = scanInlineTts(stream.string.slice(stream.pos));\n  state.inline_tts_invalid_ranges = errors.map((error) => ({\n    start: error.start + stream.pos,\n    end: error.end + stream.pos,\n  }));\n}\n\nfunction hasInlineTtsHighlightError(state: State, start: number, end: number) {\n  return state.inline_tts_invalid_ranges.some(\n    (range) => start < range.end && end > range.start,\n  );\n}\n\nfunction parseBlockSelectPhrase(stream: StringStream, state: State) {\n  if (stream.sol()) {\n    if (state.block.line === 0 && stream.eat(\">\")) {\n      startLine(state, 1, true, \"text\");\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    const hintPrefix = state.block.allow_trans && stream.eat(/[~^]/);\n    if (hintPrefix) {\n      startLine(\n        state,\n        undefined,\n        true,\n        hintPrefix === \"~\" ? \"trans\" : \"pron\",\n        true,\n      );\n      return STATE_DEFAULT;\n    }\n    if (state.block.line === 1 && stream.match(/\\S+:/)) {\n      startLine(state, 2, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_SPEAKER_TYPE;\n    }\n    if (state.block.line === 1 && stream.eat(/>/)) {\n      startLine(state, 2, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    if (state.block.allow_audio && stream.eat(\"$\")) {\n      startLine(state);\n      stream.skipToEnd();\n      return STATE_AUDIO;\n    }\n\n    if (state.block.line >= 2 && stream.eat(\"+\")) {\n      startLine(state, 3, false, \"text\");\n      stream.skipToEnd();\n      return STATE_DEFAULT;\n    }\n    if (state.block.line >= 2 && stream.eat(\"-\")) {\n      startLine(state, 3, false, \"text\");\n      stream.skipToEnd();\n      return STATE_DEFAULT;\n    }\n\n    stream.skipToEnd();\n    return STATE_ERROR;\n  }\n  if (state.block.line_type === \"text\")\n    return parserTextWithTranslation(stream, state, state.block.line === 2);\n  if (state.block.line_type === \"trans\" || state.block.line_type === \"pron\")\n    return parserTranslation(stream, state);\n\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\nfunction parseBlockContinuation(stream: StringStream, state: State) {\n  if (stream.sol()) {\n    if (state.block.line === 0 && stream.eat(\">\")) {\n      startLine(state, 1, true, \"text\");\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    const hintPrefix = state.block.allow_trans && stream.eat(/[~^]/);\n    if (hintPrefix) {\n      startLine(\n        state,\n        undefined,\n        true,\n        hintPrefix === \"~\" ? \"trans\" : \"pron\",\n        true,\n      );\n      return STATE_DEFAULT;\n    }\n    if (state.block.line === 1 && stream.match(/\\S+:/)) {\n      startLine(state, 2, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_SPEAKER_TYPE;\n    }\n    if (state.block.line === 1 && stream.eat(\">\")) {\n      startLine(state, 2, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    if (state.block.allow_audio && stream.eat(\"$\")) {\n      startLine(state);\n      stream.skipToEnd();\n      return STATE_AUDIO;\n    }\n\n    if (state.block.line >= 2 && stream.eat(\"+\")) {\n      startLine(state, 3, true, \"text\");\n      return STATE_DEFAULT;\n    }\n    if (state.block.line >= 2 && stream.eat(\"-\")) {\n      startLine(state, 3, true, \"text\");\n      return STATE_DEFAULT;\n    }\n\n    stream.skipToEnd();\n    return STATE_ERROR;\n  }\n  if (state.block.line_type === \"text\")\n    return parserTextWithTranslation(stream, state, state.block.line === 2);\n  if (state.block.line_type === \"trans\" || state.block.line_type === \"pron\")\n    return parserTranslation(stream, state);\n\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\nfunction parseBlockMultipleChoice(stream: StringStream, state: State) {\n  if (stream.sol()) {\n    if (state.block.line === 0 && stream.eat(\">\")) {\n      startLine(state, 1, true, \"text\");\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    const hintPrefix = state.block.allow_trans && stream.eat(/[~^]/);\n    if (hintPrefix) {\n      startLine(\n        state,\n        undefined,\n        true,\n        hintPrefix === \"~\" ? \"trans\" : \"pron\",\n        true,\n      );\n      return STATE_DEFAULT;\n    }\n    if (state.block.line >= 1 && stream.eat(\"+\")) {\n      startLine(state, 2, true, \"text\");\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    if (state.block.line >= 1 && stream.eat(\"-\")) {\n      startLine(state, 2, true, \"text\");\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n\n    stream.skipToEnd();\n    return STATE_ERROR;\n  }\n  if (state.block.line_type === \"text\")\n    return parserTextWithTranslation(stream, state);\n  if (state.block.line_type === \"trans\" || state.block.line_type === \"pron\")\n    return parserTranslation(stream, state);\n\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\nfunction parseBlockArrange(stream: StringStream, state: State) {\n  if (stream.sol()) {\n    if (state.block.line === 0 && stream.eat(\">\")) {\n      startLine(state, 1, true, \"text\");\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    const hintPrefix = state.block.allow_trans && stream.eat(/[~^]/);\n    if (hintPrefix) {\n      startLine(\n        state,\n        undefined,\n        true,\n        hintPrefix === \"~\" ? \"trans\" : \"pron\",\n        true,\n      );\n      return STATE_DEFAULT;\n    }\n    if (state.block.line === 1 && stream.match(/\\S+:/)) {\n      startLine(state, 2, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_SPEAKER_TYPE;\n    }\n    if (state.block.line === 1 && stream.eat(\">\")) {\n      startLine(state, 2, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    if (state.block.allow_audio && stream.eat(\"$\")) {\n      startLine(state);\n      stream.skipToEnd();\n      return STATE_AUDIO;\n    }\n\n    stream.skipToEnd();\n    return STATE_ERROR;\n  }\n  if (state.block.line_type === \"text\")\n    return parserTextWithTranslation(\n      stream,\n      state,\n      state.block.line === 2,\n      state.block.line === 2 ? 1 : 0,\n    );\n  if (state.block.line_type === \"trans\" || state.block.line_type === \"pron\")\n    return parserTranslation(stream, state);\n\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\nfunction parseBlockPointToPhrase(stream: StringStream, state: State) {\n  if (stream.sol()) {\n    if (state.block.line === 0 && stream.eat(\">\")) {\n      startLine(state, 1, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    const hintPrefix = state.block.allow_trans && stream.eat(/[~^]/);\n    if (hintPrefix) {\n      startLine(\n        state,\n        undefined,\n        true,\n        hintPrefix === \"~\" ? \"trans\" : \"pron\",\n        true,\n      );\n      return STATE_DEFAULT;\n    }\n    if (state.block.line === 1 && stream.match(/\\S+:/)) {\n      startLine(state, 2, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_SPEAKER_TYPE;\n    }\n    if (state.block.line === 1 && stream.eat(\">\")) {\n      startLine(state, 2, true, \"text\", true);\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    if (state.block.allow_audio && stream.eat(\"$\")) {\n      startLine(state);\n      stream.skipToEnd();\n      return STATE_AUDIO;\n    }\n\n    stream.skipToEnd();\n    return STATE_ERROR;\n  }\n  if (state.block.line_type === \"text\")\n    return parserTextWithTranslation(\n      stream,\n      state,\n      state.block.line === 2,\n      state.block.line === 2 ? 2 : 0,\n    );\n  if (state.block.line_type === \"trans\" || state.block.line_type === \"pron\")\n    return parserTranslation(stream, state);\n\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\nfunction parseBlockMatch(stream: StringStream, state: State) {\n  if (stream.sol()) {\n    if (state.block.line === 0 && stream.eat(\">\")) {\n      startLine(state, 1, true, \"text\");\n      setInlineTtsHighlightRanges(stream, state, true);\n      return STATE_DEFAULT;\n    }\n    const hintPrefix = state.block.allow_trans && stream.eat(/[~^]/);\n    if (hintPrefix) {\n      startLine(\n        state,\n        undefined,\n        true,\n        hintPrefix === \"~\" ? \"trans\" : \"pron\",\n        true,\n      );\n      return STATE_DEFAULT;\n    }\n    if (state.block.line === 1 && stream.eat(\"-\")) {\n      startLine(state, 1, false, \"pair\");\n      return STATE_DEFAULT;\n    }\n\n    stream.skipToEnd();\n    return STATE_ERROR;\n  }\n  if (state.block.line_type === \"text\")\n    return parserTextWithTranslation(stream, state);\n  if (state.block.line_type === \"trans\" || state.block.line_type === \"pron\")\n    return parserTranslation(stream, state);\n  if (state.block.line_type === \"pair\") return parserPair(stream, state);\n\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\nconst BLOCK_FUNCS: Record<\n  string,\n  (stream: StringStream, state: State) => string\n> = {\n  DATA: parseBlockData,\n  HEADER: parseBlockHeader,\n  LINE: parseBlockLine,\n  MULTIPLE_CHOICE: parseBlockMultipleChoice,\n  SELECT_PHRASE: parseBlockSelectPhrase,\n  CONTINUATION: parseBlockContinuation,\n  ARRANGE: parseBlockArrange,\n  MATCH: parseBlockMatch,\n  POINT_TO_PHRASE: parseBlockPointToPhrase,\n};\n\nfunction parseBlockDef(stream: StringStream, state: State) {\n  if (stream.eat(\"]\")) {\n    state.func = BLOCK_FUNCS[state?.block?.name];\n    return STATE_DEFAULT;\n  }\n  const match = stream.match(/[^\\]]+/);\n  const name =\n    match && typeof match === \"object\" && \"0\" in match ? match[0] : \"\";\n  state.block = {\n    name: name,\n    line: 0,\n    line_type: \"\",\n    allow_trans: false,\n    allow_audio: false,\n  };\n  return STATE_BLOCK_TYPE;\n}\n\nfunction parserWithMetadata(stream: StringStream, state: State) {\n  if (stream.match(\"#\")) {\n    if (stream.match(/.*TODO.*/)) {\n      stream.skipToEnd();\n      return STATE_TODO;\n    }\n    stream.skipToEnd();\n    return STATE_COMMENT;\n  }\n  if (stream.sol()) {\n    stream.match(/\\s*/);\n    if (stream.eat(\"[\")) {\n      state.func = parseBlockDef;\n      return STATE_DEFAULT;\n    }\n  }\n  if (state.func) return state.func(stream, state);\n\n  stream.skipToEnd();\n  return STATE_ERROR;\n}\n\ntype State = {\n  pos: string;\n  block: {\n    name: string;\n    line: number;\n    line_type: string;\n    allow_trans: boolean;\n    allow_audio: boolean;\n  };\n  odd: boolean;\n  func: any;\n  bracket: boolean;\n  in_button: boolean;\n  button_right: boolean;\n  inline_tts_invalid_ranges: Array<{ start: number; end: number }>;\n};\n\nfunction createStartState(): State {\n  return {\n    pos: \"default\",\n    block: {\n      name: \"\",\n      line: 0,\n      line_type: \"\",\n      allow_trans: false,\n      allow_audio: false,\n    },\n    odd: false,\n    func: null,\n    bracket: false,\n    in_button: false,\n    button_right: false,\n    inline_tts_invalid_ranges: [],\n  };\n}\n\nexport function __testTokenizeLines(\n  text: string,\n): Array<Array<{ text: string; style: string }>> {\n  const state = createStartState();\n  return text.split(\"\\n\").map((line) => {\n    const stream = new StringStream(line, 2, 2);\n    const tokens: Array<{ text: string; style: string }> = [];\n\n    while (!stream.eol()) {\n      stream.start = stream.pos;\n      const style = parserWithMetadata(stream, state) ?? STATE_DEFAULT;\n      if (stream.pos === stream.start) {\n        stream.next();\n      }\n      tokens.push({\n        text: line.slice(stream.start, stream.pos),\n        style,\n      });\n    }\n\n    return tokens;\n  });\n}\n\nconst exampleLanguage = StreamLanguage.define({\n  token: parserWithMetadata,\n  startState() {\n    return createStartState();\n  },\n});\n\nimport { LanguageSupport } from \"@codemirror/language\";\n\nexport function example() {\n  return new LanguageSupport(exampleLanguage);\n}\n"
  },
  {
    "path": "src/components/editor/story/scroll_linking.ts",
    "content": "\"use no memo\";\nimport React from \"react\";\nimport { EditorView } from \"codemirror\";\n\nfunction update_lines(editor: HTMLElement, svg_parent: SVGElement | null) {\n  const line_element = editor.querySelector(\".cm-line\");\n  if (!editor || !svg_parent || !line_element) return;\n  if (!document.defaultView) return;\n  const line_height = line_element.getBoundingClientRect().height;\n\n  let svg_element = 0;\n  const width1 = parseInt(document.defaultView.getComputedStyle(editor).width);\n  if (isNaN(width1)) return;\n  const width1b =\n    parseInt(document.defaultView.getComputedStyle(editor).width) + 20;\n  const width2 =\n    parseInt(document.defaultView.getComputedStyle(editor).width) + 40;\n  const width3 = svg_parent.getBoundingClientRect().width;\n  const height = svg_parent.getBoundingClientRect().height;\n\n  let path = \"M0,0 \";\n  for (const element of document.querySelectorAll<\n    HTMLElement & { dataset: { lineno: string } }\n  >(\"div[data-lineno]\")) {\n    const new_lineno = parseInt(element.dataset.lineno);\n    const new_top =\n      element.getBoundingClientRect().top -\n      svg_parent.getBoundingClientRect().top -\n      10; // - preview.scrollTop - preview.getBoundingClientRect().top\n    const new_linetop =\n      -10 +\n      (4 + new_lineno) * line_height -\n      editor.scrollTop -\n      svg_parent.getBoundingClientRect().top -\n      editor.getBoundingClientRect().top;\n\n    if (svg_element % 2 === 0)\n      path += `L0,${new_linetop} L ${width1},${new_linetop} C${width1b},${new_linetop} ${width1b},${new_top} ${width2},${new_top} L${width3},${new_top}`;\n    else\n      path += `L${width3},${new_top} L ${width2},${new_top} C${width1b},${new_top} ${width1b},${new_linetop} ${width1},${new_linetop} L0,${new_linetop}`;\n    svg_element += 1;\n  }\n  if (svg_element % 2 === 1) path += `L${width3},${height} L ${0},${height}`;\n\n  svg_parent.children[0].setAttribute(\"d\", path);\n}\n\nfunction createScrollLookUp(\n  editor: HTMLElement,\n  preview: HTMLElement | undefined,\n) {\n  const line_element = editor.querySelector(\".cm-line\");\n  const line_map: [number, number, number][] = [[0, 0, 0]];\n  if (!preview || !line_element) return line_map;\n\n  const line_height = line_element.getBoundingClientRect().height;\n  for (let element of document.querySelectorAll<\n    HTMLElement & { dataset: { lineno: string } }\n  >(\"div[data-lineno]\")) {\n    let new_lineno = parseInt(element.dataset.lineno);\n    let new_line_top = new_lineno * line_height + 2 - line_height;\n    let new_top =\n      element.getBoundingClientRect().top +\n      preview.scrollTop -\n      preview.getBoundingClientRect().top -\n      10;\n    line_map.push([new_lineno, new_line_top, new_top]);\n  }\n\n  return line_map;\n}\n\nfunction map_side(\n  scroll_pos: number,\n  pairss: [number, number, number][],\n  from_i: number,\n  to_i: number,\n  o: number,\n) {\n  scroll_pos = scroll_pos + o;\n  if (pairss === undefined) return;\n  function round(x: number) {\n    return x;\n  }\n  for (let i = 0; i < pairss.length - 1; i += 1) {\n    const x1 = pairss[i][from_i];\n    const x2 = pairss[i + 1][from_i];\n    const y1 = pairss[i][to_i];\n    const y2 = pairss[i + 1][to_i];\n    if (x1 <= scroll_pos && scroll_pos < x2) {\n      //if (i === 5) {\n      const scroll_diff = scroll_pos - x1;\n      const difference = x2 - x1 - (y2 - y1);\n      if (difference > 0) {\n        if (scroll_diff < difference) return round(y1 - o);\n        else return round(y1 + scroll_diff - difference - o);\n      } else {\n        if (scroll_diff < (x2 - x1) / 2) return round(y1 + scroll_diff - o);\n        else return round(y1 + scroll_diff - difference - o);\n      }\n    }\n  }\n}\n\nfunction useAutoResetRef<T>() {\n  const last_scrolled_element = React.useRef<T | null>(null);\n  const last_scrolled_element_timeout = React.useRef<ReturnType<\n    typeof setTimeout\n  > | null>(null);\n  const setLastScrolledElement = React.useCallback((element: T) => {\n    last_scrolled_element.current = element;\n    if (last_scrolled_element_timeout.current !== null) {\n      clearTimeout(last_scrolled_element_timeout.current);\n    }\n    last_scrolled_element_timeout.current = setTimeout(() => {\n      last_scrolled_element.current = null;\n      last_scrolled_element_timeout.current = null;\n    }, 500);\n  }, []);\n  return [last_scrolled_element, setLastScrolledElement] as const;\n}\n\nexport default function useScrollLinking(\n  view: EditorView | undefined,\n  previewRef: React.RefObject<HTMLElement | null>,\n  svgParentRef: React.RefObject<SVGElement | null>,\n) {\n  const editor = view?.scrollDOM;\n  const [last_scrolled_element, setLastScrolledElement] = useAutoResetRef<\n    \"editor\" | \"preview\"\n  >();\n  const syncEditorScroll = React.useEffectEvent(() => {\n    const preview = previewRef.current;\n    const svg_parent = svgParentRef.current;\n    if (!editor || !preview) return;\n    if (last_scrolled_element.current === \"preview\") return;\n    setLastScrolledElement(\"editor\");\n\n    const new_pos = map_side(\n      editor.scrollTop,\n      createScrollLookUp(editor, preview),\n      1,\n      2,\n      editor.getBoundingClientRect().height / 3,\n    );\n    if (new_pos === undefined) return;\n    preview.scrollTo({\n      top: new_pos,\n      behavior: \"auto\",\n    });\n    update_lines(editor, svg_parent);\n  });\n  const syncPreviewScroll = React.useEffectEvent(() => {\n    const preview = previewRef.current;\n    const svg_parent = svgParentRef.current;\n    if (!editor || !preview) return;\n    if (last_scrolled_element.current === \"editor\") return;\n    setLastScrolledElement(\"preview\");\n\n    const new_pos = map_side(\n      preview.scrollTop,\n      createScrollLookUp(editor, preview),\n      2,\n      1,\n      editor.getBoundingClientRect().height / 3,\n    );\n    if (new_pos === undefined) return;\n    editor.scrollTo({\n      top: new_pos,\n      behavior: \"auto\",\n    });\n    update_lines(editor, svg_parent);\n  });\n  const syncResize = React.useEffectEvent(() => {\n    const preview = previewRef.current;\n    const svg_parent = svgParentRef.current;\n    if (!editor || !preview) return;\n    const new_pos = map_side(\n      editor.scrollTop,\n      createScrollLookUp(editor, preview),\n      1,\n      2,\n      editor.getBoundingClientRect().height / 3,\n    );\n    if (new_pos === undefined) return;\n    setLastScrolledElement(\"editor\");\n    preview.scrollTo({\n      top: new_pos,\n      behavior: \"auto\",\n    });\n    update_lines(editor, svg_parent);\n  });\n\n  React.useEffect(() => {\n    if (!editor) return;\n\n    function editor_scroll() {\n      requestAnimationFrame(() => syncEditorScroll());\n    }\n    function syncPreview() {\n      requestAnimationFrame(() => syncResize());\n    }\n    editor.addEventListener(\"scroll\", editor_scroll);\n    editor.addEventListener(\"story-editor-sync-preview\", syncPreview);\n    return () => {\n      editor.removeEventListener(\"scroll\", editor_scroll);\n      editor.removeEventListener(\"story-editor-sync-preview\", syncPreview);\n    };\n  }, [editor]);\n\n  React.useEffect(() => {\n    const preview = previewRef.current;\n    const svg_parent = svgParentRef.current;\n    if (!preview || !editor) return;\n    update_lines(editor, svg_parent);\n    requestAnimationFrame(() => {\n      syncResize();\n    });\n\n    function preview_scroll() {\n      requestAnimationFrame(() => syncPreviewScroll());\n    }\n\n    preview.addEventListener(\"scroll\", preview_scroll);\n    return () => preview.removeEventListener(\"scroll\", preview_scroll);\n  }, [editor, previewRef, svgParentRef]);\n\n  React.useEffect(() => {\n    // `syncResize` is a useEffectEvent, so it reads the latest editor refs.\n    function windowResize() {\n      syncResize();\n    }\n    window.addEventListener(\"resize\", windowResize);\n    return () => window.removeEventListener(\"resize\", windowResize);\n  }, []);\n}\n"
  },
  {
    "path": "src/components/editor/story/syntax_parser_new.ts",
    "content": "import {\n  generate_ssml_line,\n  text_to_keypoints,\n} from \"@/lib/editor/audio/audio_edit_tools\";\nimport { Avatar } from \"@/app/editor/story/[story]/types\";\nimport {\n  Audio,\n  ContentWithHints,\n  HideRange,\n  HintMapResult,\n  LineElementCharacter,\n  LineElementProse,\n  StoryElement,\n  StoryElementArrange,\n  StoryElementChallengePrompt,\n  StoryElementHeader,\n  StoryElementLine,\n  StoryElementMatch,\n  StoryElementMultipleChoice,\n  StoryElementPointToPhrase,\n  StoryElementSelectPhrase,\n} from \"@/components/editor/story/syntax_parser_types\";\nimport {\n  formatInlineTtsError,\n  scanInlineTts,\n  type InlineTtsReplacement,\n} from \"@/components/editor/story/inline_tts\";\n\nexport type IpaReplacement = InlineTtsReplacement;\n\nfunction generateHintMap(\n  text: string = \"\",\n  translation: string = \"\",\n  pronunciation: string = \"\",\n): HintMapResult {\n  function unescapeBraces(value: string): string {\n    return value.replace(/\\\\([{}])/g, \"$1\");\n  }\n\n  function isEscapedAt(value: string, index: number): boolean {\n    let backslashes = 0;\n    for (let i = index - 1; i >= 0 && value[i] === \"\\\\\"; i -= 1) {\n      backslashes += 1;\n    }\n    return backslashes % 2 === 1;\n  }\n\n  function lastUnescapedIndexOf(\n    value: string,\n    char: string,\n    end: number,\n  ): number {\n    for (let i = end; i >= 0; i -= 1) {\n      if (value[i] === char && !isEscapedAt(value, i)) {\n        return i;\n      }\n    }\n    return -1;\n  }\n\n  function parseInlinePronunciationHint(token: string): {\n    translation: string;\n    pronunciation: string;\n  } {\n    const end = token.length - 1;\n    const closeIndex = lastUnescapedIndexOf(token, \"}\", end);\n    if (closeIndex === -1) {\n      return { translation: unescapeBraces(token), pronunciation: \"\" };\n    }\n    const trailing = token.substring(closeIndex + 1);\n    if (trailing.trim() !== \"\") {\n      return { translation: unescapeBraces(token), pronunciation: \"\" };\n    }\n    const openIndex = lastUnescapedIndexOf(token, \"{\", closeIndex - 1);\n    if (openIndex === -1) {\n      return { translation: unescapeBraces(token), pronunciation: \"\" };\n    }\n    return {\n      translation: unescapeBraces(\n        token.substring(0, openIndex).replace(/~+$/, \"\").trimEnd(),\n      ),\n      pronunciation: unescapeBraces(\n        token.substring(openIndex + 1, closeIndex).trim(),\n      ),\n    };\n  }\n\n  if (!text) text = \"\";\n  text = text.replace(/\\|/g, \"⁠\");\n  let text_list = splitTextTokens(text);\n  text = text.replace(/~/g, \" \"); //\n  if (!translation) translation = \"\";\n  translation = translation.replace(/\\|/g, \"⁠\");\n  if (!pronunciation) pronunciation = \"\";\n  pronunciation = pronunciation.replace(/\\|/g, \"⁠\");\n  let trans_list = splitTextTokens2(translation);\n  let pron_list = splitTextTokens2(pronunciation);\n  let hints = [];\n  let hints_pronunciation = [];\n  let hintMap = [];\n  let text_pos = 0;\n  for (let i = 0; i < text_list.length; i++) {\n    if (i === 0 && text_list[i] === \"\") {\n      trans_list.unshift(\"\", \"\");\n      pron_list.unshift(\"\", \"\");\n    }\n    const trans_value = trans_list[i];\n    const pron_value = pron_list[i];\n    const {\n      translation: trans_value_clean,\n      pronunciation: trans_inline_pron_value,\n    } =\n      trans_value && trans_value !== \"~\"\n        ? parseInlinePronunciationHint(trans_value)\n        : { translation: \"\", pronunciation: \"\" };\n\n    const has_trans_hint = Boolean(trans_value && trans_value !== \"~\");\n    const has_pron_hint = Boolean(\n      (pron_value && pron_value !== \"~\") || trans_inline_pron_value,\n    );\n    if (i % 2 === 0 && (has_trans_hint || has_pron_hint)) {\n      hintMap.push({\n        hintIndex: hints.length,\n        rangeFrom: text_pos,\n        rangeTo: text_pos + text_list[i].length - 1,\n      });\n      hints.push(\n        has_trans_hint\n          ? trans_value_clean.replace(/~/g, \" \").replace(/\\|/g, \"⁠\")\n          : \"\",\n      );\n      hints_pronunciation.push(\n        has_pron_hint\n          ? (pron_value && pron_value !== \"~\"\n              ? pron_value\n              : trans_inline_pron_value\n            )\n              .replace(/~/g, \" \")\n              .replace(/\\|/g, \"⁠\")\n          : \"\",\n      );\n    }\n    text_pos += text_list[i].length;\n  }\n  const result: HintMapResult = {\n    hintMap: hintMap,\n    hints: hints,\n    text: text.trim(),\n  };\n  if (hints_pronunciation.some((hint) => hint !== \"\")) {\n    result.hints_pronunciation = hints_pronunciation;\n  }\n  return result;\n}\n\nfunction hintsShift(content: ContentWithHints, pos: number): void {\n  for (let i in content.hintMap) {\n    if (content.hintMap[i].rangeFrom > pos) content.hintMap[i].rangeFrom -= 1;\n    if (content.hintMap[i].rangeTo >= pos) content.hintMap[i].rangeTo -= 1;\n  }\n  content.text =\n    content.text.substring(0, pos) + content.text.substring(pos + 1);\n}\n\nfunction getButtons(content: ContentWithHints): [string[], number[]] {\n  let buttons = [...content.text.matchAll(/\\(([^)]*)\\)/g)];\n  let selectablePhrases = [];\n  for (let button of buttons) {\n    selectablePhrases.push(\n      button[1].replace(/\\[\\[/g, \"[\").replace(/]]/g, \"]\").replace(/\\\\n/g, \"\\n\"),\n    );\n  }\n  let characterPositions = [];\n  let pos1 = content.text.indexOf(\")\");\n  while (pos1 !== -1) {\n    hintsShift(content, pos1);\n    pos1 = content.text.indexOf(\")\");\n  }\n  pos1 = content.text.indexOf(\"(\");\n  let first = true;\n  while (pos1 !== -1) {\n    hintsShift(content, pos1);\n    if (content.text.substring(pos1, pos1 + 1) === \"+\")\n      hintsShift(content, pos1);\n    if (!first) characterPositions.push(pos1 - 1);\n    first = false;\n    pos1 = content.text.indexOf(\"(\");\n  }\n  characterPositions.push(content.text.length - 2);\n\n  return [selectablePhrases, characterPositions];\n}\n\nfunction regexIndexOf(\n  string: string,\n  regex: RegExp,\n  startpos?: number,\n): number {\n  var indexOf = string.substring(startpos || 0).search(regex);\n  return indexOf >= 0 ? indexOf + (startpos || 0) : indexOf;\n}\nfunction removeDoubleBrackets(\n  content: ContentWithHints,\n  pos: number[],\n): number[] {\n  for (let char of [/(?<!\\[)\\[(?!\\[)/, /(?<!\\])\\](?!\\])/, /\\[\\[/, /\\]\\]/]) {\n    let pos1 = regexIndexOf(content.text, char);\n    while (pos1 !== -1) {\n      for (let i = 0; i < pos.length; i++) if (pos[i] > pos1) pos[i] -= 1;\n      hintsShift(content, pos1);\n      pos1 = regexIndexOf(content.text, char);\n    }\n  }\n  return pos;\n}\n\nfunction getHideRanges(content: ContentWithHints): HideRange[] {\n  let pos_br = content.text.indexOf(\"\\\\n\");\n  while (pos_br !== -1) {\n    hintsShift(content, pos_br);\n    content.text =\n      content.text.substring(0, pos_br) +\n      \"\\n\" +\n      content.text.substring(pos_br + 1);\n    pos_br = content.text.indexOf(\"\\\\n\");\n  }\n  //content.text = content.text.replace(/\\\\n/g, \"\\n\");\n\n  // find brackets that are not doubled\n  let pos1 = regexIndexOf(content.text, /(?<!\\[)\\[(?!\\[)/);\n  let pos2 = regexIndexOf(content.text, /(?<!\\])\\](?!\\])/);\n\n  if (pos1 !== -1 && pos2 !== -1) {\n    hintsShift(content, pos1);\n    hintsShift(content, pos2 - 1);\n    [pos1, pos2] = removeDoubleBrackets(content, [pos1, pos2]);\n    return [{ start: pos1, end: pos2 - 1 }];\n  }\n  removeDoubleBrackets(content, []);\n  return [];\n}\n\nfunction shuffleArray(selectablePhrases: string[]): [number[], string[]] {\n  let phraseOrder = [];\n  for (let i in selectablePhrases) {\n    phraseOrder.push(parseInt(i));\n  }\n  const shuffleArray = (array: number[]) => {\n    for (let i = array.length - 1; i > 0; i--) {\n      const j = Math.floor(Math.random() * (i + 1));\n      const temp = array[i];\n      array[i] = array[j];\n      array[j] = temp;\n    }\n  };\n  shuffleArray(phraseOrder);\n  let selectablePhrases2 = [];\n  for (let i of phraseOrder) selectablePhrases2.push(selectablePhrases[i]);\n  return [phraseOrder, selectablePhrases2];\n}\n\nfunction split_lines(text: string) {\n  /* splits the text into lines and removes comments */\n  let lines: LineTuple[] = [];\n  let lineno = 0;\n  let todo_count = 0;\n  for (let line of text.split(\"\\n\")) {\n    lineno += 1;\n    // ignore empty lines or lines with comments (and remove rtl isolate)\n    line = line.trim().replace(/\\u2067/, \"\");\n    if (line.length === 0 || line.substring(0, 1) === \"#\") {\n      if (line.indexOf(\"TODO\") !== -1) todo_count++;\n      continue;\n    }\n\n    lines.push([lineno, line]);\n  }\n  lines.push([lineno + 1, \"\"]);\n  lines.push([lineno + 2, \"\"]);\n  return { lines, todo_count };\n}\n\nfunction processBlockData(line_iter: LineIterator, story: StoryWithMeta) {\n  while (line_iter.get()) {\n    let line = line_iter.get();\n    if (!line) break;\n    if (line.indexOf(\"=\") !== -1) {\n      let [key, value] = line.split(\"=\", 2);\n      key = key.trim();\n      if (key == \"set\") {\n        [story.meta.set_id, story.meta.set_index] = value\n          .split(\"|\")\n          .map((x) => parseInt(x));\n      } else if (key == \"set_id\" || key == \"set_index\") {\n        story.meta[key] = parseInt(value.trim());\n      } else story.meta[key.trim()] = value.trim();\n      line_iter.advance();\n      continue;\n    }\n    break;\n  }\n  for (let key in story.meta) {\n    if (key.startsWith(\"icon_\")) {\n      let id = key.substring(5);\n      if (!story.meta.avatar_overwrites[id])\n        story.meta.avatar_overwrites[id] = {\n          id: id,\n          link: \"\",\n          speaker: \"\",\n          name: \"\",\n        };\n      story.meta.avatar_overwrites[id].link = story.meta[key] ?? \"\";\n    }\n    if (key.startsWith(\"speaker_\")) {\n      let id = key.substring(8);\n      if (!story.meta.avatar_overwrites[id])\n        story.meta.avatar_overwrites[id] = {\n          id: id,\n          link: \"\",\n          speaker: \"\",\n          name: \"\",\n        };\n      story.meta.avatar_overwrites[id].speaker = story.meta[key] ?? \"\";\n    }\n  }\n  story.from_language_name = story.meta.from_language_name;\n}\n\nlet punctuation_chars =\n  \"\\\\/¡!\\\"'`#$%&*,.:;<=>¿?@^_`{|}…\" + \"。、，！？；：（）～—·《…》〈…〉﹏……——\";\n//punctuation_chars = \"\\\\\\\\¡!\\\"#$%&*,、，.。\\\\/:：;<=>¿?@^_`{|}…\"\n\nlet regex_split_token = new RegExp(\n  `([\\\\s${punctuation_chars}\\\\]]*(?:^|\\\\s|$|​|⁠)[\\\\s${punctuation_chars}]*)`,\n);\nlet regex_split_token2 = new RegExp(\n  `([\\\\s${punctuation_chars}~]*(?:^|\\\\s|$|​|⁠)[\\\\s${punctuation_chars}~]*)`,\n);\n/*\nfunction splitTextTokens(text, keep_tilde=true) {\n    if(!text)\n        return [];\n    //console.log(text, text.split(/([\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*(?:^|\\s|$)[\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*)/))\n    if(keep_tilde)\n        return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}]+)/)\n    //return text.split(regex_split_token)\n    else\n        return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}~]+)/)\n    //return text.split(regex_split_token2)\n}\n*/\n\nfunction splitTextTokens(text: string, keep_tilde = true) {\n  if (!text) return [];\n  //console.log(text, text.split(/([\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*(?:^|\\s|$)[\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*)/))\n  if (keep_tilde)\n    //return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}]+)/)\n    return text.split(regex_split_token);\n  //return text.split(/([\\s\\\\¡!\"#$%&*,、，.。\\/:：;<=>¿?@^_`{|}…\\]]*(?:^|\\s|$|​)[\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*)/)\n  //return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}~]+)/)\n  else return text.split(regex_split_token2);\n  //return text.split(/([\\s\\\\¡!\"#$%&*,、，.。\\/:：;<=>¿?@^_`{|}…~]*(?:^|\\s|$|​)[\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…~]*)/)\n}\n\nfunction splitTextTokens2(text: string, keep_tilde = true) {\n  if (!text) return [];\n  if (keep_tilde)\n    //return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}]+)/)\n    return text.split(/([\\s​⁠]+)/);\n  //return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}~]+)/)\n  else return text.split(/([\\s​⁠~]+)/);\n}\n\nfunction getInputStringText(text: string) {\n  // remove multiple white space characters\n  text = text.replace(/(\\s)\\s+/g, \"$1\");\n  //\n  return text; //.replace(/([^-|~ ,、，;.。:：_?!…]*){([^}]*)}/g, \"$1\");\n}\n\nfunction speaker_text_trans(\n  data: Speaker,\n  meta?: Meta,\n  use_buttons = false,\n  hide = false,\n) {\n  //console.log(\"data.text\", data.text)\n\n  const text_match = data.text?.match(\n    /\\s*(?:>?\\s*(\\w*)\\s*:|>|\\+|-)\\s*(\\S.*\\S|\\S)\\s*/,\n  );\n  const speaker_text = text_match?.[1] || \"\";\n  let text = text_match?.[2] || \"\";\n\n  const translation = data.trans?.match(/\\s*~\\s*(\\S.*\\S|\\S)\\s*/)?.[1] || \"\";\n  const pronunciation = data.pron?.match(/\\s*\\^\\s*(\\S.*\\S|\\S)\\s*/)?.[1] || \"\";\n\n  getInputStringText(text);\n  const inlineTtsData = scanInlineTts(text);\n  if (inlineTtsData.errors.length > 0) {\n    throw new Error(\n      formatInlineTtsError(inlineTtsData.errors[0], data.text_lineno),\n    );\n  }\n  text = inlineTtsData.normalizedText;\n  const ipa_replacements = inlineTtsData.replacements;\n  let content = generateHintMap(text, translation, pronunciation);\n\n  let selectablePhrases, characterPositions;\n  if (use_buttons)\n    [selectablePhrases, characterPositions] = getButtons(content);\n\n  let hideRanges = getHideRanges(content);\n  for (let hide of hideRanges) {\n    for (let match of ipa_replacements) {\n      if (match.index > hide.start) match.index -= 1;\n      if (match.index > hide.end) match.index -= 1;\n    }\n  }\n\n  // split number of speaker\n  let speaker;\n  let speaker_id = \"0\";\n  if (speaker_text) {\n    speaker_id = speaker_text.match(/Speaker(.*)/)?.[1] || \"0\";\n  }\n  if (meta) {\n    speaker = get_avatar(speaker_id, meta.avatar_names, meta.avatar_overwrites);\n    meta.cast[speaker_id] = {\n      speaker:\n        meta.avatar_overwrites[speaker_id]?.speaker ||\n        meta.avatar_names[speaker_id]?.speaker ||\n        meta.avatar_names[0]?.speaker ||\n        \"\",\n      link:\n        meta.avatar_overwrites[speaker_id]?.link ||\n        meta.avatar_names[speaker_id]?.link,\n      name:\n        meta.avatar_overwrites[speaker_id]?.name ||\n        meta.avatar_names[speaker_id]?.name,\n      id: speaker_id,\n    };\n  }\n  let audio;\n  if (data.allow_audio && meta) {\n    let speaker_name =\n      meta[\"speaker_\" + \"narrator\"] || meta.avatar_names[0]?.speaker;\n    if (speaker_id)\n      speaker_name =\n        meta.avatar_overwrites[speaker_id]?.speaker ||\n        meta.avatar_names[speaker_id]?.speaker ||\n        meta.avatar_names[0]?.speaker;\n\n    audio = line_to_audio(\n      data.audio ?? \"\",\n      content.text,\n      speaker_name,\n      meta.story_id,\n      hide ? hideRanges : [],\n      meta.transcribe_data,\n      ipa_replacements,\n      {\n        inser_index: meta.audio_insert_lines.length,\n        plan_text: text,\n        plan_text_speaker_name: speaker_name,\n      },\n    );\n\n    meta.audio_insert_lines.push([data.audio_line, data.audio_line_inset ?? 0]);\n    //audio.ssml.line = data.audio_line;\n    //audio.ssml.line_insert = data.audio_line_inset;\n    content.audio = audio;\n  }\n\n  let line;\n  if (speaker && speaker_id !== \"0\") {\n    line = {\n      type: \"CHARACTER\",\n      avatarUrl: speaker.avatarUrl,\n      characterId: speaker.characterId,\n      characterName: speaker?.characterName,\n      content: content,\n    } as LineElementCharacter;\n  } else {\n    line = {\n      type: \"PROSE\",\n      content: content,\n    } as LineElementProse;\n  }\n  return {\n    speaker: speaker,\n    line: line,\n    content: content,\n    hideRanges: hideRanges,\n    selectablePhrases: selectablePhrases,\n    characterPositions: characterPositions,\n    audio: audio,\n  };\n}\n\nfunction line_to_audio(\n  line: string,\n  text: string,\n  speaker: string,\n  story_id: number,\n  hideRanges: HideRange[],\n  transcribe_data: TranscribeData,\n  ipa_replacements: IpaReplacement[],\n  ssml_payload: {\n    inser_index: number;\n    plan_text?: string | undefined;\n    plan_text_speaker_name?: string | undefined;\n  },\n) {\n  //let text_speak = getInputStringSpeechText(text, hide);\n  let audio = {\n    ssml: {\n      text: text,\n      speaker: speaker,\n    },\n    url: undefined,\n    keypoints: undefined,\n  } as Audio;\n  audio.ssml = {\n    ...generate_ssml_line(\n      {\n        text: text,\n        speaker: speaker,\n      },\n      transcribe_data,\n      hideRanges,\n      ipa_replacements,\n    ),\n    id: story_id,\n    ...ssml_payload,\n  };\n  if (line) {\n    line = line.substring(1);\n    let [filename, keypoints] = text_to_keypoints(line);\n    audio.url = \"audio/\" + filename;\n    audio.keypoints = keypoints;\n  }\n  return audio;\n}\n\nfunction get_avatar(\n  id: string,\n  avatar_names: Record<string, Avatar>,\n  avatar_overwrites: Record<string, AvatarOverwrites>,\n) {\n  const id2 = parseInt(id) ?? id;\n  if (avatar_overwrites[id2])\n    return { characterId: id2, avatarUrl: avatar_overwrites[id2]?.link };\n  return {\n    characterId: id2,\n    avatarUrl: avatar_names[id2]?.link,\n    characterName: avatar_names[id2]?.name,\n  };\n}\n\ntype Speaker = {\n  text: undefined | string;\n  trans: undefined | string;\n  pron: undefined | string;\n  text_lineno?: undefined | number;\n  allow_audio?: undefined | boolean;\n  audio_line_inset?: undefined | number;\n  audio?: undefined | string;\n  audio_line?: undefined | number;\n};\n\nfunction getText(\n  line_iter: LineIterator,\n  allow_speaker: boolean,\n  allow_trans: boolean,\n  allow_audio: boolean,\n) {\n  let speaker: Speaker = {\n    text: undefined,\n    trans: undefined,\n    pron: undefined,\n    allow_audio: undefined,\n    audio_line_inset: undefined,\n    audio: undefined,\n    audio_line: undefined,\n  };\n  let line = line_iter.get();\n  if (!line) return speaker;\n  if (line.startsWith(\">\") || (allow_speaker && line.match(/\\w*:/))) {\n    speaker.text = line;\n    speaker.text_lineno = line_iter.get_lineno();\n    line = line_iter.advance(1);\n    if (allow_trans) {\n      while (line?.startsWith(\"~\") || line?.startsWith(\"^\")) {\n        if (line.startsWith(\"~\")) {\n          speaker.trans = line;\n        } else if (line.startsWith(\"^\")) {\n          speaker.pron = line;\n        }\n        line = line_iter.advance();\n      }\n    }\n    if (allow_audio) {\n      speaker.allow_audio = allow_audio;\n      speaker.audio_line_inset = line_iter.get_lineno();\n      if (line?.startsWith(\"$\")) {\n        speaker.audio = line;\n        speaker.audio_line = line_iter.get_lineno();\n        line_iter.advance();\n      }\n    }\n  }\n  return speaker;\n}\n\nfunction pushStoryErrorData(\n  story: StoryWithMeta,\n  error: {\n    message: string;\n    errorKind?: \"parse\" | \"unknown_block\" | \"invalid_line\";\n    sourceLine?: string;\n    lineNumber?: number;\n    details?: string;\n    editor?: {\n      block_start_no?: number;\n      start_no?: number;\n      end_no?: number;\n      active_no?: number;\n    };\n  },\n) {\n  story.elements.push({\n    type: \"ERROR\",\n    text: error.message,\n    errorKind: error.errorKind,\n    sourceLine: error.sourceLine,\n    lineNumber: error.lineNumber,\n    details: error.details,\n    editor: error.editor,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n      challenge_type: \"error\",\n    },\n  });\n  story.meta.line_index += 1;\n}\n\nfunction toErrorMessage(error: unknown) {\n  if (error instanceof Error && error.message) return error.message;\n  return \"Invalid story syntax.\";\n}\n\nfunction safeSpeakerTextTrans(\n  story: StoryWithMeta,\n  data: Speaker,\n  ...args: Parameters<typeof speaker_text_trans> extends [\n    Speaker,\n    ...infer Rest,\n  ]\n    ? Rest\n    : never\n) {\n  try {\n    return speaker_text_trans(data, ...args);\n  } catch (error) {\n    pushStoryErrorData(story, {\n      message: toErrorMessage(error),\n      errorKind: \"parse\",\n      sourceLine: data.text,\n      lineNumber: data.text_lineno,\n      editor: {\n        block_start_no: data.text_lineno,\n        start_no: data.text_lineno,\n        end_no: data.audio_line ?? data.audio_line_inset ?? data.text_lineno,\n        active_no: data.text_lineno,\n      },\n    });\n    return undefined;\n  }\n}\n\nfunction getAnswers(\n  line_iter: LineIterator,\n  allow_trans: boolean,\n  lang_hints?: string | undefined,\n) {\n  const answers = [];\n  let correct_answer = undefined;\n  while (line_iter.get()) {\n    let line = line_iter.get();\n    if (!line) break;\n    if (line.startsWith(\"+\") || line.startsWith(\"-\")) {\n      if (line.startsWith(\"+\")) correct_answer = answers.length;\n      const answer = {\n        text: line,\n        trans: undefined as string | undefined,\n        pron: undefined as string | undefined,\n      };\n      line = line_iter.advance();\n      if (allow_trans) {\n        while (line && (line.startsWith(\"~\") || line.startsWith(\"^\"))) {\n          if (line.startsWith(\"~\")) {\n            answer.trans = line;\n          } else if (line.startsWith(\"^\")) {\n            answer.pron = line;\n          }\n          line = line_iter.advance();\n        }\n      }\n      if (allow_trans) {\n        const data_text = speaker_text_trans(answer);\n        data_text.content.lang_hints = lang_hints;\n        answers.push(data_text.content);\n      } else answers.push(answer.text.substring(1).trim());\n      continue;\n    }\n    break;\n  }\n  return [answers, correct_answer] as const;\n}\n\nfunction pointToPhraseButtons(line: string) {\n  const match = line.match(/\\s*(?:>?\\s*(\\w*)\\s*:|>|\\+|-)\\s*(\\S.*\\S)\\s*/);\n  if (match) line = match[2] ?? line;\n  line = line.replace(/(\\s*)\\)/g, \")$1\");\n  line = line.replace(/~/g, \" \");\n  line = line.replace(/ +/g, \" \");\n  line = line.replace(/\\|/g, \"⁠\");\n  line = line.replace(/\\[\\[/g, \"[\");\n  line = line.replace(/]]/g, \"]\");\n  line = line.replace(/\\\\n/g, \"\\n\");\n  const transcriptParts = [];\n  let correctAnswerIndex = 0;\n  let index = 0;\n\n  while (line.length) {\n    let pos = line.indexOf(\"(\");\n    if (pos === -1) {\n      for (const l of splitTextTokens(line))\n        if (l !== \"\")\n          transcriptParts.push({\n            selectable: false,\n            text: l,\n          });\n      break;\n    }\n    if (line.substring(0, pos) !== \"\") {\n      for (const l of splitTextTokens(line.substring(0, pos)))\n        if (l !== \"\")\n          transcriptParts.push({\n            selectable: false,\n            text: l,\n          });\n    }\n    line = line.substring(pos + 1);\n    if (line.startsWith(\"+\")) {\n      correctAnswerIndex = index;\n      line = line.substring(1);\n    }\n    let pos2 = line.indexOf(\")\");\n    if (pos2 === -1) pos2 = line.length - 1;\n    transcriptParts.push({\n      selectable: true,\n      text: line.substring(0, pos2).trim(),\n    });\n    index += 1;\n    line = line.substring(pos2 + 1);\n  }\n\n  return [correctAnswerIndex, transcriptParts];\n}\n\nfunction processBlockHeader(\n  line_iter: LineIterator,\n  story: StoryWithMeta,\n  lang: string,\n  story_languages: StoryLanguages,\n) {\n  const start_no = line_iter.get_lineno(-1);\n  const data = getText(line_iter, false, true, true);\n  const data_text = safeSpeakerTextTrans(story, data, story.meta);\n  if (!data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n  const start_no1 = data.text_lineno;\n\n  data_text.line.content.lang_hints = story_languages.from_language;\n\n  story.elements.push({\n    type: \"HEADER\",\n    illustrationUrl:\n      \"https://stories-cdn.duolingo.com/image/\" + story.meta[\"icon\"] + \".svg\",\n    title: story.meta[\"from_language_name\"] ?? \"\",\n    learningLanguageTitleContent: data_text.content,\n    trackingProperties: { line_index: 0 },\n    audio: data_text.audio,\n    lang: lang || story_languages.learning_language,\n    editor: {\n      block_start_no: start_no,\n      start_no: start_no,\n      end_no: line_iter.get_lineno(),\n      active_no: start_no1,\n    },\n  } as StoryElementHeader);\n  return false;\n}\n\nfunction processBlockLine(\n  line_iter: LineIterator,\n  story: StoryWithMeta,\n  lang: string,\n  story_languages: StoryLanguages,\n) {\n  const start_no = line_iter.get_lineno(-1);\n  const data = getText(line_iter, true, true, true);\n  const data_text = safeSpeakerTextTrans(story, data, story.meta);\n  if (!data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n  const start_no1 = data.text_lineno;\n\n  data_text.line.content.lang_hints = story_languages.from_language;\n\n  story.elements.push({\n    type: \"LINE\",\n    hideRangesForChallenge: data_text.hideRanges,\n    line: data_text.line,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n    },\n    audio: data_text.audio,\n    lang: lang || story_languages.learning_language,\n    editor: {\n      block_start_no: start_no,\n      start_no: start_no,\n      end_no: line_iter.get_lineno(),\n      active_no: start_no1,\n    },\n  } as StoryElementLine);\n  story.meta.line_index += 1;\n  return false;\n}\n\nfunction processBlockMultipleChoice(\n  line_iter: LineIterator,\n  story: StoryWithMeta,\n  lang: string,\n  story_languages: StoryLanguages,\n) {\n  const start_no = line_iter.get_lineno(-1);\n  const data = getText(line_iter, false, true, false);\n  const data_text = safeSpeakerTextTrans(story, data, story.meta);\n  if (!data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n  const start_no1 = data.text_lineno;\n\n  const [answers, correct_answer] = getAnswers(line_iter, true);\n  story.elements.push({\n    type: \"MULTIPLE_CHOICE\",\n    answers: answers,\n    correctAnswerIndex: correct_answer ?? 0,\n    question: data_text.content,\n    trackingProperties: {\n      line_index: story.meta.line_index - 1,\n      challenge_type: \"multiple-choice\",\n    },\n    lang: lang || story_languages.from_language,\n    editor: {\n      block_start_no: start_no,\n      start_no: start_no,\n      end_no: line_iter.get_lineno(),\n      active_no: start_no1,\n    },\n  } as StoryElementMultipleChoice);\n  //story.meta.line_index += 1;\n  return false;\n}\n\nfunction processBlockSelectPhrase(\n  line_iter: LineIterator,\n  story: StoryWithMeta,\n  lang: string,\n  story_languages: StoryLanguages,\n) {\n  const start_no = line_iter.get_lineno(-1);\n  const question_data = getText(line_iter, false, true, false);\n  const question_data_text = safeSpeakerTextTrans(\n    story,\n    question_data,\n    story.meta,\n  );\n  if (!question_data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n  const start_no1 = question_data.text_lineno;\n\n  const start_no2 = line_iter.get_lineno(0);\n  const data = getText(line_iter, true, true, true);\n  const data_text = safeSpeakerTextTrans(story, data, story.meta);\n  if (!data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n\n  data_text.line.content.lang_hints = story_languages.from_language;\n\n  const start_no3 = line_iter.get_lineno(0);\n  const [answers, correct_answer] = getAnswers(line_iter, false);\n  story.elements.push({\n    type: \"CHALLENGE_PROMPT\",\n    prompt: question_data_text.content,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n      challenge_type: \"select-phrases\",\n    },\n    lang: lang || story_languages.from_language,\n    editor: {\n      block_start_no: start_no,\n      start_no: start_no,\n      end_no: start_no2,\n      active_no: start_no1,\n    },\n  } as StoryElementChallengePrompt);\n  story.elements.push({\n    type: \"LINE\",\n    hideRangesForChallenge: data_text.hideRanges,\n    line: data_text.line,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n    },\n    audio: data_text.audio,\n    lang: story_languages.learning_language,\n    editor: { start_no: start_no2, end_no: start_no3 },\n  } as StoryElementLine);\n  story.elements.push({\n    type: \"SELECT_PHRASE\",\n    answers: answers,\n    correctAnswerIndex: correct_answer ?? 0,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n      challenge_type: \"select-phrases\",\n    },\n    lang: story_languages.learning_language,\n    editor: { start_no: start_no3, end_no: line_iter.get_lineno() },\n  } as StoryElementSelectPhrase);\n  story.meta.line_index += 1;\n}\n\nfunction processBlockContinuation(\n  line_iter: LineIterator,\n  story: StoryWithMeta,\n  lang: string,\n  story_languages: StoryLanguages,\n) {\n  const start_no = line_iter.get_lineno(-1);\n  const question_data = getText(line_iter, false, true, false);\n  const question_data_text = safeSpeakerTextTrans(\n    story,\n    question_data,\n    story.meta,\n  );\n  if (!question_data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n  const start_no1 = question_data.text_lineno;\n\n  const start_no2 = line_iter.get_lineno();\n  const data = getText(line_iter, true, true, true);\n  const data_text = safeSpeakerTextTrans(story, data, story.meta, false, true);\n  if (!data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n\n  data_text.line.content.lang_hints = story_languages.from_language;\n\n  const start_no3 = line_iter.get_lineno();\n  const [answers, correct_answer] = getAnswers(\n    line_iter,\n    true,\n    story_languages.from_language,\n  );\n  story.elements.push({\n    type: \"CHALLENGE_PROMPT\",\n    prompt: question_data_text.content,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n      challenge_type: \"continuation\",\n    },\n    lang: lang || story_languages.from_language,\n    editor: {\n      block_start_no: start_no,\n      start_no: start_no,\n      end_no: start_no2,\n      active_no: start_no1,\n    },\n  } as StoryElementChallengePrompt);\n  story.elements.push({\n    type: \"LINE\",\n    hideRangesForChallenge: data_text.hideRanges,\n    line: data_text.line,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n    },\n    audio: data_text.audio,\n    lang: story_languages.learning_language,\n    editor: { start_no: start_no2, end_no: start_no3 },\n  } as StoryElementLine);\n  story.elements.push({\n    type: \"MULTIPLE_CHOICE\",\n    answers: answers,\n    correctAnswerIndex: correct_answer ?? 0,\n    //question: data_text.content,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n      challenge_type: \"continuation\",\n    },\n    lang: story_languages.learning_language,\n    editor: { start_no: start_no3, end_no: line_iter.get_lineno() },\n  } as StoryElementMultipleChoice);\n  story.meta.line_index += 1;\n}\n\nfunction processBlockArrange(\n  line_iter: LineIterator,\n  story: StoryWithMeta,\n  lang: string,\n  story_languages: StoryLanguages,\n) {\n  const start_no = line_iter.get_lineno(-1);\n  const question_data = getText(line_iter, false, true, false);\n  const question_data_text = safeSpeakerTextTrans(\n    story,\n    question_data,\n    story.meta,\n  );\n  if (!question_data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n  const start_no1 = question_data.text_lineno;\n\n  const start_no2 = line_iter.get_lineno();\n  const data = getText(line_iter, true, true, true);\n  const data_text = safeSpeakerTextTrans(story, data, story.meta, true);\n  if (!data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n\n  data_text.line.content.lang_hints = story_languages.from_language;\n\n  const [phraseOrder, selectablePhrases2] = shuffleArray(\n    data_text.selectablePhrases ?? [],\n  );\n  story.elements.push({\n    type: \"CHALLENGE_PROMPT\",\n    prompt: question_data_text.content,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n      challenge_type: \"arrange\",\n    },\n    lang: lang || story_languages.from_language,\n    editor: {\n      block_start_no: start_no,\n      start_no: start_no,\n      end_no: start_no2,\n      active_no: start_no1,\n    },\n  } as StoryElementChallengePrompt);\n  story.elements.push({\n    type: \"LINE\",\n    hideRangesForChallenge: data_text.hideRanges,\n    line: data_text.line,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n    },\n    audio: data_text.audio,\n    lang: story_languages.learning_language,\n    editor: { start_no: start_no2, end_no: line_iter.get_lineno() },\n  } as StoryElementLine);\n  story.elements.push({\n    type: \"ARRANGE\",\n    characterPositions: data_text.characterPositions,\n    phraseOrder: phraseOrder,\n    selectablePhrases: selectablePhrases2,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n      challenge_type: \"arrange\",\n    },\n    lang: story_languages.learning_language,\n    editor: { start_no: start_no2, end_no: line_iter.get_lineno() },\n  } as StoryElementArrange);\n  story.meta.line_index += 1;\n}\n\nfunction processBlockPointToPhrase(\n  line_iter: LineIterator,\n  story: StoryWithMeta,\n  lang: string,\n  story_languages: StoryLanguages,\n) {\n  const start_no = line_iter.get_lineno(-1);\n  const question_data = getText(line_iter, false, true, false);\n  const question_data_text = safeSpeakerTextTrans(\n    story,\n    question_data,\n    story.meta,\n  );\n  if (!question_data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n  const start_no1 = question_data.text_lineno;\n\n  const start_no2 = line_iter.get_lineno();\n  const data = getText(line_iter, true, true, true);\n  const data_text = safeSpeakerTextTrans(story, data, story.meta, true);\n  if (!data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n\n  data_text.line.content.lang_hints = story_languages.from_language;\n\n  const [correctAnswerIndex, transcriptParts] = pointToPhraseButtons(\n    data.text ?? \"\",\n  );\n\n  story.elements.push({\n    type: \"LINE\",\n    hideRangesForChallenge: data_text.hideRanges,\n    line: data_text.line,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n    },\n    audio: data_text.audio,\n    lang: story_languages.learning_language,\n    editor: {\n      block_start_no: start_no,\n      start_no: start_no,\n      end_no: start_no2,\n      active_no: start_no1,\n    },\n  } as StoryElementLine);\n  story.elements.push({\n    type: \"POINT_TO_PHRASE\",\n    correctAnswerIndex: correctAnswerIndex,\n    transcriptParts: transcriptParts,\n    question: question_data_text.content,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n      challenge_type: \"point-to-phrase\",\n    },\n    lang_question: lang || story_languages.from_language,\n    lang: story_languages.learning_language,\n    editor: { start_no: start_no2, end_no: line_iter.get_lineno() },\n  } as StoryElementPointToPhrase);\n  story.meta.line_index += 1;\n}\n\nfunction processBlockMatch(\n  line_iter: LineIterator,\n  story: StoryWithMeta,\n  lang: string,\n  story_languages: StoryLanguages,\n) {\n  const start_no = line_iter.get_lineno(-1);\n  const question_data = getText(line_iter, false, true, false);\n  const question_data_text = safeSpeakerTextTrans(\n    story,\n    question_data,\n    story.meta,\n  );\n  if (!question_data_text) {\n    skipToNextBlock(line_iter);\n    return false;\n  }\n  const start_no1 = question_data.text_lineno;\n\n  const answers = [];\n  while (line_iter.get()) {\n    const line = line_iter.get();\n    if (!line) break;\n    const match = line.match(/-\\s*(.*\\S)\\s*<>\\s*(.*\\S)\\s*/);\n    if (match) {\n      const [, word1, word2] = match;\n      answers.push({ phrase: word1, translation: word2 });\n      line_iter.advance();\n      continue;\n    }\n    break;\n  }\n\n  story.elements.push({\n    type: \"MATCH\",\n    fallbackHints: answers,\n    prompt: question_data_text.content.text,\n    trackingProperties: {\n      line_index: story.meta.line_index,\n      challenge_type: \"match\",\n    },\n    lang: story_languages.learning_language,\n    lang_question: lang || story_languages.from_language,\n    editor: {\n      block_start_no: start_no,\n      start_no: start_no,\n      end_no: line_iter.get_lineno(),\n      active_no: start_no1,\n    },\n  } as StoryElementMatch);\n  story.meta.line_index += 1;\n}\n\nconst block_functions: Record<\n  string,\n  (\n    line_iter: LineIterator,\n    story: StoryWithMeta,\n    lang: string,\n    story_languages: StoryLanguages,\n  ) => void\n> = {\n  DATA: processBlockData,\n  HEADER: processBlockHeader,\n  LINE: processBlockLine,\n  MULTIPLE_CHOICE: processBlockMultipleChoice,\n  SELECT_PHRASE: processBlockSelectPhrase,\n  CONTINUATION: processBlockContinuation,\n  ARRANGE: processBlockArrange,\n  POINT_TO_PHRASE: processBlockPointToPhrase,\n  MATCH: processBlockMatch,\n};\n\nfunction line_iterator(lines: LineTuple[]) {\n  let index = 0;\n  function get(offset = 0) {\n    if (lines[index + offset]) return lines[index + offset][1];\n  }\n  function get_lineno(offset = 0) {\n    if (lines[index + offset]) return lines[index + offset][0];\n  }\n  function advance(offset = 1) {\n    index += offset;\n    return get();\n  }\n  return { get: get, get_lineno: get_lineno, advance: advance };\n}\ntype LineIterator = ReturnType<typeof line_iterator>;\n\ntype StoryWithMeta = StoryType & {\n  meta: Meta;\n};\nexport type StoryType = {\n  elements: StoryElement[];\n  from_language_name?: string | undefined;\n};\n\ntype Meta = {\n  audio_insert_lines: [number | undefined, number][];\n  line_index: number;\n  story_id: number;\n  avatar_names: Record<string, Avatar>;\n  avatar_overwrites: Record<string, AvatarOverwrites>;\n  cast: Record<\n    string,\n    {\n      id: string;\n      link: string;\n      speaker: string;\n      name: string;\n    }\n  >;\n  transcribe_data: TranscribeData;\n  todo_count: number;\n  from_language_name?: string | undefined;\n  fromLanguageName: string;\n  set?: string | undefined;\n  set_id: number;\n  set_index: number;\n  icon?: string | undefined;\n  [key: string]: any;\n};\n\ntype AvatarOverwrites = {\n  id: string;\n  link: string;\n  speaker: string;\n  name: string;\n};\n\ntype StoryLanguages = {\n  learning_language: string;\n  from_language: string;\n};\n\nexport type TranscribeData = string;\n\ntype LineTuple = [number, string];\n\nfunction isBlockHeaderLine(line: string | undefined) {\n  return Boolean(line?.match(/\\[([^\\]]*)\\](<(.+)>)?$/));\n}\n\nfunction consumeUnknownBlock(\n  line_iter: LineIterator,\n  headerLineNumber: number | undefined,\n) {\n  let endLineNumber = headerLineNumber;\n  const bodyLines: string[] = [];\n\n  while (line_iter.get()) {\n    const line = line_iter.get();\n    if (!line || isBlockHeaderLine(line)) break;\n    endLineNumber = line_iter.get_lineno();\n    bodyLines.push(line);\n    line_iter.advance();\n  }\n\n  return { endLineNumber, bodyLines };\n}\n\nfunction skipToNextBlock(line_iter: LineIterator) {\n  let endLineNumber = line_iter.get_lineno(-1);\n\n  while (line_iter.get()) {\n    const line = line_iter.get();\n    if (!line || isBlockHeaderLine(line)) break;\n    endLineNumber = line_iter.get_lineno();\n    line_iter.advance();\n  }\n\n  return endLineNumber;\n}\n\n//window.audio_insert_lines = []\nexport function processStoryFile(\n  text: string,\n  story_id: number,\n  avatar_names: Record<number, Avatar>,\n  story_languages: StoryLanguages,\n  transcribe_data: TranscribeData,\n) {\n  // reset those line as they may have changed\n  //window.audio_insert_lines = []\n\n  const { lines, todo_count } = split_lines(text);\n\n  const story: StoryWithMeta = {\n    elements: [],\n    meta: {\n      audio_insert_lines: [],\n      line_index: 1,\n      story_id: story_id,\n      avatar_names: avatar_names,\n      avatar_overwrites: {},\n      fromLanguageName: \"\",\n      cast: {},\n      set_id: 0,\n      set_index: 0,\n      transcribe_data: transcribe_data,\n      todo_count: 0,\n      icon: \"\",\n    },\n  };\n  const line_iter = line_iterator(lines);\n  while (line_iter.get()) {\n    const line = line_iter.get();\n    const currentLineNumber = line_iter.get_lineno();\n    if (!line) break;\n    const match = line.match(/\\[([^\\]]*)\\](<(.+)>)?$/);\n    if (match !== null) {\n      line_iter.advance();\n      const current_block: string = match[1];\n      try {\n        if (block_functions[current_block]) {\n          block_functions[current_block](\n            line_iter,\n            story,\n            match[3],\n            story_languages,\n          );\n          continue;\n        }\n        const { endLineNumber, bodyLines } = consumeUnknownBlock(\n          line_iter,\n          currentLineNumber,\n        );\n        pushStoryErrorData(story, {\n          message: `Unknown block type \"${current_block}\"`,\n          errorKind: \"unknown_block\",\n          sourceLine: line,\n          lineNumber: currentLineNumber,\n          details:\n            bodyLines.length > 0\n              ? `Ignored ${bodyLines.length} line${bodyLines.length === 1 ? \"\" : \"s\"} until the next block.`\n              : undefined,\n          editor: {\n            block_start_no: currentLineNumber,\n            start_no: currentLineNumber,\n            end_no: endLineNumber,\n            active_no: currentLineNumber,\n          },\n        });\n        continue;\n      } catch (e) {\n        console.error(e);\n        pushStoryErrorData(story, {\n          message: toErrorMessage(e),\n          errorKind: \"parse\",\n          sourceLine: line,\n          lineNumber: currentLineNumber,\n          editor: {\n            block_start_no: currentLineNumber,\n            start_no: currentLineNumber,\n            end_no: line_iter.get_lineno(-1) ?? currentLineNumber,\n            active_no: currentLineNumber,\n          },\n        });\n        continue;\n      }\n    }\n    pushStoryErrorData(story, {\n      message: \"Unexpected line outside a recognized block\",\n      errorKind: \"invalid_line\",\n      sourceLine: line,\n      lineNumber: currentLineNumber,\n      editor: {\n        block_start_no: currentLineNumber,\n        start_no: currentLineNumber,\n        end_no: currentLineNumber,\n        active_no: currentLineNumber,\n      },\n    });\n    //console.log(\"error\", lineno, line)\n    line_iter.advance();\n  }\n  story.meta.todo_count = todo_count;\n  const meta = story.meta;\n  //delete story.meta;\n  const audio_insert_lines = meta.audio_insert_lines;\n  //delete meta.audio_insert_lines;\n\n  //console.log(story);\n  //console.log(meta);\n\n  const { meta: _removedMeta, ...storyWithoutMeta } = story;\n  const {\n    audio_insert_lines: _removedAudioInsertLines,\n    ...metaWithoutAudioInsertLines\n  } = meta;\n\n  return [\n    storyWithoutMeta,\n    metaWithoutAudioInsertLines,\n    audio_insert_lines,\n  ] as const;\n}\n"
  },
  {
    "path": "src/components/editor/story/syntax_parser_types.ts",
    "content": "export type Audio = {\n  ssml: {\n    text: string;\n    speaker: string;\n    id: number;\n    inser_index: number;\n    plan_text?: string | undefined;\n    plan_text_speaker_name?: string | undefined;\n    mapping?: Record<number, number>;\n  };\n  url: undefined | string;\n  keypoints: undefined | { rangeEnd: number; audioStart: number }[];\n};\n\n// Core Types\ninterface HintMapItem {\n  hintIndex: number;\n  rangeFrom: number;\n  rangeTo: number;\n}\n\nexport interface HintMapResult {\n  hintMap: HintMapItem[];\n  hints: string[];\n  hints_pronunciation?: string[];\n  text: string;\n  audio?: Audio;\n  lang_hints?: string;\n  lang_hints_pronunciation?: string;\n}\n\nexport interface ContentWithHints {\n  hintMap: HintMapItem[];\n  text: string;\n  [key: string]: any; // For additional properties\n}\n\nexport interface HideRange {\n  start: number;\n  end: number;\n}\n\nexport type LineElementCharacter = {\n  type: \"CHARACTER\";\n  avatarUrl?: string;\n  characterId: number | string;\n  characterName?: string;\n  content: ContentWithHints;\n};\nexport type LineElementProse = {\n  type: \"PROSE\";\n  content: ContentWithHints;\n};\ntype LineElementTitle = {\n  type: \"TITLE\";\n  content: ContentWithHints;\n};\nexport type LineElement =\n  | LineElementCharacter\n  | LineElementProse\n  | LineElementTitle;\n\nexport type StoryElementHeader = {\n  type: \"HEADER\";\n  illustrationUrl: string;\n  title: string;\n  learningLanguageTitleContent: ContentWithHints;\n  trackingProperties: { line_index: 0 };\n  audio?: Audio;\n  lang: string;\n  editor: {\n    block_start_no?: number;\n    start_no?: number;\n    end_no?: number;\n    active_no?: number;\n  };\n};\n\nexport type StoryElementLine = {\n  type: \"LINE\";\n  hideRangesForChallenge?: HideRange[];\n  line: LineElement;\n  trackingProperties: { line_index: number; [key: string]: any };\n  audio?: Audio;\n  lang: string;\n  editor: {\n    block_start_no?: number;\n    start_no?: number;\n    end_no?: number;\n    active_no?: number;\n  };\n};\n\nexport type StoryElementMultipleChoice = {\n  type: \"MULTIPLE_CHOICE\";\n  answers: (string | HintMapResult)[];\n  correctAnswerIndex: number;\n  question?: ContentWithHints;\n  trackingProperties: {\n    line_index: number;\n    challenge_type: \"multiple-choice\" | \"continuation\";\n  };\n  lang: string;\n  editor: {\n    block_start_no?: number;\n    start_no?: number;\n    end_no?: number;\n    active_no?: number;\n  };\n};\n\nexport type StoryElementChallengePrompt = {\n  type: \"CHALLENGE_PROMPT\";\n  prompt: ContentWithHints;\n  trackingProperties: {\n    line_index: number;\n    challenge_type: \"select-phrases\" | \"continuation\" | \"arrange\";\n  };\n  lang: string;\n  editor: {\n    block_start_no?: number;\n    start_no?: number;\n    end_no?: number;\n    active_no?: number;\n  };\n};\n\nexport type StoryElementSelectPhrase = {\n  type: \"SELECT_PHRASE\";\n  answers: (string | HintMapResult)[];\n  correctAnswerIndex: number;\n  trackingProperties: {\n    line_index: number;\n    challenge_type: \"select-phrases\";\n  };\n  lang: string;\n  editor: { start_no?: number; end_no?: number; block_start_no?: number };\n};\n\nexport type StoryElementArrange = {\n  type: \"ARRANGE\";\n  characterPositions?: number[];\n  phraseOrder: number[];\n  selectablePhrases: string[];\n  trackingProperties: {\n    line_index: number;\n    challenge_type: \"arrange\";\n  };\n  lang: string;\n  editor: { start_no?: number; end_no?: number; block_start_no?: number };\n};\n\nexport type StoryElementPointToPhrase = {\n  type: \"POINT_TO_PHRASE\";\n  correctAnswerIndex: number;\n  transcriptParts: { selectable: boolean; text: string }[];\n  question: ContentWithHints;\n  trackingProperties: {\n    line_index: number;\n    challenge_type: \"point-to-phrase\";\n  };\n  lang_question: string;\n  lang: string;\n  editor: {\n    block_start_no?: number;\n    start_no?: number;\n    end_no?: number;\n    active_no?: number;\n  };\n};\n\nexport type StoryElementMatch = {\n  type: \"MATCH\";\n  fallbackHints: { phrase: string; translation: string }[];\n  prompt: string;\n  trackingProperties: {\n    line_index: number;\n    challenge_type: \"match\";\n  };\n  lang: string;\n  lang_question: string;\n  editor: {\n    block_start_no?: number;\n    start_no?: number;\n    end_no?: number;\n    active_no?: number;\n  };\n};\n\nexport type StoryElementError = {\n  type: \"ERROR\";\n  text: string;\n  errorKind?: \"parse\" | \"unknown_block\" | \"invalid_line\";\n  sourceLine?: string;\n  lineNumber?: number;\n  details?: string;\n  editor?: {\n    block_start_no?: number;\n    start_no?: number;\n    end_no?: number;\n    active_no?: number;\n  };\n  trackingProperties: {\n    line_index: number;\n    challenge_type: \"error\";\n  };\n};\n\nexport type StoryElement =\n  | StoryElementHeader\n  | StoryElementLine\n  | StoryElementMultipleChoice\n  | StoryElementChallengePrompt\n  | StoryElementSelectPhrase\n  | StoryElementArrange\n  | StoryElementPointToPhrase\n  | StoryElementMatch\n  | StoryElementError;\n"
  },
  {
    "path": "src/components/icons.tsx",
    "content": "export function IconDiscord() {\n  return (\n    <svg\n      viewBox=\"0 0 512 512\"\n      width=\"32\"\n      height=\"32\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      role=\"img\"\n    >\n      <title>Discord</title>\n      <circle style={{ fill: \"var(--text-color)\" }} cx=\"256\" cy=\"256\" r=\"256\" />\n      <path\n        style={{ fill: \"var(--body-background)\" }}\n        d=\"m 332.58986,371.6245 c 0,0 -9.88907,-11.90726 -18.1636,-22.40178 20.04479,-4.71103 37.77531,-16.36454 50.05082,-32.8963 -31.79079,21.10431 -113.8314,55.96232 -207.26689,5.65089 -1.85067,0 -7.12106,-3.60353 -8.87999,-4.84362 11.84471,16.2069 28.97571,27.77033 48.43628,32.69448 l -18.36542,23.00723 c -33.39839,0.86883 -65.06171,-14.84955 -84.561666,-41.9781 1.024024,-56.18432 14.827516,-111.39792 40.363566,-161.45425 22.26207,-17.5059 49.4139,-27.66139 77.69985,-29.06176 l 2.82545,3.4309 c -26.64043,6.55419 -51.51335,18.8873 -72.85623,36.12539 55.22363,-31.08007 145.81,-41.42149 223.2105,1.00909 -20.26995,-16.44715 -43.79325,-28.41515 -69.02169,-35.1163 l 3.83454,-4.44 c 28.34495,1.41172 55.5578,11.56376 77.90167,29.06177 25.48959,50.07372 39.29028,105.27644 40.36357,161.45425 -20.18935,26.59347 -52.22616,41.47848 -85.57076,39.75811 z\"\n      />\n      <circle\n        style={{ fill: \"var(--text-color)\" }}\n        cx=\"308.31046\"\n        cy=\"273.13742\"\n        r=\"30.676001\"\n      />\n      <circle\n        style={{ fill: \"var(--text-color)\" }}\n        cx=\"203.6895\"\n        cy=\"273.13742\"\n        r=\"30.676001\"\n      />\n    </svg>\n  );\n}\n\nexport function IconGithub() {\n  return (\n    <svg\n      height=\"32px\"\n      viewBox=\"0 0 32 32\"\n      width=\"32px\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      role=\"img\"\n    >\n      <title>GitHub</title>\n      <path\n        style={{ fill: \"var(--text-color)\" }}\n        d=\"M16.003,0C7.17,0,0.008,7.162,0.008,15.997  c0,7.067,4.582,13.063,10.94,15.179c0.8,0.146,1.052-0.328,1.052-0.752c0-0.38,0.008-1.442,0-2.777  c-4.449,0.967-5.371-2.107-5.371-2.107c-0.727-1.848-1.775-2.34-1.775-2.34c-1.452-0.992,0.109-0.973,0.109-0.973  c1.605,0.113,2.451,1.649,2.451,1.649c1.427,2.443,3.743,1.737,4.654,1.329c0.146-1.034,0.56-1.739,1.017-2.139  c-3.552-0.404-7.286-1.776-7.286-7.906c0-1.747,0.623-3.174,1.646-4.292C7.28,10.464,6.73,8.837,7.602,6.634  c0,0,1.343-0.43,4.398,1.641c1.276-0.355,2.645-0.532,4.005-0.538c1.359,0.006,2.727,0.183,4.005,0.538  c3.055-2.07,4.396-1.641,4.396-1.641c0.872,2.203,0.323,3.83,0.159,4.234c1.023,1.118,1.644,2.545,1.644,4.292  c0,6.146-3.74,7.498-7.304,7.893C19.479,23.548,20,24.508,20,26c0,2,0,3.902,0,4.428c0,0.428,0.258,0.901,1.07,0.746  C27.422,29.055,32,23.062,32,15.997C32,7.162,24.838,0,16.003,0z\"\n      />\n    </svg>\n  );\n}\n\nexport function IconOpenCollective() {\n  return (\n    <svg\n      height=\"32\"\n      viewBox=\"0 0 24 24\"\n      width=\"32\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      role=\"img\"\n    >\n      <title>Open Collective</title>\n      <path\n        style={{ fill: \"var(--text-color)\" }}\n        d=\"M16.6818 15.7529L18.8116 17.8827C20.1752 16.3052 21 14.249 21 12.0001C21 9.78747 20.2016 7.76133 18.8771 6.19409L16.7444 8.32671C17.5315 9.34177 18 10.6162 18 12.0001C18 13.4203 17.5066 14.7253 16.6818 15.7529Z\"\n        fillOpacity=\"0.5\"\n      />\n      <path\n        style={{ fill: \"var(--text-color)\" }}\n        d=\"M15.6734 16.7445C14.6583 17.5315 13.3839 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6C13.4202 6 14.7252 6.49344 15.7528 7.31823L17.8826 5.18843C16.3051 3.82482 14.2489 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21C14.2126 21 16.2387 20.2016 17.806 18.8771L15.6734 16.7445Z\"\n      />\n    </svg>\n  );\n}\n\nexport function IconInstagram() {\n  return (\n    <svg\n      height=\"32\"\n      width=\"32\"\n      viewBox=\"0 0 512 512\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      role=\"img\"\n    >\n      <title>Instagram</title>\n      <path\n        style={{ fill: \"var(--text-color)\" }}\n        d=\"M256,0c141.29,0 256,114.71 256,256c0,141.29 -114.71,256 -256,256c-141.29,0 -256,-114.71 -256,-256c0,-141.29 114.71,-256 256,-256Zm0,96c-43.453,0 -48.902,0.184 -65.968,0.963c-17.03,0.777 -28.661,3.482 -38.839,7.437c-10.521,4.089 -19.444,9.56 -28.339,18.455c-8.895,8.895 -14.366,17.818 -18.455,28.339c-3.955,10.177 -6.659,21.808 -7.437,38.838c-0.778,17.066 -0.962,22.515 -0.962,65.968c0,43.453 0.184,48.902 0.962,65.968c0.778,17.03 3.482,28.661 7.437,38.838c4.089,10.521 9.56,19.444 18.455,28.34c8.895,8.895 17.818,14.366 28.339,18.455c10.178,3.954 21.809,6.659 38.839,7.436c17.066,0.779 22.515,0.963 65.968,0.963c43.453,0 48.902,-0.184 65.968,-0.963c17.03,-0.777 28.661,-3.482 38.838,-7.436c10.521,-4.089 19.444,-9.56 28.34,-18.455c8.895,-8.896 14.366,-17.819 18.455,-28.34c3.954,-10.177 6.659,-21.808 7.436,-38.838c0.779,-17.066 0.963,-22.515 0.963,-65.968c0,-43.453 -0.184,-48.902 -0.963,-65.968c-0.777,-17.03 -3.482,-28.661 -7.436,-38.838c-4.089,-10.521 -9.56,-19.444 -18.455,-28.339c-8.896,-8.895 -17.819,-14.366 -28.34,-18.455c-10.177,-3.955 -21.808,-6.66 -38.838,-7.437c-17.066,-0.779 -22.515,-0.963 -65.968,-0.963Zm0,28.829c42.722,0 47.782,0.163 64.654,0.933c15.6,0.712 24.071,3.318 29.709,5.509c7.469,2.902 12.799,6.37 18.397,11.969c5.6,5.598 9.067,10.929 11.969,18.397c2.191,5.638 4.798,14.109 5.509,29.709c0.77,16.872 0.933,21.932 0.933,64.654c0,42.722 -0.163,47.782 -0.933,64.654c-0.711,15.6 -3.318,24.071 -5.509,29.709c-2.902,7.469 -6.369,12.799 -11.969,18.397c-5.598,5.6 -10.928,9.067 -18.397,11.969c-5.638,2.191 -14.109,4.798 -29.709,5.509c-16.869,0.77 -21.929,0.933 -64.654,0.933c-42.725,0 -47.784,-0.163 -64.654,-0.933c-15.6,-0.711 -24.071,-3.318 -29.709,-5.509c-7.469,-2.902 -12.799,-6.369 -18.398,-11.969c-5.599,-5.598 -9.066,-10.928 -11.968,-18.397c-2.191,-5.638 -4.798,-14.109 -5.51,-29.709c-0.77,-16.872 -0.932,-21.932 -0.932,-64.654c0,-42.722 0.162,-47.782 0.932,-64.654c0.712,-15.6 3.319,-24.071 5.51,-29.709c2.902,-7.468 6.369,-12.799 11.968,-18.397c5.599,-5.599 10.929,-9.067 18.398,-11.969c5.638,-2.191 14.109,-4.797 29.709,-5.509c16.872,-0.77 21.932,-0.933 64.654,-0.933Zm0,49.009c-45.377,0 -82.162,36.785 -82.162,82.162c0,45.377 36.785,82.162 82.162,82.162c45.377,0 82.162,-36.785 82.162,-82.162c0,-45.377 -36.785,-82.162 -82.162,-82.162Zm0,135.495c-29.455,0 -53.333,-23.878 -53.333,-53.333c0,-29.455 23.878,-53.333 53.333,-53.333c29.455,0 53.333,23.878 53.333,53.333c0,29.455 -23.878,53.333 -53.333,53.333Zm104.609,-138.741c0,10.604 -8.597,19.199 -19.201,19.199c-10.603,0 -19.199,-8.595 -19.199,-19.199c0,-10.604 8.596,-19.2 19.199,-19.2c10.604,0 19.201,8.596 19.201,19.2Z\"\n      />\n    </svg>\n  );\n}\n\nexport function IconTwitter() {\n  return (\n    <svg\n      width=\"32\"\n      height=\"32\"\n      viewBox=\"0 0 512 512\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      role=\"img\"\n    >\n      <title>Twitter</title>\n      <path\n        style={{ fill: \"var(--text-color)\" }}\n        d=\"M256,0c141.29,0 256,114.71 256,256c0,141.29 -114.71,256 -256,256c-141.29,0 -256,-114.71 -256,-256c0,-141.29 114.71,-256 256,-256Zm-45.091,392.158c113.283,0 175.224,-93.87 175.224,-175.223c0,-2.682 0,-5.364 -0.128,-7.919c12.005,-8.684 22.478,-19.54 30.779,-31.928c-10.983,4.853 -22.861,8.174 -35.377,9.706c12.772,-7.663 22.478,-19.668 27.076,-34.099c-11.878,7.024 -25.032,12.132 -39.081,14.942c-11.239,-12.005 -27.203,-19.412 -44.955,-19.412c-33.972,0 -61.558,27.586 -61.558,61.558c0,4.853 0.511,9.578 1.66,14.048c-51.213,-2.554 -96.552,-27.075 -126.947,-64.368c-5.237,9.068 -8.302,19.668 -8.302,30.907c0,21.328 10.856,40.23 27.459,51.213c-10.09,-0.255 -19.541,-3.065 -27.842,-7.662l0,0.766c0,29.885 21.2,54.661 49.425,60.409c-5.108,1.404 -10.6,2.171 -16.219,2.171c-3.96,0 -7.791,-0.383 -11.622,-1.15c7.79,24.521 30.523,42.274 57.471,42.784c-21.073,16.476 -47.637,26.31 -76.501,26.31c-4.981,0 -9.834,-0.256 -14.687,-0.894c26.948,17.624 59.387,27.841 94.125,27.841Z\"\n      />\n    </svg>\n  );\n}\n\nfunction IconFacebook() {\n  return (\n    <svg\n      height=\"32px\"\n      viewBox=\"0 0 32 32\"\n      width=\"32px\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      role=\"img\"\n    >\n      <title>Facebook</title>\n      <path\n        d=\"M 17.93074,31.738365 V 16.137686 h 4.398123 l 0.582986,-5.490713 H 17.93074 l 0.0075,-2.7483403 c 0,-1.4321013 0.136049,-2.1990929 2.193058,-2.1990929 h 2.749819 V 0.20790378 h -4.399109 c -5.284113,0 -7.143695,2.66344882 -7.143695,7.14320242 V 10.647407 H 8.044244 v 5.4912 h 3.29396 v 15.378564 c 1.283246,0.255667 2.609891,0.390733 3.968242,0.390733 a 20.382325,20.382323 0 0 0 2.624294,-0.169539 z\"\n        fill=\"#006aff\"\n        strokeWidth=\"0.00615613\"\n        id=\"path1-5\"\n      />\n    </svg>\n  );\n}\n\nfunction IconGoogle() {\n  return (\n    <svg\n      height=\"32px\"\n      viewBox=\"0 0 32 32\"\n      width=\"32px\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      role=\"img\"\n    >\n      <title>Google</title>\n      <path\n        fill=\"#ea4335\"\n        d=\"M 7.7358373,13.489515 C 8.9286817,9.8749944 12.325458,7.2790698 16.348837,7.2790698 c 2.162791,0 4.11628,0.7674419 5.651163,2.0232557 L 26.465117,4.8372093 C 23.744186,2.4651163 20.255814,1 16.348837,1 10.298926,1 5.090145,4.4513112 2.586034,9.5058452 Z\"\n      />\n      <path\n        fill=\"#34a853\"\n        d=\"m 21.517209,24.039358 c -1.393943,0.900097 -3.165234,1.379247 -5.168372,1.379247 -4.007983,0 -7.3941494,-2.576095 -8.5992092,-6.168995 L 2.582801,23.172659 c 2.5010008,5.063378 7.709645,8.525015 13.766036,8.525015 3.751302,0 7.335908,-1.33357 10.020478,-3.837746 z\"\n      />\n      <path\n        fill=\"#4a90e2\"\n        d=\"M 26.369315,27.859928 C 29.176842,25.241057 31,21.341895 31,16.348837 31,15.44186 30.860465,14.465117 30.651163,13.55814 H 16.348837 v 5.930232 h 8.232558 c -0.40622,1.994173 -1.496593,3.53876 -3.064186,4.550986 z\"\n      />\n      <path\n        fill=\"#fbbc05\"\n        d=\"m 7.7496278,19.24961 c -0.3052569,-0.910128 -0.4705581,-1.8855 -0.4705581,-2.900773 0,-0.999876 0.1603258,-1.961054 0.4567676,-2.859322 L 2.586034,9.5058452 C 1.5584255,11.565668 1,13.88712 1,16.348837 c 0,2.455237 0.5689056,4.771149 1.582801,6.823822 z\"\n      />\n    </svg>\n  );\n}\n\nexport function IconPlayStore() {\n  return (\n    <svg\n      viewBox=\"0 0 512 512\"\n      width=\"32\"\n      height=\"32\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      role=\"img\"\n    >\n      <title id=\"title1\">Google Play Store</title>\n      <path\n        d=\"m 82.315389,37.18166 a 35.958967,35.958967 0 0 1 23.725501,5.189954 L 347.00304,179.34933 286.02108,241.07271 Z M 63.779839,55.71721 A 42.817121,42.817121 0 0 0 62.11164,68.136028 V 451.08049 a 42.817121,42.817121 0 0 0 1.668199,12.41882 L 267.67089,259.60826 Z M 286.20644,278.14381 82.315389,482.03486 a 34.846834,34.846834 0 0 0 23.725501,-5.18995 L 347.00304,339.86719 Z m 157.92289,-43.37319 -73.03007,-41.51963 -66.72798,66.35727 66.54262,66.54262 73.03007,-41.51963 c 24.09622,-13.7163 24.09622,-36.32968 0,-50.04598 z\"\n        style={{ fill: \"var(--text-color)\" }}\n      />\n    </svg>\n  );\n}\n\nexport function GetIcon({ name }: { name: string }) {\n  if (name === \"twitter\") {\n    return <IconTwitter />;\n  }\n  if (name === \"github\") {\n    return <IconGithub />;\n  }\n  if (name === \"discord\") {\n    return <IconDiscord />;\n  }\n  if (name === \"instagram\") {\n    return <IconInstagram />;\n  }\n  if (name === \"opencollective\") {\n    return <IconOpenCollective />;\n  }\n  if (name === \"google\") {\n    return <IconGoogle />;\n  }\n  if (name === \"facebook\") {\n    return <IconFacebook />;\n  }\n  if (name === \"playstore\") {\n    return <IconPlayStore />;\n  }\n  return <span>Unknown</span>;\n}\n"
  },
  {
    "path": "src/components/layout/legal.tsx",
    "content": "import React from \"react\";\nimport Link from \"next/link\";\n\nexport default function Legal({ language_name }: { language_name?: string }) {\n  return (\n    <small className=\"mt-5 block px-[10px] pb-[10px] text-center text-[0.8em] text-[#666] [direction:ltr] [&_a]:text-[#666]\">\n      These stories are owned by Duolingo, Inc. and are used under license from\n      Duolingo.\n      <br />\n      Duolingo is not responsible for the translation of these stories{\" \"}\n      <span id=\"license_language\">\n        {language_name ? \"into \" + language_name : \"\"}\n      </span>{\" \"}\n      and this is not an official product of Duolingo.\n      <br />\n      Any further use of these stories requires a license from Duolingo.\n      <br />\n      Visit <Link href=\"https://www.duolingo.com\">www.duolingo.com</Link> for\n      more information.\n    </small>\n  );\n}\n"
  },
  {
    "path": "src/components/login/LoggedInButtonWrappedClient.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { LogInButton, LoggedInButton } from \"@/components/login/loggedinbutton\";\nimport { authClient } from \"@/lib/auth-client\";\n\nexport function LoggedInButtonWrappedClient(props: {\n  course_id?: string;\n  page: string;\n}) {\n  const { course_id, page } = props;\n  const { data: session } = authClient.useSession();\n\n  const sessionUser = session?.user as\n    | {\n        role?: string;\n        name?: string | null;\n        image?: string | null;\n      }\n    | undefined;\n\n  const user = sessionUser ? sessionUser : undefined;\n\n  return (\n    <>\n      {user ? (\n        <LoggedInButton page={page} course_id={course_id} user={user} />\n      ) : (\n        <LogInButton />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/login/loggedinbutton.tsx",
    "content": "\"use client\";\n\"use no memo\";\n\nimport React, { useEffect, useState } from \"react\";\nimport Link from \"next/link\";\nimport { useRouter, useSelectedLayoutSegments } from \"next/navigation\";\nimport Button from \"@/components/ui/button\";\nimport { authClient } from \"@/lib/auth-client\";\nimport { isAdmin, isContributor } from \"@/lib/userInterface\";\nimport { resetPostHogUser } from \"@/lib/posthog-user\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/shadcn\";\n\nfunction themeToLightOrDark(\n  theme: string | null,\n): \"light\" | \"dark\" | undefined {\n  if (theme === \"light\") return \"light\";\n  if (theme === \"dark\") return \"dark\";\n  return undefined;\n}\n\nfunction get_current_theme(): \"light\" | \"dark\" | undefined {\n  // it's currently saved in the document?\n  if (\n    typeof document !== \"undefined\" &&\n    document.body.dataset.theme &&\n    document.body.dataset.theme !== \"undefined\"\n  ) {\n    return themeToLightOrDark(document.body.dataset.theme);\n  }\n  if (typeof window !== \"undefined\") {\n    // it has been previously saved in the window?\n    if (\n      window.localStorage.getItem(\"theme\") !== undefined &&\n      window.localStorage.getItem(\"theme\") !== \"undefined\"\n    ) {\n      return themeToLightOrDark(window.localStorage.getItem(\"theme\"));\n    }\n    // or the user has a preference?\n    if (window.matchMedia(\"(prefers-color-scheme: dark)\").matches)\n      return \"dark\";\n    return \"light\";\n  }\n  // or we don't know yet\n  return undefined;\n}\n\nfunction useDarkLight() {\n  const [activeTheme, setActiveTheme] = useState<\"light\" | \"dark\" | undefined>(\n    undefined,\n  );\n  const inactiveTheme = activeTheme === \"light\" ? \"dark\" : \"light\";\n  //...\n\n  useEffect(() => {\n    if (activeTheme === undefined) {\n      setActiveTheme(get_current_theme());\n    } else {\n      document.body.dataset.theme = activeTheme;\n      window.localStorage.setItem(\"theme\", activeTheme);\n    }\n  }, [activeTheme]);\n\n  return {\n    set: setActiveTheme,\n    toggle: () => setActiveTheme(inactiveTheme),\n    value: activeTheme,\n  };\n}\n\nexport function LogInButton() {\n  const router = useRouter();\n  return (\n    <Button onClick={() => router.push(\"/auth/signin\")} data-cy=\"login-button\">\n      Log in\n    </Button>\n  );\n}\n\nexport function LoggedInButton({\n  page,\n  course_id,\n  user,\n}: {\n  page: string;\n  course_id?: string;\n  user?: {\n    name?: string | null;\n    image?: string | null;\n    role?: boolean | string | null;\n    admin?: boolean | null;\n  };\n}) {\n  //const { data: session } = useSession();\n  const controls = useDarkLight();\n  const router = useRouter();\n\n  const segment = useSelectedLayoutSegments();\n  if (course_id === \"segment\") {\n    if (segment[0] === \"course\") course_id = segment[1];\n    else course_id = segment[0];\n  }\n\n  let editor_link = \"/editor\";\n  if (course_id && course_id.includes(\"-\"))\n    editor_link = \"/editor/course/\" + course_id;\n  let stories_link = \"/\";\n  if (course_id) stories_link = \"/\" + course_id;\n\n  if (user === undefined)\n    return (\n      <Button\n        onClick={() => router.push(\"/auth/signin\")}\n        data-cy=\"login-button\"\n      >\n        Log in\n      </Button>\n    );\n\n  const canContribute = isContributor(user ?? null);\n  const isAdminUser = isAdmin(user ?? null);\n  const dropdownButtonClass =\n    \"w-full overflow-hidden text-left text-[var(--text-color)] no-underline [text-overflow:ellipsis] whitespace-nowrap\";\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <button\n          type=\"button\"\n          className=\"flex h-[50px] w-[50px] min-w-[50px] items-center justify-center rounded-[30px] bg-[var(--profile-background)] p-0 text-center text-[28px] leading-none uppercase text-[var(--profile-text)] outline-none transition hover:brightness-95 focus-visible:ring-2 focus-visible:ring-[var(--button-background)] focus-visible:ring-offset-2\"\n          data-cy=\"user-button\"\n          aria-label=\"Open user menu\"\n          style={\n            user?.image ? { backgroundImage: `url('${user?.image}')` } : {}\n          }\n        >\n          {(user.name ?? \"\").substring(0, 1)}\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        align=\"end\"\n        className=\"w-[180px] overflow-visible\"\n        sideOffset={10}\n      >\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute -top-[12px] right-[11px] h-[13px] w-[28px] overflow-hidden\"\n        >\n          <svg\n            viewBox=\"0 0 28 13\"\n            className=\"absolute inset-0 h-[13px] w-[28px]\"\n            aria-hidden=\"true\"\n          >\n            <path\n              d=\"M14 0.5 Q15.5 0.5 16.5 1.7 L28 13 H0 L11.5 1.7 Q12.5 0.5 14 0.5 Z\"\n              fill=\"var(--header-border)\"\n            />\n            <path\n              d=\"M14 1.5 Q15 1.5 15.8 2.4 L26 13 H2 L12.2 2.4 Q13 1.5 14 1.5 Z\"\n              fill=\"var(--body-background)\"\n            />\n          </svg>\n        </div>\n        <DropdownMenuItem asChild>\n          <Link\n            className={dropdownButtonClass}\n            href={\"/profile\"}\n            data-cy=\"user-profile\"\n          >\n            Profile\n          </Link>\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          className=\"button_dark_mode\"\n          data-cy=\"user-lightdark\"\n          onSelect={() => {\n            controls.toggle();\n          }}\n        >\n          {controls.value === \"light\"\n            ? \"Dark Mode\"\n            : controls.value === \"dark\"\n              ? \"Light Mode\"\n              : \"Light/Dark\"}\n        </DropdownMenuItem>\n        {canContribute && page !== \"stories\" ? (\n          <DropdownMenuItem asChild>\n            <Link\n              className={dropdownButtonClass}\n              href={stories_link}\n              data-cy=\"user-stories\"\n            >\n              Stories\n            </Link>\n          </DropdownMenuItem>\n        ) : null}\n        {canContribute && page !== \"editor\" ? (\n          <DropdownMenuItem asChild>\n            <Link\n              className={dropdownButtonClass}\n              href={editor_link}\n              data-cy=\"user-editor\"\n            >\n              Editor\n            </Link>\n          </DropdownMenuItem>\n        ) : null}\n        {canContribute && page !== \"docs\" ? (\n          <DropdownMenuItem asChild>\n            <Link\n              className={dropdownButtonClass}\n              href={\"/docs\"}\n              data-cy=\"user-docs\"\n            >\n              Docs\n            </Link>\n          </DropdownMenuItem>\n        ) : null}\n        {isAdminUser && page !== \"admin\" ? (\n          <DropdownMenuItem asChild>\n            <Link\n              className={dropdownButtonClass}\n              href={\"/admin\"}\n              data-cy=\"user-admin\"\n            >\n              Admin\n            </Link>\n          </DropdownMenuItem>\n        ) : null}\n        <DropdownMenuSeparator />\n        <DropdownMenuItem\n          data-cy=\"user-logout\"\n          onSelect={async () => {\n            await authClient.signOut();\n            resetPostHogUser();\n            window.location.href = \"/\";\n          }}\n        >\n          Log out\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/ConvexClientProvider.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport { ConvexReactClient } from \"convex/react\";\nimport { ConvexBetterAuthProvider } from \"@convex-dev/better-auth/react\";\nimport { authClient } from \"@/lib/auth-client\";\n\nexport default function ConvexClientProvider({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;\n  const convex = React.useMemo(\n    () => (convexUrl ? new ConvexReactClient(convexUrl) : null),\n    [convexUrl],\n  );\n\n  if (!convex) {\n    if (process.env.NODE_ENV !== \"production\") {\n      console.warn(\n        \"ConvexClientProvider: NEXT_PUBLIC_CONVEX_URL is not set. Auth will be disabled.\",\n      );\n    }\n    return <>{children}</>;\n  }\n\n  return (\n    <ConvexBetterAuthProvider client={convex} authClient={authClient}>\n      {children}\n    </ConvexBetterAuthProvider>\n  );\n}\n"
  },
  {
    "path": "src/components/providers/PostHogUserIdentifier.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\nimport posthog from \"posthog-js\";\nimport { authClient } from \"@/lib/auth-client\";\nimport { identifyPostHogUser } from \"@/lib/posthog-user\";\n\nconst PENDING_SIGNIN_STORAGE_KEY = \"posthog_pending_signin\";\n\ntype SessionUser = {\n  id?: string;\n  email?: string | null;\n  name?: string | null;\n  username?: string | null;\n  role?: string | null;\n};\n\ntype PendingSignInPayload = {\n  method?: string;\n  provider?: string;\n};\n\nexport default function PostHogUserIdentifier() {\n  const { data: session } = authClient.useSession();\n  const user = (session?.user ?? null) as SessionUser | null;\n  const trackedSignInUserId = React.useRef<string | null>(null);\n\n  const readPendingSignIn =\n    React.useCallback((): PendingSignInPayload | null => {\n      if (typeof window === \"undefined\") return null;\n      const value = window.sessionStorage.getItem(PENDING_SIGNIN_STORAGE_KEY);\n      if (!value) return null;\n      try {\n        return JSON.parse(value) as PendingSignInPayload;\n      } catch {\n        window.sessionStorage.removeItem(PENDING_SIGNIN_STORAGE_KEY);\n        return null;\n      }\n    }, []);\n\n  React.useEffect(() => {\n    if (!user?.id) return;\n    identifyPostHogUser(user);\n  }, [user]);\n\n  React.useEffect(() => {\n    if (!user?.id) return;\n    if (trackedSignInUserId.current === user.id) return;\n\n    const pending = readPendingSignIn();\n    if (!pending) return;\n\n    posthog.capture(\"user_signed_in\", {\n      method: pending.method ?? \"unknown\",\n      provider: pending.provider ?? undefined,\n    });\n    trackedSignInUserId.current = user.id;\n    if (typeof window !== \"undefined\") {\n      window.sessionStorage.removeItem(PENDING_SIGNIN_STORAGE_KEY);\n    }\n  }, [readPendingSignIn, user?.id]);\n\n  return null;\n}\n"
  },
  {
    "path": "src/components/ui/badge.tsx",
    "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport default function Badge({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) {\n  return (\n    <span\n      className={cn(\n        \"rounded-[10px] bg-[var(--editor-ssml)] px-[10px] py-[5px]\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/ui/button.tsx",
    "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ButtonVariant =\n  | \"default\"\n  | \"primary\"\n  | \"blue\"\n  | \"destructive\"\n  | \"secondary\"\n  | \"outline\"\n  | \"ghost\"\n  | \"link\";\n\nexport type ButtonSize = \"default\" | \"sm\" | \"lg\";\n\nexport type ButtonProps = {\n  children: React.ReactNode;\n  primary?: boolean;\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n} & React.ButtonHTMLAttributes<HTMLButtonElement>;\n\nfunction resolveVariant({\n  primary = false,\n  variant,\n}: {\n  primary?: boolean;\n  variant?: ButtonVariant;\n}) {\n  return variant === \"blue\"\n    ? \"primary\"\n    : primary && variant === undefined\n      ? \"primary\"\n      : (variant ?? \"default\");\n}\n\nexport function buttonRootClassName({\n  className,\n  disabled = false,\n  primary = false,\n  variant,\n}: {\n  className?: string;\n  disabled?: boolean;\n  primary?: boolean;\n  variant?: ButtonVariant;\n}) {\n  const resolvedVariant = resolveVariant({ primary, variant });\n\n  return cn(\n    \"mt-1 rounded-[15px] p-0 text-center font-bold uppercase transition-[background-color,border-color,box-shadow,filter,transform] duration-100 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[color:color-mix(in_srgb,var(--ring)_35%,transparent)]\",\n    {\n      default:\n        \"bg-[var(--button-border)] text-[var(--button-color)] disabled:bg-[var(--button-inactive-background)] disabled:text-[var(--button-inactive-color)]\",\n      primary:\n        \"bg-[var(--button-blue-border)] text-[var(--button-blue-color)] disabled:bg-[var(--button-inactive-background)] disabled:text-[var(--button-inactive-color)]\",\n      destructive:\n        \"bg-[#ea8b8b] text-[#9b1c1c] disabled:bg-[var(--button-inactive-background)] disabled:text-[var(--button-inactive-color)]\",\n      secondary:\n        \"bg-[var(--overview-hr)] text-[var(--text-color)] hover:bg-[color:color-mix(in_srgb,var(--link-blue)_18%,var(--overview-hr))] hover:text-[var(--text-color)] disabled:bg-[var(--button-inactive-background)] disabled:text-[var(--button-inactive-color)]\",\n      outline:\n        \"bg-[var(--overview-hr)] text-[var(--text-color)] hover:bg-[color:color-mix(in_srgb,var(--link-blue)_18%,var(--overview-hr))] hover:text-[var(--text-color)] disabled:bg-[var(--button-inactive-background)] disabled:text-[var(--button-inactive-color)]\",\n      ghost:\n        \"bg-transparent text-[var(--text-color)] hover:bg-[color:color-mix(in_srgb,var(--body-background-faint)_88%,transparent)] disabled:text-[var(--button-inactive-color)]\",\n      link: \"bg-transparent text-[var(--link-blue)] underline underline-offset-2 disabled:text-[var(--button-inactive-color)]\",\n    }[resolvedVariant],\n    disabled &&\n      \"cursor-not-allowed bg-[var(--button-inactive-background)] text-[var(--button-inactive-color)]\",\n    className,\n  );\n}\n\nexport function buttonInnerClassName({\n  disabled = false,\n  primary = false,\n  size = \"default\",\n  variant,\n}: {\n  disabled?: boolean;\n  primary?: boolean;\n  size?: ButtonSize;\n  variant?: ButtonVariant;\n}) {\n  const resolvedVariant = resolveVariant({ primary, variant });\n\n  return cn(\n    \"block rounded-[inherit] text-[1rem] font-bold uppercase transition-[background-color,border-color,transform] duration-100\",\n    {\n      default: disabled\n        ? \"bg-[var(--button-inactive-background)] text-[var(--button-inactive-color)]\"\n        : \"bg-[var(--button-background)]\",\n      primary: disabled\n        ? \"bg-[var(--button-inactive-background)] text-[var(--button-inactive-color)]\"\n        : \"bg-[var(--button-blue-background)]\",\n      destructive: disabled\n        ? \"bg-[var(--button-inactive-background)] text-[var(--button-inactive-color)]\"\n        : \"bg-[#f7a3a3] text-[#9b1c1c] hover:bg-[#f39a9a]\",\n      secondary:\n        \"border-2 border-[var(--overview-hr)] bg-[var(--body-background)] text-[var(--text-color)] hover:border-[color:color-mix(in_srgb,var(--link-blue)_28%,var(--overview-hr))] hover:bg-[color:color-mix(in_srgb,var(--link-blue)_8%,var(--body-background))]\",\n      outline:\n        \"border-2 border-[var(--overview-hr)] bg-[var(--body-background)] text-[var(--text-color)] hover:border-[color:color-mix(in_srgb,var(--link-blue)_28%,var(--overview-hr))] hover:bg-[color:color-mix(in_srgb,var(--link-blue)_8%,var(--body-background))]\",\n      ghost:\n        \"bg-transparent text-[var(--text-color)] hover:bg-[color:color-mix(in_srgb,var(--body-background-faint)_88%,transparent)]\",\n      link: \"bg-transparent px-0 py-0 text-[inherit] normal-case\",\n    }[resolvedVariant],\n    {\n      \"px-[30px] py-[10px]\": size === \"default\",\n      \"px-5 py-2 text-[0.92rem]\": size === \"sm\",\n      \"px-9 py-[12px] text-[1.05rem]\": size === \"lg\",\n      \"-translate-y-1 hover:-translate-y-[5px] active:-translate-y-0.5\":\n        !disabled && resolvedVariant !== \"ghost\" && resolvedVariant !== \"link\",\n      \"translate-y-0\": disabled,\n      \"px-0 py-0\": resolvedVariant === \"link\",\n      \"disabled:border-[var(--button-inactive-background)] disabled:bg-[var(--button-inactive-background)]\":\n        resolvedVariant === \"secondary\" ||\n        resolvedVariant === \"outline\" ||\n        resolvedVariant === \"destructive\",\n      \"text-[var(--button-inactive-color)]\":\n        disabled &&\n        (resolvedVariant === \"secondary\" ||\n          resolvedVariant === \"outline\" ||\n          resolvedVariant === \"destructive\"),\n    },\n  );\n}\n\nexport default React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(\n  {\n    children,\n    primary = false,\n    variant,\n    size = \"default\",\n    className,\n    disabled,\n    ...props\n  },\n  ref,\n) {\n  const isDisabled = Boolean(disabled);\n  const rootClassName = buttonRootClassName({\n    className,\n    disabled: isDisabled,\n    primary,\n    variant,\n  });\n  const innerClassName = buttonInnerClassName({\n    disabled: isDisabled,\n    primary,\n    size,\n    variant,\n  });\n\n  return (\n    <button ref={ref} className={rootClassName} disabled={disabled} {...props}>\n      <span className={innerClassName}>{children}</span>\n    </button>\n  );\n});\n"
  },
  {
    "path": "src/components/ui/dialog.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { XIcon } from \"lucide-react\";\nimport { Dialog as DialogPrimitive } from \"radix-ui\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Dialog({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  return <DialogPrimitive.Root data-slot=\"dialog\" {...props} />;\n}\n\nfunction DialogTrigger({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />;\n}\n\nfunction DialogPortal({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Portal>) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />;\n}\n\nfunction DialogClose({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"dialog-close\" {...props} />;\n}\n\nfunction DialogOverlay({\n  className,\n  disableAnimation = false,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay> & {\n  disableAnimation?: boolean;\n}) {\n  const overlayAnimationClassName = disableAnimation\n    ? \"data-[state=closed]:hidden\"\n    : \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\";\n\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"dialog-overlay\"\n      className={cn(\n        \"fixed inset-0 z-[1000] bg-black/45 backdrop-blur-[2px]\",\n        overlayAnimationClassName,\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DialogContent({\n  className,\n  children,\n  showCloseButton = true,\n  showOverlay = true,\n  overlayClassName,\n  disableAnimation = false,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content> & {\n  showCloseButton?: boolean;\n  showOverlay?: boolean;\n  overlayClassName?: string;\n  disableAnimation?: boolean;\n}) {\n  const contentAnimationClassName = disableAnimation\n    ? \"data-[state=closed]:hidden\"\n    : \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200\";\n\n  return (\n    <DialogPortal data-slot=\"dialog-portal\">\n      {showOverlay ? (\n        <DialogOverlay\n          className={overlayClassName}\n          disableAnimation={disableAnimation}\n        />\n      ) : null}\n      <DialogPrimitive.Content\n        data-slot=\"dialog-content\"\n        className={cn(\n          \"bg-background fixed inset-4 z-[1001] grid w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] gap-4 overflow-auto rounded-2xl border border-slate-200 p-6 shadow-[0_20px_50px_-24px_rgba(0,0,0,0.55)] outline-none sm:inset-auto sm:top-[50%] sm:left-[50%] sm:w-full sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%]\",\n          contentAnimationClassName,\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <DialogPrimitive.Close\n            data-slot=\"dialog-close\"\n            className=\"absolute top-3 right-3 grid size-8 place-items-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus:ring-2 focus:ring-[var(--button-background)] focus:ring-offset-2 focus:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n          >\n            <XIcon />\n            <span className=\"sr-only\">Close</span>\n          </DialogPrimitive.Close>\n        )}\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  );\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-left\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogFooter({\n  className,\n  showCloseButton = false,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  showCloseButton?: boolean;\n}) {\n  return (\n    <div\n      data-slot=\"dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      {showCloseButton ? (\n        <DialogPrimitive.Close>Close</DialogPrimitive.Close>\n      ) : null}\n    </div>\n  );\n}\n\nfunction DialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"dialog-title\"\n      className={cn(\"text-lg leading-none font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      data-slot=\"dialog-description\"\n      className={cn(\"text-sm text-slate-500\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogTitle,\n  DialogTrigger,\n};\n"
  },
  {
    "path": "src/components/ui/flag.tsx",
    "content": "import React from \"react\";\nimport Image from \"next/image\";\n\nexport default function Flag(props: {\n  width?: number | undefined;\n  height?: number | undefined;\n  iso?: string;\n  flag_file?: string | null;\n  flag?: number | null;\n  priority?: boolean;\n  loading?: \"lazy\" | \"eager\";\n  className?: string;\n}) {\n  let flag = 0;\n  const order = [\n    \"en\",\n    \"es\",\n    \"fr\",\n    \"de\",\n    \"ja\",\n    \"it\",\n    \"ko\",\n    \"zh\",\n    \"ru\",\n    \"pt\",\n    \"tr\",\n    \"nl\",\n    \"sv\",\n    \"ga\",\n    \"el\",\n    \"he\",\n    \"pl\",\n    \"no\",\n    \"vi\",\n    \"da\",\n    \"hv\",\n    \"ro\",\n    \"sw\",\n    \"eo\",\n    \"hu\",\n    \"cy\",\n    \"uk\",\n    \"tlh\",\n    \"cs\",\n    \"hi\",\n    \"id\",\n    \"hw\",\n    \"nv\",\n    \"ar\",\n    \"ca\",\n    \"th\",\n    \"gn\",\n    \"world\",\n    \"duo\",\n    \"tools\",\n    \"reader\",\n    \"la\",\n    \"gd\",\n    \"fi\",\n    \"yi\",\n    \"ht\",\n    \"tl\",\n    \"zu\",\n  ];\n  if (props.iso) {\n    for (const [index, value] of order.entries()) {\n      if (value === (props.iso || \"world\")) {\n        flag = index;\n      }\n    }\n  }\n  if (flag === 0 && !props.flag_file && props.iso !== \"en\") {\n    flag = props.flag && props.flag > 0 && props.flag < 48 ? props.flag : 37;\n  }\n\n  const isCustomFlag = Boolean(props.flag_file);\n  const aspectRatio = isCustomFlag ? 62 / 78 : 66 / 82;\n  const width = Math.round(props.width ?? 88);\n  const scale = width / 82;\n  const height = Math.round(props.height ?? width * aspectRatio);\n  const customScale = width / 88;\n  const outlineWidth = isCustomFlag ? 7 * customScale : 5 * scale;\n  const outlineOffset = isCustomFlag ? -6 * customScale : -6 * scale;\n  const flagImageStyle: React.CSSProperties = {\n    width,\n    height,\n    minWidth: width,\n    objectFit: \"cover\",\n    objectPosition: `0 ${-66 * scale * flag}px`,\n    outline: `${outlineWidth}px solid var(--body-background)`,\n    outlineOffset: `${outlineOffset}px`,\n    borderRadius: `${16 * scale}px`,\n    display: \"block\",\n    flexShrink: 0,\n  };\n\n  return (\n    <Image\n      style={flagImageStyle}\n      width={width}\n      height={height}\n      priority={props.priority === true}\n      loading={props.loading}\n      className={props.className || \"\"}\n      src={\n        props.flag_file\n          ? `/flags/${props.flag_file}`\n          : \"https://d35aaqx5ub95lt.cloudfront.net/vendor/87938207afff1598611ba626a8c4827c.svg\"\n      }\n      alt={props.iso ? `${props.iso} flag` : \"Language flag\"}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/ui/input.tsx",
    "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype InputProps = {\n  label?: string;\n} & React.InputHTMLAttributes<HTMLInputElement>;\n\nconst inputClassName =\n  \"w-full rounded-[16px] border-2 border-[var(--input-border)] bg-[var(--input-background)] px-[17px] py-[10px] text-[calc(16/16*1rem)] text-[var(--text-color)] outline-none transition focus:border-[color:color-mix(in_srgb,var(--link-blue)_45%,var(--input-border))] focus:ring-2 focus:ring-[color:color-mix(in_srgb,var(--link-blue)_12%,transparent)]\";\n\nexport default React.forwardRef<HTMLInputElement, InputProps>(function Input(\n  { label = \"\", className, ...props },\n  ref,\n) {\n  const input = (\n    <input ref={ref} className={cn(inputClassName, className)} {...props} />\n  );\n\n  if (!label) return input;\n\n  return (\n    <label className=\"inline-flex items-baseline gap-2\">\n      <span>{label}</span>\n      {input}\n    </label>\n  );\n});\n"
  },
  {
    "path": "src/components/ui/kbd.tsx",
    "content": "import { cn } from \"@/lib/utils\";\n\nfunction Kbd({ className, ...props }: React.ComponentProps<\"kbd\">) {\n  return (\n    <kbd\n      data-slot=\"kbd\"\n      className={cn(\n        \"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none\",\n        \"[&_svg:not([class*='size-'])]:size-3\",\n        \"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction KbdGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"kbd-group\"\n      className={cn(\"inline-flex items-center gap-1\", className)}\n      {...props}\n    />\n  );\n}\n\nexport { Kbd, KbdGroup };\n"
  },
  {
    "path": "src/components/ui/language-flag.tsx",
    "content": "\"use client\";\n\nimport type { Id } from \"@convex/_generated/dataModel\";\nimport { api } from \"@convex/_generated/api\";\nimport { useQuery } from \"convex/react\";\nimport type { ComponentProps } from \"react\";\nimport Flag from \"./flag\";\n\ntype FlagProps = ComponentProps<typeof Flag>;\n\ntype LanguageFlagEntry = {\n  languageId: Id<\"languages\">;\n  short: string;\n  flag?: number;\n  flag_file?: string;\n};\n\nfunction useLanguageFlag(languageId?: Id<\"languages\"> | string) {\n  const languageFlags = useQuery(api.localization.getAllLanguageFlags, {});\n  if (!languageId || !languageFlags) return undefined;\n\n  for (const language of languageFlags as LanguageFlagEntry[]) {\n    if (language.languageId === languageId) {\n      return language;\n    }\n  }\n}\n\nexport default function LanguageFlag({\n  languageId,\n  ...props\n}: {\n  languageId?: Id<\"languages\"> | string;\n} & Omit<FlagProps, \"iso\" | \"flag\" | \"flag_file\">) {\n  const language = useLanguageFlag(languageId);\n\n  return (\n    <Flag\n      {...props}\n      iso={language?.short}\n      flag={language?.flag}\n      flag_file={language?.flag_file ?? undefined}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/ui/shadcn/dropdown-menu.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { DropdownMenu as DropdownMenuPrimitive } from \"radix-ui\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction DropdownMenu({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {\n  return <DropdownMenuPrimitive.Root data-slot=\"dropdown-menu\" {...props} />;\n}\n\nfunction DropdownMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {\n  return (\n    <DropdownMenuPrimitive.Trigger\n      data-slot=\"dropdown-menu-trigger\"\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuPortal({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {\n  return (\n    <DropdownMenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />\n  );\n}\n\nfunction DropdownMenuContent({\n  className,\n  sideOffset = 8,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  return (\n    <DropdownMenuPortal>\n      <DropdownMenuPrimitive.Content\n        data-slot=\"dropdown-menu-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"z-[1200] max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[11rem] overflow-y-auto rounded-[15px] border border-[var(--header-border)] bg-[var(--body-background)] p-1 text-[var(--text-color)] shadow-[0_18px_38px_-22px_rgba(0,0,0,0.45)] outline-none\",\n          className,\n        )}\n        {...props}\n      />\n    </DropdownMenuPortal>\n  );\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean;\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      data-slot=\"dropdown-menu-item\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-[var(--language-selector-hover-background)] focus:text-[var(--link-hover)] relative flex cursor-default select-none items-center rounded-[11px] px-3 py-2 text-[18px] font-bold [text-transform:none] outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset=true]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {\n  return (\n    <DropdownMenuPrimitive.Separator\n      data-slot=\"dropdown-menu-separator\"\n      className={cn(\"-mx-1 my-1 h-px bg-[var(--header-border)]\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n};\n"
  },
  {
    "path": "src/components/ui/shadcn/index.ts",
    "content": "export * from \"./dropdown-menu\";\n"
  },
  {
    "path": "src/components/ui/sheet.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { XIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Sheet(props: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  return <DialogPrimitive.Root data-slot=\"sheet\" {...props} />;\n}\n\nfunction SheetTrigger(\n  props: React.ComponentProps<typeof DialogPrimitive.Trigger>,\n) {\n  return <DialogPrimitive.Trigger data-slot=\"sheet-trigger\" {...props} />;\n}\n\nfunction SheetClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"sheet-close\" {...props} />;\n}\n\nfunction SheetPortal(\n  props: React.ComponentProps<typeof DialogPrimitive.Portal>,\n) {\n  return <DialogPrimitive.Portal data-slot=\"sheet-portal\" {...props} />;\n}\n\nfunction SheetOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"sheet-overlay\"\n      className={cn(\n        \"fixed inset-0 z-[1000] bg-black/45 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SheetContent({\n  className,\n  children,\n  side = \"right\",\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content> & {\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n}) {\n  return (\n    <SheetPortal>\n      <SheetOverlay />\n      <DialogPrimitive.Content\n        data-slot=\"sheet-content\"\n        className={cn(\n          \"fixed z-[1001] flex flex-col gap-4 border-slate-200 bg-white shadow-[0_20px_50px_-24px_rgba(0,0,0,0.55)] outline-none transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in duration-300\",\n          side === \"left\" &&\n            \"inset-y-0 left-0 h-full w-[min(22rem,calc(100vw-1.5rem))] border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left\",\n          side === \"right\" &&\n            \"inset-y-0 right-0 h-full w-[min(22rem,calc(100vw-1.5rem))] border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right\",\n          side === \"top\" &&\n            \"inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n          side === \"bottom\" &&\n            \"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        <DialogPrimitive.Close className=\"absolute top-3 right-3 grid size-8 place-items-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus:ring-2 focus:ring-[var(--button-background)] focus:ring-offset-2 focus:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\">\n          <XIcon />\n          <span className=\"sr-only\">Close</span>\n        </DialogPrimitive.Close>\n      </DialogPrimitive.Content>\n    </SheetPortal>\n  );\n}\n\nfunction SheetHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sheet-header\"\n      className={cn(\"flex flex-col gap-1.5 p-4 pr-12\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"sheet-title\"\n      className={cn(\"text-base font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      data-slot=\"sheet-description\"\n      className={cn(\"text-sm text-slate-500\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Sheet,\n  SheetClose,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetPortal,\n  SheetTitle,\n  SheetTrigger,\n};\n"
  },
  {
    "path": "src/components/ui/spinner.tsx",
    "content": "import React from \"react\";\n\nexport function Spinner() {\n  return (\n    <div className=\"relative h-[200px] w-full\">\n      <div className=\"absolute left-1/2 top-1/2 grid -translate-x-1/2 -translate-y-1/2 grid-cols-3 gap-3\">\n        <div className=\"h-[18px] w-[18px] animate-[spinnerFade1_1.2s_ease-in-out_infinite] rounded-full bg-[#e5e5e5]\" />\n        <div className=\"h-[18px] w-[18px] animate-[spinnerFade2_1.2s_ease-in-out_infinite] rounded-full bg-[#e5e5e5]\" />\n        <div className=\"h-[18px] w-[18px] animate-[spinnerFade3_1.2s_ease-in-out_infinite] rounded-full bg-[#e5e5e5]\" />\n      </div>\n    </div>\n  );\n}\n\nexport function SpinnerBlue() {\n  return (\n    <div className=\"relative inline-block h-5 w-5\">\n      <div className=\"absolute left-1/2 top-[70%] grid -translate-x-1/2 -translate-y-1/2 grid-cols-3 gap-[2px]\">\n        <div className=\"h-1 w-1 animate-[spinnerFade1_1.2s_ease-in-out_infinite] rounded-full bg-[#0089e5]\" />\n        <div className=\"h-1 w-1 animate-[spinnerFade2_1.2s_ease-in-out_infinite] rounded-full bg-[#0089e5]\" />\n        <div className=\"h-1 w-1 animate-[spinnerFade3_1.2s_ease-in-out_infinite] rounded-full bg-[#0089e5]\" />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/ui/switch.tsx",
    "content": "export default function Switch({\n  checked,\n  onClick,\n  disabled = false,\n  ariaLabel,\n}: {\n  checked: boolean;\n  onClick: () => void;\n  disabled?: boolean;\n  ariaLabel?: string;\n}) {\n  return (\n    <button\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={checked}\n      aria-label={ariaLabel}\n      onClick={onClick}\n      disabled={disabled}\n      className={[\n        \"relative inline-flex h-[32px] w-[56px] items-center rounded-full border border-transparent transition-colors\",\n        disabled ? \"cursor-not-allowed opacity-60\" : \"cursor-pointer\",\n        checked\n          ? \"bg-[var(--button-background)]\"\n          : \"bg-[var(--button-inactive-background)]\",\n      ].join(\" \")}\n    >\n      <span\n        className={[\n          \"inline-block h-[26px] w-[26px] transform rounded-full bg-white shadow transition-transform\",\n          checked ? \"translate-x-[26px]\" : \"translate-x-[3px]\",\n        ].join(\" \")}\n      />\n    </button>\n  );\n}\n"
  },
  {
    "path": "src/hooks/use-choice-buttons.hook.ts",
    "content": "import React from \"react\";\nimport useKeypress from \"./use-keypress.hook\";\nimport { playSoundEffect } from \"@/lib/sound-effects\";\n\nexport function useChoiceButtons(\n  count: number,\n  rightIndex: number,\n  callRight: () => void,\n  callWrong: () => void,\n  active: boolean,\n) {\n  // create a list with one state for each button\n  let [buttonState, setButtonState] = React.useState<string[]>([\n    ...new Array(count),\n  ]);\n\n  let click = React.useCallback(\n    (index: number) => {\n      // when the button was already clicked, do nothing\n      if (buttonState[index] !== undefined) return;\n      // if the button was the right one\n      if (index === rightIndex) {\n        // update all button states\n        setButtonState((buttonState) =>\n          buttonState.map((v, i) =>\n            i === index ? \"right\" : v === \"false\" ? \"false\" : \"done\",\n          ),\n        );\n        // callback for clicking the right button\n        callRight();\n      } else {\n        // set the state of the current button to display that the answer was wrong\n        setButtonState((buttonState) =>\n          buttonState.map((v, i) => (i === index ? \"false\" : v)),\n        );\n        // callback for clicking the wrong button\n        playSoundEffect(\"wrong\");\n        callWrong();\n      }\n    },\n    [buttonState, callRight, callWrong, rightIndex],\n  );\n\n  useKeypress(\"number\", (value: KeyboardEvent | number) => {\n    if (typeof value === \"number\" && active && value <= count) click(value - 1);\n  });\n\n  // return button states and click callback\n  return [buttonState, click] as const;\n}\n"
  },
  {
    "path": "src/hooks/use-keypress.hook.ts",
    "content": "import React from \"react\";\n\nconst NUMBERS = [\"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"0\"];\n\nfunction useKeypress(\n  key: string,\n  callback: (event: KeyboardEvent | number) => void,\n  eventType: \"keypress\" | \"keydown\" = \"keypress\",\n) {\n  const actualEventType = key === \"Escape\" ? \"keydown\" : eventType;\n  const callbackRef = React.useRef(callback);\n\n  React.useEffect(() => {\n    callbackRef.current = callback;\n  }, [callback]);\n\n  let requireCtrl = false;\n  let targetKey = key;\n  if (key.startsWith(\"Ctrl+\") || key.startsWith(\"ctrl+\")) {\n    requireCtrl = true;\n    targetKey = key.substring(5).toLowerCase();\n  }\n\n  React.useEffect(() => {\n    function listen(event: KeyboardEvent) {\n      if (requireCtrl && !event.ctrlKey) return;\n      if (targetKey === \"number\" && NUMBERS.includes(event.key)) {\n        return callbackRef.current(Number.parseInt(event.key, 10));\n      } else if (event.code === targetKey || event.key === targetKey) {\n        return callbackRef.current(event);\n      }\n    }\n    window.addEventListener(actualEventType, listen);\n    return () => window.removeEventListener(actualEventType, listen);\n  }, [actualEventType, requireCtrl, targetKey]);\n}\n\nexport default useKeypress;\n"
  },
  {
    "path": "src/hooks/use-scroll-into-view.hook.ts",
    "content": "import React, { useEffect } from \"react\";\n\nexport default function useScrollIntoView(condition: boolean) {\n  const ref = React.useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (condition && ref.current)\n      ref.current.scrollIntoView({ behavior: \"smooth\", block: \"center\" });\n  }, [condition]);\n  return ref;\n}\n"
  },
  {
    "path": "src/instrumentation-client.ts",
    "content": "import posthog from \"posthog-js\";\n\nconst posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;\n\nif (posthogKey) {\n  posthog.init(posthogKey, {\n    api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,\n    defaults: \"2025-11-30\",\n  });\n}\n"
  },
  {
    "path": "src/lib/audio/client-audio-processing.ts",
    "content": "import { getLamejsModule } from \"@/lib/lamejs-compat\";\n\nconst DEFAULT_NORMALIZATION_TARGET_PEAK = 0.97;\nconst MP3_BITRATE_KBPS = 128;\nconst MP3_SAMPLE_BLOCK_SIZE = 1152;\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction float32ToInt16Sample(sample: number) {\n  const clampedSample = clamp(sample, -1, 1);\n  return clampedSample < 0\n    ? Math.round(clampedSample * 0x8000)\n    : Math.round(clampedSample * 0x7fff);\n}\n\nfunction toPlainArrayBuffer(view: Uint8Array | Int8Array) {\n  const arrayBuffer = new ArrayBuffer(view.byteLength);\n  new Uint8Array(arrayBuffer).set(\n    new Uint8Array(view.buffer, view.byteOffset, view.byteLength),\n  );\n  return arrayBuffer;\n}\n\nexport async function decodeAudioData(arrayBuffer: ArrayBuffer) {\n  const audioContext = new AudioContext({ latencyHint: \"interactive\" });\n  try {\n    return await audioContext.decodeAudioData(arrayBuffer.slice(0));\n  } finally {\n    await audioContext.close();\n  }\n}\n\nexport async function encodeAudioBufferAsMp3(buffer: AudioBuffer) {\n  const { Mp3Encoder } = await getLamejsModule();\n  const sampleRate = buffer.sampleRate;\n  const channelCount = Math.min(2, Math.max(1, buffer.numberOfChannels));\n  const encoder = new Mp3Encoder(channelCount, sampleRate, MP3_BITRATE_KBPS);\n  const channelData = Array.from({ length: channelCount }, (_, index) =>\n    buffer.getChannelData(index),\n  );\n  const mp3Chunks: Uint8Array[] = [];\n\n  for (\n    let frameOffset = 0;\n    frameOffset < buffer.length;\n    frameOffset += MP3_SAMPLE_BLOCK_SIZE\n  ) {\n    const chunkFrameCount = Math.min(\n      MP3_SAMPLE_BLOCK_SIZE,\n      buffer.length - frameOffset,\n    );\n    const leftChunk = new Int16Array(chunkFrameCount);\n    const rightChunk =\n      channelCount > 1 ? new Int16Array(chunkFrameCount) : null;\n\n    for (let chunkIndex = 0; chunkIndex < chunkFrameCount; chunkIndex += 1) {\n      const sourceFrameIndex = frameOffset + chunkIndex;\n      leftChunk[chunkIndex] = float32ToInt16Sample(\n        channelData[0]?.[sourceFrameIndex] ?? 0,\n      );\n      if (rightChunk) {\n        rightChunk[chunkIndex] = float32ToInt16Sample(\n          channelData[1]?.[sourceFrameIndex] ?? 0,\n        );\n      }\n    }\n\n    const encodedChunk = rightChunk\n      ? encoder.encodeBuffer(leftChunk, rightChunk)\n      : encoder.encodeBuffer(leftChunk);\n    if (encodedChunk.length > 0) {\n      mp3Chunks.push(Uint8Array.from(encodedChunk));\n    }\n  }\n\n  const flushChunk = encoder.flush();\n  if (flushChunk.length > 0) {\n    mp3Chunks.push(Uint8Array.from(flushChunk));\n  }\n\n  return new Blob(\n    mp3Chunks.map((chunk) => toPlainArrayBuffer(chunk)),\n    {\n      type: \"audio/mpeg\",\n    },\n  );\n}\n\nexport function normalizeAudioBufferPeak(\n  buffer: AudioBuffer,\n  targetPeak = DEFAULT_NORMALIZATION_TARGET_PEAK,\n) {\n  let peak = 0;\n\n  for (\n    let channelIndex = 0;\n    channelIndex < buffer.numberOfChannels;\n    channelIndex += 1\n  ) {\n    const channel = buffer.getChannelData(channelIndex);\n    for (let sampleIndex = 0; sampleIndex < channel.length; sampleIndex += 1) {\n      peak = Math.max(peak, Math.abs(channel[sampleIndex] ?? 0));\n    }\n  }\n\n  if (!Number.isFinite(peak) || peak <= 0) {\n    return {\n      buffer,\n      changed: false,\n      peak: 0,\n      gain: 1,\n    };\n  }\n\n  const safeTargetPeak = clamp(\n    Number.isFinite(targetPeak)\n      ? targetPeak\n      : DEFAULT_NORMALIZATION_TARGET_PEAK,\n    0.01,\n    0.999,\n  );\n  const gain = safeTargetPeak / peak;\n  if (Math.abs(gain - 1) < 0.01) {\n    return {\n      buffer,\n      changed: false,\n      peak,\n      gain: 1,\n    };\n  }\n\n  const normalizedBuffer = new AudioBuffer({\n    length: buffer.length,\n    numberOfChannels: buffer.numberOfChannels,\n    sampleRate: buffer.sampleRate,\n  });\n\n  for (\n    let channelIndex = 0;\n    channelIndex < buffer.numberOfChannels;\n    channelIndex += 1\n  ) {\n    const sourceChannel = buffer.getChannelData(channelIndex);\n    const nextChannel = normalizedBuffer.getChannelData(channelIndex);\n    for (\n      let sampleIndex = 0;\n      sampleIndex < sourceChannel.length;\n      sampleIndex += 1\n    ) {\n      nextChannel[sampleIndex] = clamp(\n        (sourceChannel[sampleIndex] ?? 0) * gain,\n        -1,\n        1,\n      );\n    }\n  }\n\n  return {\n    buffer: normalizedBuffer,\n    changed: true,\n    peak,\n    gain,\n  };\n}\n"
  },
  {
    "path": "src/lib/auth-client.ts",
    "content": "import { convexClient } from \"@convex-dev/better-auth/client/plugins\";\nimport { usernameClient } from \"better-auth/client/plugins\";\nimport { createAuthClient } from \"better-auth/react\";\n\nexport const authClient = createAuthClient({\n  plugins: [convexClient(), usernameClient()],\n});\n"
  },
  {
    "path": "src/lib/auth-server.ts",
    "content": "import { convexBetterAuthNextJs } from \"@convex-dev/better-auth/nextjs\";\n\nexport const { handler, isAuthenticated, fetchAuthQuery, fetchAuthMutation } =\n  convexBetterAuthNextJs({\n    convexUrl:\n      process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL ?? \"\",\n    convexSiteUrl:\n      process.env.NEXT_PUBLIC_CONVEX_SITE_URL ??\n      process.env.NEXT_PUBLIC_SITE_URL ??\n      process.env.BETTER_AUTH_URL ??\n      process.env.NEXTAUTH_URL ??\n      process.env.SITE_URL ??\n      \"\",\n  });\n"
  },
  {
    "path": "src/lib/editor/audio/audio_edit_tools.test.ts",
    "content": "import assert from \"node:assert/strict\";\nimport test from \"node:test\";\n\nimport {\n  timing_text_without_filename,\n  timings_to_text,\n} from \"@/lib/editor/audio/audio_edit_tools\";\n\ntest(\"timing_text_without_filename keeps only timing deltas\", () => {\n  assert.equal(\n    timing_text_without_filename(\"$audio/1961/_f34f3b72.mp3;2,132768;7,127\"),\n    \";2,132768;7,127\",\n  );\n});\n\ntest(\"timing_text_without_filename prevents duplicate audio filenames on save\", () => {\n  const existingTimingText = timings_to_text({\n    filename: \"audio/1961/_399e4cc6.mp3\",\n    keypoints: [\n      { rangeEnd: 2, audioStart: 132768 },\n      { rangeEnd: 9, audioStart: 132895 },\n    ],\n  });\n\n  const savedText = `$1961/_f34f3b72.mp3${timing_text_without_filename(\n    existingTimingText,\n  )}`;\n\n  assert.equal(savedText, \"$1961/_f34f3b72.mp3;2,132768;7,127\");\n});\n"
  },
  {
    "path": "src/lib/editor/audio/audio_edit_tools.ts",
    "content": "import { fetch_post } from \"@/lib/fetch_post\";\nimport type { ChangeDesc } from \"@codemirror/state\";\nimport {\n  add_word_marks_replacements,\n  find_replace_with_mapping,\n  init_mapping,\n  replace_with_mapping,\n  transcribe_text,\n} from \"./text_with_mapping\";\nimport { EditorView } from \"codemirror\";\nimport { HideRange } from \"@/components/editor/story/syntax_parser_types\";\nimport {\n  IpaReplacement,\n  TranscribeData,\n} from \"@/components/editor/story/syntax_parser_new\";\n\nexport function generate_ssml_line(\n  ssml: { speaker: string; text: string },\n  transcribe_data: TranscribeData,\n  hideRanges: HideRange[],\n  ipa_replacements: IpaReplacement[],\n) {\n  // foo{bar:ipa} replacement\n  hideRanges = JSON.parse(JSON.stringify(hideRanges));\n\n  let speaker = ssml[\"speaker\"] || \"\";\n  let speak_text = init_mapping(ssml[\"text\"]);\n  let match = speaker.match(/([^(]*)\\((.*)\\)/);\n\n  let offset = 0;\n  function insert(insert: string, pos: number) {\n    speak_text = replace_with_mapping(speak_text, insert, offset + pos);\n    for (let range of hideRanges) {\n      if (range.end > offset + pos) range.end += insert.length;\n      if (range.start > offset + pos) range.start += insert.length;\n    }\n    offset += insert.length;\n  }\n  for (let match of ipa_replacements) {\n    if (!match.word || !match.alias) continue;\n    let new_words = [`<sub alias=\"${match.alias}\">`, `</sub>`];\n    if (match.alphabet) {\n      new_words = [\n        `<phoneme alphabet=\"${match.alphabet}\" ph=\"${match.alias}\">`,\n        `</phoneme>`,\n      ];\n    }\n    insert(new_words[0], match.index);\n    insert(new_words[1], match.index + match.word.length);\n  }\n\n  for (let range of hideRanges) {\n    if (speaker.split(\"-\").length === 3)\n      speak_text = replace_with_mapping(\n        speak_text,\n        \"<break/>\",\n        range.start,\n        range.end,\n      );\n    else {\n      speak_text = replace_with_mapping(speak_text, \"</prosody>\", range.end);\n      speak_text = replace_with_mapping(\n        speak_text,\n        '<prosody volume=\"silent\">',\n        range.start,\n      );\n    }\n  }\n  speak_text.text = speak_text.text.replace(\n    /<\\/prosody><\\/sub>/g,\n    \"</sub></prosody>\",\n  );\n  speak_text.text = speak_text.text.replace(\n    /<\\/prosody><\\/phoneme>/g,\n    \"</phoneme></prosody>\",\n  );\n  //speak_text = find_replace_with_mapping(speak_text, /(\\[)(.*)(])]/,\n  //    '<prosody volume=\"silent\">$2</prosody>');\n\n  //speak_text = find_replace_with_mapping(speak_text, /\\[/, '');\n  //speak_text = find_replace_with_mapping(speak_text, /]/, '');\n\n  if (transcribe_data)\n    speak_text = transcribe_text(speak_text, transcribe_data);\n\n  speak_text = find_replace_with_mapping(\n    speak_text,\n    /(\\.\\.\\.|…)/,\n    '<sub alias=\" \">$1</sub><break/>',\n  );\n\n  if (speak_text.text.startsWith(\"<speak>\"))\n    speak_text = replace_with_mapping(speak_text, \"\", 0, \"<speak>\".length);\n  if (speak_text.text.endsWith(\"</speak>\"))\n    speak_text = replace_with_mapping(\n      speak_text,\n      \"\",\n      speak_text.text.length - \"</speak>\".length,\n      speak_text.text.length,\n    );\n\n  if (match) {\n    speaker = match[1];\n    let attributes = \"\";\n    for (let part of match[2].matchAll(/(\\w*)=([\\w-]*)/g)) {\n      attributes += ` ${part[1]}=\"${part[2]}\"`;\n    }\n\n    speak_text = replace_with_mapping(speak_text, `<prosody ${attributes}>`, 0);\n    speak_text = replace_with_mapping(\n      speak_text,\n      `</prosody>`,\n      speak_text.text.length,\n    );\n  }\n  if (\n    speaker.split(\"-\").length === 4 &&\n    [\"Wavenet\", \"Standard\", \"Neural2\"].includes(speaker.split(\"-\")[2])\n  ) {\n    speak_text = add_word_marks_replacements(speak_text);\n    speak_text = replace_with_mapping(speak_text, `<speak>`, 0);\n    speak_text = replace_with_mapping(\n      speak_text,\n      `</speak>`,\n      speak_text.text.length,\n    );\n  } else if (speaker.split(\"-\").length >= 3) {\n    let lang = speaker.split(\"-\")[0] + \"-\" + speaker.split(\"-\")[1];\n    //text = `<speak version='1.0' xml:lang='${lang}'><voice name=\"${voice_id}\">${text}</voice></speak>`;\n    speak_text = replace_with_mapping(\n      speak_text,\n      `<speak version=\"1.0\" xmlns=\"http://www.w3.org/2001/10/synthesis\" xml:lang=\"${lang}\"><voice name=\"${speaker}\">`,\n      0,\n    );\n    speak_text = replace_with_mapping(\n      speak_text,\n      `</voice></speak>`,\n      speak_text.text.length,\n    );\n  } else {\n    speak_text = replace_with_mapping(speak_text, `<speak>`, 0);\n    speak_text = replace_with_mapping(\n      speak_text,\n      `</speak>`,\n      speak_text.text.length,\n    );\n  }\n  /*\n    // when the speaker contains three times \"-\"\n    if((speaker.split(\"-\")))\n    let lang = voice_id.split(\"-\")[0] + \"-\" + voice_id.split(\"-\")[1];\n    text = `<speak version='1.0' xml:lang='${lang}'><voice name=\"${voice_id}\">${text}</voice></speak>`;\n    if()\n    if()\n    if(speaker.)\n\n     */\n\n  const normalizedMapping: number[] = [];\n  let lastValid = 0;\n  for (let i = 0; i < speak_text.mapping.length; i++) {\n    const value = speak_text.mapping[i];\n    if (typeof value === \"number\" && Number.isFinite(value)) {\n      lastValid = value;\n      normalizedMapping.push(value);\n    } else {\n      normalizedMapping.push(lastValid);\n    }\n  }\n\n  return {\n    ...ssml,\n    text: speak_text.text,\n    mapping: normalizedMapping,\n    speaker: speaker,\n  };\n}\n\nexport async function generate_audio_line(ssml: {\n  text: string;\n  speaker: string;\n  id: number;\n  mapping?: Record<number, number>;\n}) {\n  let speaker = ssml[\"speaker\"].trim();\n  let speak_text = ssml[\"text\"];\n  let mapping = ssml[\"mapping\"] ?? {};\n  /*\n  let speaker = ssml[\"speaker\"].trim();\n  let speak_text = init_mapping(ssml[\"text\"]);\n  let match = speaker.match(/([^(]*)\\((.*)\\)/);\n\n\n\n    speak_text = find_replace_with_mapping(speak_text, /\\[/, '');\n    speak_text = find_replace_with_mapping(speak_text, /]/, '');\n    speak_text = find_replace_with_mapping(speak_text, /]/, '');\n\n    if(transcribe_data)\n        speak_text = transcribe_text(speak_text, transcribe_data)\n\n    speak_text = find_replace_with_mapping(speak_text, /(\\.\\.\\.|…)/, '<sub alias=\" \">$1</sub><break/>');\n\n  if (speak_text.text.startsWith(\"<speak>\"))\n    speak_text = replace_with_mapping(speak_text, \"\", 0, \"<speak>\".length);\n  if (speak_text.text.endsWith(\"</speak>\"))\n    speak_text = replace_with_mapping(speak_text, \"\", speak_text.text.length - \"</speak>\".length, speak_text.text.length);\n\n  if (match) {\n    speaker = match[1];\n    let attributes = \"\";\n    for (let part of match[2].matchAll(/(\\w*)=([\\w-]*)/g)) {\n      attributes += ` ${part[1]}=\"${part[2]}\"`;\n    }\n\n    speak_text = replace_with_mapping(speak_text, `<prosody ${attributes}>`, 0);\n    speak_text = replace_with_mapping(speak_text, `</prosody>`, speak_text.text.length);\n  }\n  speak_text = replace_with_mapping(speak_text, `<speak>`, 0);\n  speak_text = replace_with_mapping(speak_text, `</speak>`, speak_text.text.length);\n\n  //speak_text = find_replace_with_mapping(speak_text, /(\\W)(is)(\\W)/, '$1<phoneme alphabet=\"ipa\" ph=\"bla\">$2</phoneme>$3');\n*/\n  let response2 = await fetch_post(`/audio/create`, {\n    id: ssml[\"id\"],\n    speaker: speaker,\n    text: speak_text,\n  });\n  let ssml_response = await response2.json();\n  let keypoints = [];\n  if (ssml_response.timepoints) {\n    for (let mark of ssml_response.timepoints) {\n      keypoints.push({\n        rangeEnd: parseInt(mark.markName),\n        audioStart: Math.round(mark.timeSeconds * 1000),\n      });\n    }\n  } else if (ssml_response.marks2) {\n    let last_time = 0;\n    let last_time_delta = 0;\n    let last_end = 0;\n    for (let mark of ssml_response.marks2) {\n      if (mark.timeSeconds === undefined) {\n        last_end = parseInt(mark.markName);\n        continue;\n      }\n      keypoints.push({\n        rangeEnd: parseInt(mark.markName),\n        audioStart: last_time_delta,\n      });\n      //timings.push([parseInt(mark.markName) - last_end, last_time_delta])\n      last_end = parseInt(mark.markName);\n      last_time_delta = Math.round(mark.timeSeconds * 1000); // - last_time;\n      last_time = Math.round(mark.timeSeconds * 1000);\n    }\n  } else {\n    let last_time = 0;\n    let last_end = 0;\n    for (let mark of ssml_response.marks) {\n      if (mark.time === undefined) {\n        last_end += Math.round(mark.value.length);\n        continue;\n      }\n      keypoints.push({\n        rangeEnd: mapping[Math.round(mark.end)],\n        audioStart: Math.round(mark.time),\n      });\n      //timings.push([Math.round(mark.value.length) + Math.round(last_end), Math.round(mark.time) - last_time]);\n      last_end += Math.round(mark.value.length);\n      last_time = Math.round(mark.time);\n    }\n  }\n\n  return {\n    filename: ssml_response[\"output_file\"],\n    keypoints: keypoints,\n    content: ssml_response.content,\n  };\n}\n\nexport type AudioInsertAnchor = {\n  kind: \"replace\" | \"insert\";\n  from: number;\n  to: number;\n};\n\nexport function content_to_audio(content: string) {\n  let binaryString = window.atob(content);\n  let binaryData = new Uint8Array(binaryString.length);\n  for (let i = 0; i < binaryString.length; i++) {\n    binaryData[i] = binaryString.charCodeAt(i);\n  }\n  let blob = new Blob([binaryData], { type: \"audio/mp3\" });\n  let url = URL.createObjectURL(blob);\n  let audio = new Audio();\n  audio.src = url;\n  return audio;\n}\n\nexport function timings_to_text({\n  filename,\n  keypoints,\n}: {\n  filename: string;\n  keypoints: { rangeEnd: number; audioStart: number }[];\n}) {\n  let text = filename ? \"$\" + filename : \"\";\n  let last_end = 0;\n  let last_time = 0;\n  if (keypoints) {\n    for (let point of keypoints) {\n      text += \";\";\n      text += Math.round(point.rangeEnd - last_end);\n      text += \",\";\n      text += Math.round(point.audioStart - last_time);\n      last_end = point.rangeEnd;\n      last_time = point.audioStart;\n    }\n  }\n  return text;\n}\n\nexport function timing_text_without_filename(text: string) {\n  const firstTimingIndex = text.indexOf(\";\");\n  if (firstTimingIndex === -1) return \"\";\n  return text.slice(firstTimingIndex);\n}\n\nexport function text_to_keypoints(line: string) {\n  const parts = line.split(\";\");\n  const filename = parts.splice(0, 1)[0];\n  const keypoints: { rangeEnd: number; audioStart: number }[] = [];\n  let last_end = 0;\n  let last_time = 0;\n  for (const part of parts) {\n    const [start0, duration0] = part.split(\",\");\n    const start = parseInt(start0);\n    const duration = parseInt(duration0);\n    keypoints.push({\n      rangeEnd: last_end + start,\n      audioStart: last_time + duration,\n    });\n    last_end += start;\n    last_time += duration;\n  }\n  return [filename, keypoints] as const;\n}\n\nexport function insert_audio_line(\n  text: string,\n  ssml: {\n    text: string;\n    speaker: string;\n    id: number;\n    inser_index: number;\n  },\n  view: EditorView,\n  audio_insert_lines: [number | undefined, number][],\n) {\n  const anchor = create_audio_insert_anchor(ssml, view, audio_insert_lines);\n  if (!anchor) return;\n  insert_audio_at_anchor(text, view, anchor);\n}\n\nexport function create_audio_insert_anchor(\n  ssml: {\n    inser_index: number;\n  },\n  view: EditorView,\n  audio_insert_lines: [number | undefined, number][],\n): AudioInsertAnchor | undefined {\n  const insertTarget = audio_insert_lines[ssml.inser_index];\n  if (!insertTarget) return undefined;\n\n  const [line, line_insert] = insertTarget;\n  if (line !== undefined) {\n    const lineNumber = Math.min(Math.max(1, line), view.state.doc.lines);\n    const line_state = view.state.doc.line(lineNumber);\n    return {\n      kind: \"replace\",\n      from: line_state.from,\n      to: line_state.to,\n    };\n  }\n\n  const lineNumber = Math.min(\n    Math.max(1, line_insert - 1),\n    view.state.doc.lines,\n  );\n  const line_state = view.state.doc.line(lineNumber);\n  return {\n    kind: \"insert\",\n    from: line_state.from,\n    to: line_state.from,\n  };\n}\n\nexport function map_audio_insert_anchor(\n  anchor: AudioInsertAnchor,\n  changes: ChangeDesc,\n) {\n  if (anchor.kind === \"replace\") {\n    // Keep replacement anchors wrapped around the edited content.\n    anchor.from = changes.mapPos(anchor.from, -1);\n    anchor.to = changes.mapPos(anchor.to, 1);\n    return;\n  }\n\n  const mapped = changes.mapPos(anchor.from, 1);\n  anchor.from = mapped;\n  anchor.to = mapped;\n}\n\nexport function insert_audio_at_anchor(\n  text: string,\n  view: EditorView,\n  anchor: AudioInsertAnchor,\n) {\n  const insertText = anchor.kind === \"insert\" ? `${text}\\n` : text;\n  const insertedLength = insertText.length;\n  const from = anchor.from;\n  view.dispatch(\n    view.state.update({\n      changes: {\n        from,\n        to: anchor.to,\n        insert: insertText,\n      },\n    }),\n  );\n  anchor.kind = \"replace\";\n  anchor.from = from;\n  anchor.to = from + insertedLength;\n}\n\nexport function insert_audio_lines(\n  updates: {\n    text: string;\n    ssml: {\n      text: string;\n      speaker: string;\n      id: number;\n      inser_index: number;\n    };\n  }[],\n  view: EditorView,\n  audio_insert_lines: [number | undefined, number][],\n) {\n  const changes = updates\n    .map((update) => {\n      const insertTarget = audio_insert_lines[update.ssml.inser_index];\n      if (!insertTarget) return undefined;\n\n      const [line, line_insert] = insertTarget;\n      if (line !== undefined) {\n        const lineNumber = Math.min(Math.max(1, line), view.state.doc.lines);\n        const line_state = view.state.doc.line(lineNumber);\n        return {\n          from: line_state.from,\n          to: line_state.to,\n          insert: update.text,\n        };\n      }\n\n      const lineInsertNumber = Math.min(\n        Math.max(1, line_insert - 1),\n        view.state.doc.lines,\n      );\n      const line_state = view.state.doc.line(lineInsertNumber);\n      return {\n        from: line_state.from,\n        to: line_state.from,\n        insert: update.text + \"\\n\",\n      };\n    })\n    .filter((change) => change !== undefined)\n    .sort((a, b) => a.from - b.from || a.to - b.to);\n\n  if (changes.length === 0) return;\n\n  view.dispatch(\n    view.state.update({\n      changes,\n    }),\n  );\n}\n"
  },
  {
    "path": "src/lib/editor/audio/text_with_mapping.ts",
    "content": "import { parse as parseYaml } from \"yaml\";\nimport { TranscribeData } from \"@/components/editor/story/syntax_parser_new\";\n\ntype Mapping = number[];\ntype MappedText = { text: string; mapping: Mapping };\n\nexport function init_mapping(text: string) {\n  const mapping: Mapping = [];\n  for (let i = 0; i < text.length; i++) {\n    mapping.push(i);\n  }\n  return { text, mapping } as MappedText;\n}\n\nexport function replace_with_mapping(\n  { text, mapping }: MappedText,\n  replace: string,\n  start: number,\n  end?: number,\n) {\n  if (mapping === undefined) mapping = init_mapping(text).mapping;\n  if (!end) end = start;\n  let length = end - start;\n  //console.log(\"replace_with_mapping\", {text, mapping}, replace, start, end, length)\n\n  let new_indices: number[] = [];\n  const fallbackIndex =\n    mapping[start] ??\n    mapping[start - 1] ??\n    (mapping.length > 0 ? mapping[mapping.length - 1] : 0);\n  for (let j = 0; j < replace.length; j++) {\n    const fromExisting =\n      j < length\n        ? mapping[start + j]\n        : length > 0\n          ? mapping[start + length - 1]\n          : fallbackIndex;\n    new_indices.push(\n      typeof fromExisting === \"number\" && Number.isFinite(fromExisting)\n        ? fromExisting\n        : fallbackIndex,\n    );\n  }\n  mapping.splice(start, length, ...new_indices);\n  text = text.substring(0, start) + replace + text.substring(end);\n  return { text, mapping, length: text.length };\n}\n\nexport function find_replace_with_mapping(\n  mapped_text: { text: string; mapping: number[] },\n  find: RegExp,\n  replace: string,\n) {\n  let match = mapped_text.text.match(find);\n  let o = 0;\n  while (match && match.index) {\n    match.index += o;\n    if (match.length > 1) {\n      let replacer = replace;\n      for (let i = 1; i < match.length; i++) {\n        let p1 = replacer.indexOf(\"$\" + i);\n        if (p1 !== -1) {\n          mapped_text = replace_with_mapping(\n            mapped_text,\n            replacer.substring(0, p1),\n            match.index,\n            match.index,\n          );\n          replacer = replacer.substring(p1 + 2);\n          match.index += p1 + match[i].length;\n        } else {\n          mapped_text = replace_with_mapping(\n            mapped_text,\n            \"\",\n            match.index,\n            match.index + match[i].length,\n          );\n          //replacer = replacer.substring(p1 + 2);\n          //match.index += match[i].length;\n        }\n      }\n      mapped_text = replace_with_mapping(mapped_text, replacer, match.index);\n      o = match.index + replacer.length;\n    } else {\n      mapped_text = replace_with_mapping(\n        mapped_text,\n        replace,\n        match.index,\n        match.index + match[0].length,\n      );\n      o = match.index + match[0].length;\n    }\n    //console.log(o, mapped_text.text.substring(o), match)\n    match = mapped_text.text.substring(o).match(find);\n  }\n  return mapped_text;\n}\n\nfunction apply_letter_replacements(\n  mapped_text: { text: string; mapping: number[] },\n  replacements?: Record<string, string>,\n) {\n  if (!replacements) return mapped_text;\n  let tag_start = [];\n  for (let i = 0; i < mapped_text.text.length; i++) {\n    let char = mapped_text.text[i].toLowerCase();\n    let new_char = replacements[char];\n    if (char === \"<\") {\n      tag_start.push(i);\n    } else if (char === \">\") {\n      tag_start.pop();\n    }\n    if (new_char !== undefined && tag_start.length === 0) {\n      mapped_text = replace_with_mapping(mapped_text, new_char, i, i + 1);\n      i += new_char.length - 1;\n    }\n  }\n  return mapped_text;\n}\n\n/**\n * Iterate over word replacements in the given mapped text.\n *\n * @param {object} mapped_text - The mapped text object.\n * @param {function} callback - The callback function to be called for each word replacement.\n * @returns {object} - The modified mapped text object.\n */\nfunction iter_word_replacements(\n  mapped_text: { text: string; mapping: number[] },\n  callback: (\n    mapped_text: { text: string; mapping: number[] },\n    word: string,\n    word_start: number,\n    i: number,\n    bracket_start: number,\n  ) => [mapped_text: { text: string; mapping: number[] }, i: number],\n) {\n  // Initialize variables\n  let last_char_word = false;\n  let word_start = undefined;\n  let tag_start = [];\n  let bracket_start = [];\n\n  // Iterate over each character in the text\n  for (let i = 0; i < mapped_text.text.length + 1; i++) {\n    let char = mapped_text.text[i] || \"\\n\";\n    let word;\n    let is_word = false;\n\n    // Check if the character is part of a word\n    if (![\"<\", \">\", \"[\", \"]\"].includes(char))\n      is_word = char.match(reg_white) === null;\n\n    // Check if a new word starts or ends\n    if (!last_char_word && is_word) {\n      word_start = i;\n    } else if (word_start !== undefined && !is_word) {\n      word = mapped_text.text.substring(word_start, i);\n    }\n    last_char_word = is_word;\n\n    // Call the callback function if not inside a tag and a word is found\n    if (tag_start.length === 0 && word && word_start !== undefined) {\n      [mapped_text, i] = callback(\n        mapped_text,\n        word,\n        word_start,\n        i,\n        bracket_start.length,\n      );\n    }\n    if (word) word_start = undefined;\n\n    // Update tag_start and bracket_start arrays\n    switch (char) {\n      case \"<\":\n        tag_start.push(i);\n        break;\n      case \">\":\n        tag_start.pop();\n        break;\n      case \"[\":\n        bracket_start.push(i);\n        break;\n      case \"]\":\n        bracket_start.pop();\n        break;\n    }\n  }\n\n  return mapped_text;\n}\n\nfunction apply_word_replacements(\n  mapped_text: { text: string; mapping: number[] },\n  replacements: Record<string, string>,\n  options: { in_brackets?: number },\n) {\n  mapped_text = iter_word_replacements(\n    mapped_text,\n    (mapped_text, word, word_start, i, bracket_level) => {\n      if (\n        options.in_brackets !== undefined &&\n        options.in_brackets != bracket_level\n      )\n        return [mapped_text, i];\n      //console.log(mapped_text.text.substring(0, i)+ \"ö\" + mapped_text.text.substring(i))\n      let new_word = replacements[word.toLowerCase()];\n      if (new_word !== undefined) {\n        if (new_word.endsWith(\":ipa\")) {\n          new_word = `<phoneme alphabet=\"ipa\" ph=\"${new_word.substring(\n            0,\n            new_word.length - 4,\n          )}\">`;\n          mapped_text = replace_with_mapping(\n            mapped_text,\n            new_word,\n            word_start,\n            word_start,\n          );\n          i += new_word.length;\n          new_word = `</phoneme>`;\n          mapped_text = replace_with_mapping(mapped_text, new_word, i, i);\n          i += new_word.length;\n        } else {\n          mapped_text = replace_with_mapping(\n            mapped_text,\n            new_word,\n            word_start,\n            word_start + word.length,\n          );\n          i += new_word.length - word.length;\n        }\n      }\n      return [mapped_text, i];\n    },\n  );\n  return mapped_text;\n}\n\nlet punctuation_chars =\n  \"\\\\/¡!\\\"'`#$%&*,.:;<=>¿?@^_`{|}…\" + \"。、，！？；：（）～—·《…》〈…〉﹏……——\";\n//punctuation_chars = \"\\\\\\\\¡!\\\"#$%&*,、，.。\\\\/:：;<=>¿?@^_`{|}…\"\n\nlet reg_white = new RegExp(`[\\\\s${punctuation_chars}~]`);\nexport function add_word_marks_replacements(mapped_text: {\n  text: string;\n  mapping: number[];\n}) {\n  mapped_text = iter_word_replacements(\n    mapped_text,\n    (mapped_text, word, word_start, i, bracket_start) => {\n      let insert = `<mark name=\"${mapped_text.mapping[i]}\"/>`;\n      mapped_text = replace_with_mapping(mapped_text, insert, word_start);\n      i += insert.length;\n      return [mapped_text, i];\n    },\n  );\n  return mapped_text;\n}\n\nlet regex_split_token = new RegExp(\n  `([\\\\s${punctuation_chars}\\\\]]*(?:^|\\\\s|$|​)[\\\\s${punctuation_chars}]*)`,\n);\nlet regex_split_token2 = new RegExp(\n  `([\\\\s${punctuation_chars}~]*(?:^|\\\\s|$|​)[\\\\s${punctuation_chars}~]*)`,\n);\nfunction splitTextTokens(text: string, keep_tilde = true) {\n  if (!text) return [];\n  //console.log(text, text.split(/([\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*(?:^|\\s|$)[\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*)/))\n  if (keep_tilde)\n    //return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}]+)/)\n    return text.split(regex_split_token);\n  //return text.split(/([\\s\\\\¡!\"#$%&*,、，.。\\/:：;<=>¿?@^_`{|}…\\]]*(?:^|\\s|$|​)[\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*)/)\n  //return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}~]+)/)\n  else return text.split(regex_split_token2);\n  //return text.split(/([\\s\\\\¡!\"#$%&*,、，.。\\/:：;<=>¿?@^_`{|}…~]*(?:^|\\s|$|​)[\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…~]*)/)\n}\n\nfunction apply_fragment_replacements(\n  mapped_text: { text: string; mapping: number[] },\n  replacements: Record<string, string>,\n) {\n  mapped_text = iter_word_replacements(\n    mapped_text,\n    (mapped_text, word, word_start, i, bracket_start) => {\n      for (let frag in replacements) {\n        let match = word.match(new RegExp(frag, \"i\"));\n        if (!match || !match.index) continue;\n        let new_word = replacements[frag];\n        mapped_text = replace_with_mapping(\n          mapped_text,\n          new_word,\n          word_start + match.index,\n          word_start + match.index + match[0].length,\n        );\n        i += new_word.length - match[0].length;\n      }\n      return [mapped_text, i];\n    },\n  );\n  return mapped_text;\n\n  for (let frag in replacements) {\n    mapped_text = find_replace_with_mapping(\n      mapped_text,\n      new RegExp(frag, \"i\"),\n      replacements[frag],\n    );\n  }\n  return mapped_text;\n}\n\nfunction apply_group(\n  mapped_text: { text: string; mapping: number[] },\n  data: Record<string, unknown>,\n  options: { in_brackets?: number },\n) {\n  const asRecord = (value: unknown) =>\n    value && typeof value === \"object\"\n      ? (value as Record<string, unknown>)\n      : null;\n\n  for (const section in data) {\n    if (section.toUpperCase() === \"OPTIONS\") {\n      const optionRecord = asRecord(data[section]);\n      if (optionRecord) {\n        options = { ...options, ...optionRecord } as { in_brackets?: number };\n      }\n    }\n  }\n\n  for (const section in data) {\n    const sectionRecord = asRecord(data[section]);\n    if (section.toUpperCase().startsWith(\"GROUP\")) {\n      if (sectionRecord) {\n        mapped_text = apply_group(mapped_text, sectionRecord, options);\n      }\n    }\n    if (section.toUpperCase() === \"LETTERS\") {\n      if (!sectionRecord) continue;\n      mapped_text = apply_letter_replacements(\n        mapped_text,\n        sectionRecord as Record<string, string>,\n        //options,\n      );\n    }\n    if (section.toUpperCase() === \"FRAGMENTS\") {\n      if (!sectionRecord) continue;\n      mapped_text = apply_fragment_replacements(\n        mapped_text,\n        sectionRecord as Record<string, string>,\n        //options,\n      );\n    }\n    if (section.toUpperCase() === \"WORDS\") {\n      if (!sectionRecord) continue;\n      mapped_text = apply_word_replacements(\n        mapped_text,\n        sectionRecord as Record<string, string>,\n        options,\n      );\n    }\n  }\n  return mapped_text;\n}\n\nexport function transcribe_text(\n  mapped_text: { text: string; mapping: number[] },\n  data: TranscribeData,\n) {\n  const data2 = parseYaml(data) as Record<string, unknown>;\n\n  mapped_text = apply_group(mapped_text, data2, {});\n\n  return mapped_text;\n}\n\nif (0) {\n  let speak_text = init_mapping(\n    \"break this... <break name='xx' />is [be KALO and] Text break\",\n  );\n\n  //speak_text = apply_word_replacements(speak_text, {\"break\": \"take:ipa\", \"is\": \"XX\"})\n\n  //console.log(speak_text)\n  //speak_text = apply_letter_replacements(speak_text, {\"b\": \"IaI\", \"n\": \"i\"})\n  //console.log(speak_text)\n\n  speak_text = apply_fragment_replacements(speak_text, {\n    eak$: \"ion\",\n    \"^b\": \"B\",\n  });\n\n  //speak_text = add_word_marks_replacements(speak_text);\n  //console.log(speak_text)\n}\nif (0) {\n  let speak_text = init_mapping(\n    \"break this... <break name='xx' />is [be KALO and] Text break\",\n  );\n  speak_text = apply_letter_replacements(speak_text, { i: \"IaI\", a: \"i\" });\n  speak_text = apply_word_replacements(\n    speak_text,\n    {\n      is: \"bla:ipa\",\n      text: \"test\",\n    },\n    {},\n  );\n  //speak_text = find_replace_with_mapping(speak_text, \"t\", \"t\")\n\n  //speak_text = find_replace_with_mapping(speak_text, /\\[/, \"\")\n  //console.log(speak_text)\n\n  //speak_text = find_replace_with_mapping(speak_text, /(is)/, \"X$1X\")\n  //speak_text = find_replace_with_mapping(speak_text, /(\\[.*?)(K)(ALO)(.*?\\])/, '$1$2$4');\n  speak_text = find_replace_with_mapping(\n    speak_text,\n    /(\\W)(is)(\\W)/,\n    '$1<phoneme alphabet=\"ipa\" ph=\"bla\">$2</phoneme>$3',\n  );\n}\n"
  },
  {
    "path": "src/lib/editor/editorHandlers.ts",
    "content": "import type { EditorStateType } from \"@/app/editor/story/[story]/editor_state\";\n\nexport interface EditorBlock {\n  block_start_no?: number;\n  start_no?: number;\n  end_no?: number;\n  active_no?: number;\n}\n\nexport interface EditorProps {\n  editorState?: EditorStateType;\n  editorBlock?: EditorBlock;\n}\n\nexport function getEditorHandlers(editorProps?: EditorProps): {\n  isEditorMode: boolean;\n  forceVisible: boolean;\n  onClick: (() => void) | undefined;\n} {\n  const editorState = editorProps?.editorState;\n  const editorBlock = editorProps?.editorBlock;\n  const view = editorState?.view;\n\n  let onClick: (() => void) | undefined;\n\n  if (editorBlock && view && editorState) {\n    onClick = () => {\n      if (editorBlock.active_no) {\n        editorState.select(String(editorBlock.active_no), false);\n      } else if (editorBlock.start_no) {\n        editorState.select(String(editorBlock.start_no), false);\n      }\n    };\n  }\n\n  return {\n    isEditorMode: !!editorState,\n    forceVisible: !!editorState,\n    onClick,\n  };\n}\n"
  },
  {
    "path": "src/lib/editor/tts_transcripte.ts",
    "content": "import { parse as parseYaml } from \"yaml\";\n\nlet punctuation_chars =\n  \"\\\\/¡!\\\"'`#$%&*,.:;<=>¿?@^_`{|}…\" + \"。、，！？；：（）～—·《…》〈…〉﹏……——\";\n//punctuation_chars = \"\\\\\\\\¡!\\\"#$%&*,、，.。\\\\/:：;<=>¿?@^_`{|}…\"\n\nlet regex_split_token = new RegExp(\n  `([\\\\s${punctuation_chars}\\\\]]*(?:^|\\\\s|$|​)[\\\\s${punctuation_chars}]*)`,\n);\nlet regex_split_token2 = new RegExp(\n  `([\\\\s${punctuation_chars}~]*(?:^|\\\\s|$|​)[\\\\s${punctuation_chars}~]*)`,\n);\n/*\nfunction splitTextTokens(text, keep_tilde=true) {\n    if(!text)\n        return [];\n    //console.log(text, text.split(/([\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*(?:^|\\s|$)[\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*)/))\n    if(keep_tilde)\n        return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}]+)/)\n    //return text.split(regex_split_token)\n    else\n        return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}~]+)/)\n    //return text.split(regex_split_token2)\n}\n*/\n\nexport function splitTextTokens(\n  text: string,\n  keep_tilde: boolean = true,\n): string[] {\n  if (!text) return [];\n  //console.log(text, text.split(/([\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*(?:^|\\s|$)[\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*)/))\n  if (keep_tilde)\n    //return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}]+)/)\n    return text.split(regex_split_token);\n  //return text.split(/([\\s\\\\¡!\"#$%&*,、，.。\\/:：;<=>¿?@^_`{|}…\\]]*(?:^|\\s|$|​)[\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…]*)/)\n  //return text.split(/([\\s\\u2000-\\u206F\\u2E00-\\u2E7F\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}~]+)/)\n  else return text.split(regex_split_token2);\n  //return text.split(/([\\s\\\\¡!\"#$%&*,、，.。\\/:：;<=>¿?@^_`{|}…~]*(?:^|\\s|$|​)[\\s\\\\¡!\"#$%&*,.\\/:;<=>¿?@^_`{|}…~]*)/)\n}\n\nlet data = `\nmode:\n    ipa: true\nletters:\n    a: a\n    b: b\n    c: ts\n    ĉ: cz\n    d: d\n    e: e\n    f: f\n    g: g\n    ĝ: dż\n    h: h\n    ĥ: ch\n    i: ij\n    j: y\n    ĵ: rz\n    k: k\n    l: l\n    m: m\n    n: n\n    o: o\n    p: p\n    r: r\n    s: s\n    ŝ: sz\n    t: t\n    u: u\n    ŭ: ł\n    v: w\n    z: z\nfragments:\n    tsx: cz:ipa\n    gx: dż\n    hx: ch\n    yx: rz\n    sx: sz\n    ux: ł\n    atsij: atssij\n    ide\\\\b: ijde\n    io\\\\b: ijo\n    ioy\\\\b: ijoj\n    ioyn\\\\b: ijojn\n    feyo\\\\b: fejo\n    feyoy\\\\b: feyoj\n    feyoyn\\\\b: feyoj\n    ^ekzij: ekzji\n    tssijl: tssil\n    ijuy: iuyy\n    ijeh: ije\n    sijlo: ssilo\n    ^sij: syy\n    tsij: tssij\n    sij: ssij\n    sssij: ssij\n    rijpozij: ryypozyj\n    zijs: zyjs\nwords:\n    ok: ohk\n    s-ro: sjijnjoro\n    s-ino: sjijnjorijno\n    ktp: ko-to-po\n    k.t.p: ko-to-po\n    atm: antałtagmeze\n    ptm: posttagmeze\n    bv: bonvolu\n`;\ndata = `\n# es-MX-GerardoNeural\n# jan~[[lape~uta~sona~ike]] li lon tomo\n\n# lines with # are ignored\n\n# here you can add single letters that should be replaced    \nLETTERS:\n    ä: ä\n# here you can add parts of words to be replaced. You can use valid regular expressions (regex) here\nFRAGMENTS:\n    (\\\\[[^\\\\]]*?)(kepeken)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(sitelen)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(kalama)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(kulupu)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(pakala)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(palisa)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(pimeja)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(sijelo)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(sinpin)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(soweli)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(akesi)([^\\\\]]*?\\\\]): A\n    (\\\\[[^\\\\]]*?)(alasa)([^\\\\]]*?\\\\]): A\n    (\\\\[[^\\\\]]*?)(kiwen)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(linja)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(lukin)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(monsi)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(nanpa)([^\\\\]]*?\\\\]): N\n    (\\\\[[^\\\\]]*?)(nasin)([^\\\\]]*?\\\\]): N\n    (\\\\[[^\\\\]]*?)(pilin)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(tenpo)([^\\\\]]*?\\\\]): T\n    (\\\\[[^\\\\]]*?)(utala)([^\\\\]]*?\\\\]): U\n    (\\\\[[^\\\\]]*?)(anpa)([^\\\\]]*?\\\\]): A\n    (\\\\[[^\\\\]]*?)(ante)([^\\\\]]*?\\\\]): A\n    (\\\\[[^\\\\]]*?)(awen)([^\\\\]]*?\\\\]): A\n    (\\\\[[^\\\\]]*?)(esun)([^\\\\]]*?\\\\]): E\n    (\\\\[[^\\\\]]*?)(insa)([^\\\\]]*?\\\\]): I\n    (\\\\[[^\\\\]]*?)(jaki)([^\\\\]]*?\\\\]): J\n    (\\\\[[^\\\\]]*?)(jelo)([^\\\\]]*?\\\\]): J\n    (\\\\[[^\\\\]]*?)(kala)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(kama)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(kasi)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(kili)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(kule)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(kute)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(lape)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(laso)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(lawa)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(lete)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(lili)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(lipu)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(loje)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(luka)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(lupa)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(mama)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(mani)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(meli)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(mije)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(moku)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(moli)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(musi)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(mute)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(nasa)([^\\\\]]*?\\\\]): N\n    (\\\\[[^\\\\]]*?)(nena)([^\\\\]]*?\\\\]): N\n    (\\\\[[^\\\\]]*?)(nimi)([^\\\\]]*?\\\\]): N\n    (\\\\[[^\\\\]]*?)(noka)([^\\\\]]*?\\\\]): N\n    (\\\\[[^\\\\]]*?)(olin)([^\\\\]]*?\\\\]): O\n    (\\\\[[^\\\\]]*?)(open)([^\\\\]]*?\\\\]): O\n    (\\\\[[^\\\\]]*?)(pali)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(pana)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(pini)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(pipi)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(poka)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(poki)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(pona)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(sama)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(seli)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(selo)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(seme)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(sewi)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(sike)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(sina)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(sona)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(suli)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(suno)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(supa)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(suwi)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(taso)([^\\\\]]*?\\\\]): T\n    (\\\\[[^\\\\]]*?)(tawa)([^\\\\]]*?\\\\]): T\n    (\\\\[[^\\\\]]*?)(telo)([^\\\\]]*?\\\\]): T\n    (\\\\[[^\\\\]]*?)(toki)([^\\\\]]*?\\\\]): T\n    (\\\\[[^\\\\]]*?)(tomo)([^\\\\]]*?\\\\]): T\n    (\\\\[[^\\\\]]*?)(unpa)([^\\\\]]*?\\\\]): U\n    (\\\\[[^\\\\]]*?)(walo)([^\\\\]]*?\\\\]): W\n    (\\\\[[^\\\\]]*?)(waso)([^\\\\]]*?\\\\]): W\n    (\\\\[[^\\\\]]*?)(wawa)([^\\\\]]*?\\\\]): W\n    (\\\\[[^\\\\]]*?)(weka)([^\\\\]]*?\\\\]): W\n    (\\\\[[^\\\\]]*?)(wile)([^\\\\]]*?\\\\]): W\n    (\\\\[[^\\\\]]*?)(ala)([^\\\\]]*?\\\\]): A\n    (\\\\[[^\\\\]]*?)(ale)([^\\\\]]*?\\\\]): A\n    (\\\\[[^\\\\]]*?)(anu)([^\\\\]]*?\\\\]): A\n    (\\\\[[^\\\\]]*?)(ijo)([^\\\\]]*?\\\\]): I\n    (\\\\[[^\\\\]]*?)(ike)([^\\\\]]*?\\\\]): I\n    (\\\\[[^\\\\]]*?)(ilo)([^\\\\]]*?\\\\]): I\n    (\\\\[[^\\\\]]*?)(jan)([^\\\\]]*?\\\\]): J\n    (\\\\[[^\\\\]]*?)(ken)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(kon)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(len)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(lon)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(mun)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(ona)([^\\\\]]*?\\\\]): O\n    (\\\\[[^\\\\]]*?)(pan)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(sin)([^\\\\]]*?\\\\]): S\n    (\\\\[[^\\\\]]*?)(tan)([^\\\\]]*?\\\\]): T\n    (\\\\[[^\\\\]]*?)(uta)([^\\\\]]*?\\\\]): U\n    (\\\\[[^\\\\]]*?)(wan)([^\\\\]]*?\\\\]): W\n    (\\\\[[^\\\\]]*?)(en)([^\\\\]]*?\\\\]): E\n    (\\\\[[^\\\\]]*?)(jo)([^\\\\]]*?\\\\]): J\n    (\\\\[[^\\\\]]*?)(ko)([^\\\\]]*?\\\\]): K\n    (\\\\[[^\\\\]]*?)(la)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(li)([^\\\\]]*?\\\\]): L\n    (\\\\[[^\\\\]]*?)(ma)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(mi)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(mu)([^\\\\]]*?\\\\]): M\n    (\\\\[[^\\\\]]*?)(ni)([^\\\\]]*?\\\\]): N\n    (\\\\[[^\\\\]]*?)(pi)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(pu)([^\\\\]]*?\\\\]): P\n    (\\\\[[^\\\\]]*?)(tu)([^\\\\]]*?\\\\]): T\n    (\\\\[[^\\\\]]*?)(~| )([^\\\\]]*?\\\\]): \"\"\n    \\\\\\\\n:  \" \"\n    \\\\~: \" \"\n    \\\\[: \" \"\n    \\\\]: \" \"\n    z+: \" \"\n    \\\\(: \" \"\n    \\\\): \" \"\n    \\\\+: \" \"\n# whole words that should be replaced\nWORDS:\n    a: a{a:ipa}\n    akesi: akesi{akesi:ipa}\n    ala: ala{ala:ipa}\n    alasa: alasa{alasa:ipa}\n    ale: ale{ale:ipa}\n    ali: ali{ali:ipa}\n    anpa: anpa{anpa:ipa}\n    ante: ante{ante:ipa}\n    anu: anu{anu:ipa}\n    awen: awen{awen:ipa}\n    e: e{e:ipa}\n    en: en{en:ipa}\n    epiku: epiku{epiku:ipa}\n    esun: esun{esun:ipa}\n    ijo: ijo{ijo:ipa}\n    ike: ike{ike:ipa}\n    ilo: ilo{ilo:ipa}\n    insa: insa{insa:ipa}\n    jaki: jaki{jaki:ipa}\n    jan: jan{jan:ipa}\n    jasima: jasima{jasima:ipa}\n    jelo: jelo{jelo:ipa}\n    jo: jo{jo:ipa}\n    kala: kala{kala:ipa}\n    kalama: kalama{kalama:ipa}\n    kama: kama{kama:ipa}\n    kasi: kasi{kasi:ipa}\n    ken: ken{ken:ipa}\n    kepeken: kepeken{kepeken:ipa}\n    kijetesantakalu: kijetesantakalu{kijetesantakalu:ipa}\n    kili: kili{kili:ipa}\n    kin: kin{kin:ipa}\n    kipisi: kipisi{kipisi:ipa}\n    kiwen: kiwen{kiwen:ipa}\n    ko: ko{ko:ipa}\n    kokosila: kokosila{kokosila:ipa}\n    kon: kon{kon:ipa}\n    kule: kule{kule:ipa}\n    kulupu: kulupu{kulupu:ipa}\n    kute: kute{kute:ipa}\n    la: la{la:ipa}\n    lanpan: lanpan{lanpan:ipa}\n    lape: lape{lape:ipa}\n    laso: laso{laso:ipa}\n    lawa: lawa{lawa:ipa}\n    leko: leko{leko:ipa}\n    len: len{len:ipa}\n    lete: lete{lete:ipa}\n    li: li{li:ipa}\n    lili: lili{lili:ipa}\n    linja: linja{linja:ipa}\n    lipu: lipu{lipu:ipa}\n    loje: loje{loje:ipa}\n    lon: lon{lon:ipa}\n    luka: luka{luka:ipa}\n    lukin: lukin{lukin:ipa}\n    lupa: lupa{lupa:ipa}\n    ma: ma{ma:ipa}\n    mama: mama{mama:ipa}\n    mani: mani{mani:ipa}\n    meli: meli{meli:ipa}\n    meso: meso{meso:ipa}\n    mi: mi{mi:ipa}\n    mije: mije{mije:ipa}\n    misikeke: misikeke{misikeke:ipa}\n    moku: moku{moku:ipa}\n    moli: moli{moli:ipa}\n    monsi: monsi{monsi:ipa}\n    monsuta: monsuta{monsuta:ipa}\n    mu: mu{mu:ipa}\n    mun: mun{mun:ipa}\n    musi: musi{musi:ipa}\n    mute: mute{mute:ipa}\n    namako: namako{namako:ipa}\n    nanpa: nanpa{nanpa:ipa}\n    nasa: nasa{nasa:ipa}\n    nasin: nasin{nasin:ipa}\n    nena: nena{nena:ipa}\n    ni: ni{ni:ipa}\n    nimi: nimi{nimi:ipa}\n    noka: noka{noka:ipa}\n    o: o{o:ipa}\n    oko: oko{oko:ipa}\n    olin: olin{olin:ipa}\n    ona: ona{ona:ipa}\n    open: open{open:ipa}\n    pakala: pakala{pakala:ipa}\n    pali: pali{pali:ipa}\n    palisa: palisa{palisa:ipa}\n    pan: pan{pan:ipa}\n    pana: pana{pana:ipa}\n    pi: pi{pi:ipa}\n    pilin: pilin{pilin:ipa}\n    pimeja: pimeja{pimeja:ipa}\n    pini: pini{pini:ipa}\n    pipi: pipi{pipi:ipa}\n    poka: poka{poka:ipa}\n    poki: poki{poki:ipa}\n    pona: pona{pona:ipa}\n    pu: pu{pu:ipa}\n    sama: sama{sama:ipa}\n    seli: seli{seli:ipa}\n    selo: selo{selo:ipa}\n    seme: seme{seme:ipa}\n    sewi: sewi{sewi:ipa}\n    sijelo: sijelo{sijelo:ipa}\n    sike: sike{sike:ipa}\n    sin: sin{sin:ipa}\n    sina: sina{sina:ipa}\n    sinpin: sinpin{sinpin:ipa}\n    sitelen: sitelen{sitelen:ipa}\n    soko: soko{soko:ipa}\n    sona: sona{sona:ipa}\n    soweli: soweli{soweli:ipa}\n    suli: suli{suli:ipa}\n    suno: suno{suno:ipa}\n    supa: supa{supa:ipa}\n    suwi: suwi{suwi:ipa}\n    tan: tan{tan:ipa}\n    taso: taso{taso:ipa}\n    tawa: tawa{tawa:ipa}\n    telo: telo{telo:ipa}\n    tenpo: tenpo{tenpo:ipa}\n    toki: toki{toki:ipa}\n    tomo: tomo{tomo:ipa}\n    tonsi: tonsi{tonsi:ipa}\n    tu: tu{tu:ipa}\n    unpa: unpa{unpa:ipa}\n    uta: uta{uta:ipa}\n    utala: utala{utala:ipa}\n    walo: walo{walo:ipa}\n    wan: wan{wan:ipa}\n    waso: waso{waso:ipa}\n    wawa: wawa{wawa:ipa}\n    weka: weka{weka:ipa}\n    wile: wile{wile:ipa}\n    n: n{n:ipa}\n    ku: ku{ku:ipa}\n`;\nlet text = \"Ich better testetsx amion und einen hut\";\ninterface TranscribeConfig {\n  [section: string]: {\n    [key: string]: string;\n  };\n}\n\nfunction transcribe_text(text: string, dataYaml: string): [string, number[]] {\n  const config = parseYaml(dataYaml) as TranscribeConfig;\n\n  const mapping: number[] = [];\n  for (const section in config) {\n    if (section.toUpperCase() === \"MODE\") {\n      // Mode section handling - ipa flag not used currently\n    }\n    if (section.toUpperCase() === \"LETTERS\") {\n      let text2 = \"\";\n      const sectionData = config[section];\n      for (let i = 0; i < text.length; i++) {\n        if (sectionData[text[i]]) {\n          text2 += sectionData[text[i]];\n          for (let j = 0; j < sectionData[text[i]].length; j++) mapping.push(i);\n        } else {\n          text2 += text[i];\n          mapping.push(i);\n        }\n      }\n      text = text2;\n    }\n    if (section.toUpperCase() === \"FRAGMENTS\") {\n      const sectionData = config[section];\n      for (const frag in sectionData) {\n        let match: (RegExpMatchArray & { index?: number }) | null = text.match(\n          new RegExp(frag, \"i\"),\n        );\n        let counter = 0;\n        while (match && match.index !== undefined && counter < 100) {\n          let matchIndex = match.index;\n          if (match.length >= 3) {\n            matchIndex = match.index + match[1].length;\n            match = [match[2]] as RegExpMatchArray;\n            (match as RegExpMatchArray & { index: number }).index = matchIndex;\n          }\n          text =\n            text.substring(0, matchIndex) +\n            sectionData[frag] +\n            text.substring(matchIndex + match[0].length);\n          const new_indices: number[] = [];\n          for (let j = 0; j < sectionData[frag].length; j++) {\n            if (j < match[0].length) new_indices.push(matchIndex + j);\n            else new_indices.push(matchIndex + match[0].length - 1);\n          }\n          mapping.splice(matchIndex, match[0].length, ...new_indices);\n          match = text.match(new RegExp(frag, \"i\"));\n          counter += 1;\n        }\n      }\n    }\n    if (section.toUpperCase() === \"WORDS\") {\n      let text2 = \"\";\n      const sectionData = config[section];\n      for (const word of splitTextTokens(text)) {\n        if (sectionData[word]) {\n          const new_indices: number[] = [];\n          for (let j = 0; j < sectionData[word].length; j++) {\n            if (j < word.length) new_indices.push(text2.length + j);\n            else new_indices.push(text2.length + word.length - 1);\n          }\n          mapping.splice(text2.length, word.length, ...new_indices);\n\n          text2 += sectionData[word];\n        } else text2 += word;\n      }\n      text = text2;\n    }\n  }\n  let match = text.match(/(\\w*)\\{(\\w*):ipa\\}/);\n  let counter = 0;\n  while (match && counter < 10) {\n    //console.log(match);\n    text = text.replace(\n      match[0],\n      `<phoneme alphabet=\"ipa\" ph=\"${match[1]}\">${match[2]}</phoneme>`,\n    );\n    match = text.match(/(\\w*)\\{(\\w*):ipa\\}/);\n    counter += 1;\n  }\n  //console.log(match);\n  //if(ipa)\n  return [text, mapping];\n}\n//console.log(transcribe_text(\"jan~[[lape~uta~sona~ike]] li lon tomo\", data))\n/*\nletters:\n    a: a\n    b: b\n    c: ts\n    ĉ: cz\n    d: d\n    e: e\n    f: f\n    g: g\n    ĝ: dż\n    h: h\n    ĥ: ch\n    i: ij\n    j: y\n    ĵ: rz\n    k: k\n    l: l\n    m: m\n    n: n\n    o: o\n    p: p\n    r: r\n    s: s\n    ŝ: sz\n    t: t\n    u: u\n    ŭ: ł\n    v: w\n    z: z\nfragments:\n    tsx: cz\n    gx: dż\n    hx: ch\n    yx: rz\n    sx: sz\n    ux: ł\n    atsij: atssij\n    ide\\b: ijde\n    io\\b: ijo\n    ioy\\b: ijoj\n    ioyn\\b: ijojn\n    feyo\\b: fejo\n    feyoy\\b: feyoj\n    feyoyn\\b: feyoj\n    ^ekzij: ekzji\n    tssijl: tssil\n    ijuy: iuyy\n    ijeh: ije\n    sijlo: ssilo\n    ^sij: syy\n    tsij: tssij\n    sij: ssij\n    sssij: ssij\n    rijpozij: ryypozyj\n    zijs: zyjs\nwords:\n    ok: ohk\n    s-ro: sjijnjoro\n    s-ino: sjijnjorijno\n    ktp: ko-to-po\n    k.t.p: ko-to-po\n    atm: antałtagmeze\n    ptm: posttagmeze\n    bv: bonvolu\n */\n/*\nlet words = \"a akesi ala alasa ale anpa ante anu awen e en esun ijo ike ilo insa jaki jan jelo jo kala kalama kama kasi ken kepeken kili kiwen ko kon kule kulupu kute la lape laso lawa len lete li lili linja lipu loje lon luka lukin lupa ma mama mani meli mi mije moku moli monsi mu mun musi mute nanpa nasa nasin nena ni nimi noka o olin ona open pakala pali palisa pan pana pi pilin pimeja pini pipi poka poki pona pu sama seli selo seme sewi sijelo sike sin sina sinpin sitelen sona soweli suli suno supa suwi tan taso tawa telo tenpo toki tomo tu unpa uta utala walo wan waso wawa weka wile\"\nfor(let word of words.split(\" \").sort((a, b) => b.length - a.length)) {\n    //console.log(word)\n    if(word.length > 1)\n        //console.log(`    (\\\\[[^\\\\]]*?)(${word})([^\\\\]]*?\\\\]): ${word.substring(0, 1).toUpperCase()}`)\n}\n*/\n"
  },
  {
    "path": "src/lib/fetch_post.ts",
    "content": "export async function fetch_post(\n  url: string,\n  data: unknown,\n): Promise<Response> {\n  // check if the user is logged in\n  let req = new Request(url, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify(data),\n    mode: \"cors\",\n    credentials: \"include\",\n  });\n  return fetch(req);\n}\n"
  },
  {
    "path": "src/lib/getUserId.ts",
    "content": "import { getUser } from \"@/lib/userInterface\";\n\nexport default async function getUserId() {\n  const user = await getUser();\n  return user?.userId;\n}\n"
  },
  {
    "path": "src/lib/get_localisation.ts",
    "content": "import get_localisation_func from \"@/lib/get_localisation_func\";\nimport { ConvexHttpClient } from \"convex/browser\";\nimport { api } from \"@convex/_generated/api\";\nimport type { Id } from \"@convex/_generated/dataModel\";\n\nexport const get_localisation_dict = async (lang: number) => {\n  if (!lang) return {};\n  const rows = await get_localisation_entries_by_legacy_language_id(lang);\n  const data: Record<string, string> = {};\n  for (const row of rows) data[row.tag] = row.text;\n  return data;\n};\n\nconst convexUrl =\n  process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL ?? \"\";\nif (!convexUrl) {\n  throw new Error(\"Missing NEXT_PUBLIC_CONVEX_URL/CONVEX_URL\");\n}\nconst convex = new ConvexHttpClient(convexUrl);\n\nasync function get_localisation_entries_by_convex_language_id(\n  langId: Id<\"languages\">,\n) {\n  return await convex.query(\n    api.localization.getLocalizationWithEnglishFallback,\n    {\n      languageId: langId,\n    },\n  );\n}\n\nasync function get_localisation_entries_by_legacy_language_id(\n  legacyLanguageId: number,\n) {\n  return await convex.query(\n    api.localization.getLocalizationByLegacyLanguageId,\n    {\n      legacyLanguageId,\n    },\n  );\n}\n\nconst get_localisation_dict_by_convex_language_id = async (\n  langId: Id<\"languages\">,\n) => {\n  const rows = await get_localisation_entries_by_convex_language_id(langId);\n  const data: Record<string, string> = {};\n  for (const row of rows) data[row.tag] = row.text;\n  return data;\n};\n\nexport async function get_localisation_by_convex_language_id(\n  langId: Id<\"languages\">,\n) {\n  const data = await get_localisation_dict_by_convex_language_id(langId);\n  return get_localisation_func(data);\n}\n"
  },
  {
    "path": "src/lib/get_localisation_func.tsx",
    "content": "import React from \"react\";\nimport Link from \"next/link\";\n\ntype LocalisationFunc = ReturnType<typeof get_localisation_func>;\n\nexport default function get_localisation_func(data: Record<string, string>) {\n  function apply(\n    tag: string,\n    replacements?: Record<string, string>,\n    links?: string[],\n  ) {\n    let text = data[tag];\n    if (!text) return undefined;\n    if (replacements) text = replaceTags(text, replacements);\n    if (tag.startsWith(\"meta\")) return text;\n    if (links && replacements)\n      return replaceLinks(replaceTags(text, replacements), links);\n    return insetWithNewlines(text);\n  }\n  return apply;\n}\n\nfunction insetWithNewlines(text: string) {\n  let parts = text.split(\"\\n\");\n  let last = parts[parts.length - 1];\n  return (\n    <>\n      {parts.slice(0, parts.length - 1).map((t, i) => (\n        <React.Fragment key={i}>\n          {t}\n          <br />\n        </React.Fragment>\n      ))}\n      {last}\n    </>\n  );\n}\n\nfunction replaceLinks(text: string, links: string[]) {\n  return (\n    <>\n      {text.split(/[{}]/).map((t, i) => (\n        <React.Fragment key={i}>\n          {i % 2 === 0 ? (\n            insetWithNewlines(t)\n          ) : (\n            <Link href={links[(i - 1) / 2]}>{t}</Link>\n          )}\n        </React.Fragment>\n      ))}\n    </>\n  );\n}\n\nfunction replaceTags(text: string, tags: Record<string, string>) {\n  for (let tag in tags) {\n    text = text.replaceAll(tag, tags[tag]);\n  }\n  return text;\n}\n"
  },
  {
    "path": "src/lib/hooks.ts",
    "content": "import React from \"react\";\n\nexport function useInput(\n  def: string | undefined,\n): [string, (e: React.ChangeEvent<HTMLInputElement>) => string] {\n  const [value, setValue] = React.useState(def ?? \"\");\n  function set(e: React.ChangeEvent<HTMLInputElement>): string {\n    let v: string;\n    if (e?.target?.type === \"checkbox\") {\n      v = String(e?.target?.checked);\n    } else {\n      v = e?.target?.value ?? \"\";\n    }\n    setValue(v);\n    return v;\n  }\n  return [value, set];\n}\n"
  },
  {
    "path": "src/lib/is-typing-target.ts",
    "content": "export function isTypingTarget(\n  target: EventTarget | null,\n  options?: { includeButtons?: boolean },\n) {\n  if (!(target instanceof HTMLElement)) return false;\n  const tagName = target.tagName;\n  return (\n    target.isContentEditable ||\n    tagName === \"INPUT\" ||\n    tagName === \"TEXTAREA\" ||\n    tagName === \"SELECT\" ||\n    (options?.includeButtons === true && tagName === \"BUTTON\")\n  );\n}\n"
  },
  {
    "path": "src/lib/lamejs-compat.ts",
    "content": "type LamejsEncoder = {\n  encodeBuffer(left: Int16Array, right?: Int16Array): Int8Array;\n  flush(): Int8Array;\n};\n\ntype LamejsModule = {\n  Mp3Encoder: new (\n    channels: number,\n    sampleRate: number,\n    kbps: number,\n  ) => LamejsEncoder;\n};\n\ntype CjsModuleNamespace = {\n  default?: unknown;\n};\n\ntype LamejsDependencyLoader = readonly [\n  globalName: string,\n  load: () => Promise<unknown>,\n];\n\nconst LAMEJS_GLOBAL_DEPENDENCIES: readonly LamejsDependencyLoader[] = [\n  [\"MPEGMode\", () => import(\"lamejs/src/js/MPEGMode.js\")],\n  [\"Lame\", () => import(\"lamejs/src/js/Lame.js\")],\n  [\"BitStream\", () => import(\"lamejs/src/js/BitStream.js\")],\n  [\"Encoder\", () => import(\"lamejs/src/js/Encoder.js\")],\n  [\"PsyModel\", () => import(\"lamejs/src/js/PsyModel.js\")],\n  [\"Takehiro\", () => import(\"lamejs/src/js/Takehiro.js\")],\n  [\"QuantizePVT\", () => import(\"lamejs/src/js/QuantizePVT.js\")],\n  [\"Reservoir\", () => import(\"lamejs/src/js/Reservoir.js\")],\n  [\"Tables\", () => import(\"lamejs/src/js/Tables.js\")],\n  [\"Version\", () => import(\"lamejs/src/js/Version.js\")],\n  [\"VBRTag\", () => import(\"lamejs/src/js/VBRTag.js\")],\n  [\"GainAnalysis\", () => import(\"lamejs/src/js/GainAnalysis.js\")],\n] as const;\n\nlet lamejsModulePromise: Promise<LamejsModule> | null = null;\n\nfunction unwrapCjsModule(module: unknown) {\n  return (module as CjsModuleNamespace).default ?? module;\n}\n\nasync function exposeLamejsGlobals() {\n  for (const [globalName, loadModule] of LAMEJS_GLOBAL_DEPENDENCIES) {\n    Reflect.set(globalThis, globalName, unwrapCjsModule(await loadModule()));\n  }\n}\n\nexport async function getLamejsModule() {\n  if (!lamejsModulePromise) {\n    lamejsModulePromise = (async () => {\n      try {\n        // The published `lamejs` entrypoint is broken because several internal\n        // modules rely on undeclared globals that only exist in the bundled build.\n        await exposeLamejsGlobals();\n        return unwrapCjsModule(\n          await import(\"lamejs/src/js/index.js\"),\n        ) as LamejsModule;\n      } catch (error) {\n        lamejsModulePromise = null;\n        throw error;\n      }\n    })();\n  }\n\n  return lamejsModulePromise;\n}\n"
  },
  {
    "path": "src/lib/posthog-server.ts",
    "content": "import { PostHog } from \"posthog-node\";\n\nlet posthogClient: PostHog | null = null;\nconst noopPostHogClient = {\n  capture: () => undefined,\n  shutdown: async () => undefined,\n};\n\ntype PostHogLike = Pick<PostHog, \"capture\" | \"shutdown\">;\n\nexport function getPostHogClient(): PostHogLike {\n  const apiKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;\n  if (!apiKey) {\n    return noopPostHogClient;\n  }\n\n  if (!posthogClient) {\n    posthogClient = new PostHog(apiKey, {\n      host: process.env.NEXT_PUBLIC_POSTHOG_HOST,\n      flushAt: 1,\n      flushInterval: 0,\n    });\n  }\n  return posthogClient;\n}\n\nasync function shutdownPostHog() {\n  if (posthogClient) {\n    await posthogClient.shutdown();\n  }\n}\n"
  },
  {
    "path": "src/lib/posthog-user.ts",
    "content": "\"use client\";\n\nimport posthog from \"posthog-js\";\nimport { authClient } from \"@/lib/auth-client\";\n\nexport type PostHogUser = {\n  id?: string;\n  email?: string | null;\n  name?: string | null;\n  username?: string | null;\n  role?: string | null;\n};\n\nexport function identifyPostHogUser(user: PostHogUser | null | undefined) {\n  if (!user?.id) return false;\n\n  const role = typeof user.role === \"string\" ? user.role : undefined;\n  const isAdmin = role === \"admin\";\n  const isContributor = role === \"contributor\" || isAdmin;\n  posthog.identify(user.id, {\n    email: user.email ?? undefined,\n    name: user.name ?? undefined,\n    username: user.username ?? undefined,\n    ...(role !== undefined\n      ? {\n          role,\n          is_admin: isAdmin,\n          is_contributor: isContributor,\n        }\n      : {}),\n  });\n  return true;\n}\n\nexport async function getCurrentPostHogUser() {\n  try {\n    const { data } = await authClient.getSession();\n    const user = (data?.user ?? null) as PostHogUser | null;\n    return user?.id ? user : null;\n  } catch {\n    return null;\n  }\n}\n\nexport function resetPostHogUser() {\n  posthog.reset();\n}\n"
  },
  {
    "path": "src/lib/shuffle.ts",
    "content": "/**\n * Shuffles array in place using seeded random for deterministic results\n */\n\nlet random_seed = 1234;\n\nfunction setSeed(seed: number) {\n  random_seed = seed;\n}\n\nfunction randomSeeded() {\n  random_seed = Math.sin(random_seed) * 10000;\n  return random_seed - Math.floor(random_seed);\n}\n\nexport function shuffle<T>(a: T[]): T[] {\n  for (let i = a.length - 1; i > 0; i--) {\n    const j = Math.floor(randomSeeded() * (i + 1));\n    [a[i], a[j]] = [a[j], a[i]];\n  }\n  return a;\n}\n"
  },
  {
    "path": "src/lib/sound-effects.ts",
    "content": "const SOUND_EFFECT_URLS = {\n  done: \"/sound_done.mp3\",\n  right: \"/sound_right.mp3\",\n  wrong: \"/sound_wrong.mp3\",\n} as const;\n\ntype SoundEffectName = keyof typeof SOUND_EFFECT_URLS;\n\nconst audioCache = new Map<SoundEffectName, HTMLAudioElement>();\n\nexport function playSoundEffect(name: SoundEffectName) {\n  if (typeof window === \"undefined\") return;\n\n  let audio = audioCache.get(name);\n  if (!audio) {\n    audio = new Audio(SOUND_EFFECT_URLS[name]);\n    audio.preload = \"auto\";\n    audioCache.set(name, audio);\n  }\n\n  audio.currentTime = 0;\n  void audio.play().catch(() => {});\n}\n"
  },
  {
    "path": "src/lib/story-preferences.ts",
    "content": "export const HIDE_STORY_QUESTIONS_COOKIE = \"hide_story_questions\";\n\nexport function isStoryQuestionsDisabled(\n  value: string | null | undefined,\n): boolean {\n  return value === \"1\";\n}\n"
  },
  {
    "path": "src/lib/story-search.ts",
    "content": "export type StorySearchable = {\n  name: string;\n  set_id: number;\n  set_index: number;\n  status?: string;\n  public?: boolean;\n};\n\nexport type StorySearchState = \"draft\" | \"feedback\" | \"finished\" | \"published\";\n\nexport type ParsedStorySearch = {\n  setId: number | null;\n  setIndex: number | null;\n  nameQuery: string;\n  statusFilter: StorySearchState | null;\n};\n\ntype StorySearchOptions = {\n  enableStatusFilters?: boolean;\n};\n\nexport function parseStorySearch(\n  searchQuery: string,\n  options?: StorySearchOptions,\n): ParsedStorySearch | null {\n  const trimmedQuery = searchQuery.trim();\n  if (!trimmedQuery) return null;\n\n  const enableStatusFilters = options?.enableStatusFilters === true;\n  const statusTokensRemoved = enableStatusFilters\n    ? splitStatusTokens(trimmedQuery)\n    : { remainingQuery: trimmedQuery, statusFilter: null };\n  const normalizedQuery = statusTokensRemoved.remainingQuery.trim();\n\n  if (!normalizedQuery && statusTokensRemoved.statusFilter !== null) {\n    return {\n      setId: null,\n      setIndex: null,\n      nameQuery: \"\",\n      statusFilter: statusTokensRemoved.statusFilter,\n    };\n  }\n\n  const setPrefixMatch = normalizedQuery.match(/^(?<setId>\\d+)\\s*-\\s*$/);\n\n  if (setPrefixMatch?.groups) {\n    return {\n      setId: Number.parseInt(setPrefixMatch.groups.setId, 10),\n      setIndex: null,\n      nameQuery: \"\",\n      statusFilter: statusTokensRemoved.statusFilter,\n    };\n  }\n\n  const setAndIndexMatch = normalizedQuery.match(\n    /^(?<setId>\\d+)\\s*-\\s*(?<setIndex>\\d+)(?:\\s+(?<nameQuery>.+))?$/,\n  );\n\n  if (setAndIndexMatch?.groups) {\n    return {\n      setId: Number.parseInt(setAndIndexMatch.groups.setId, 10),\n      setIndex: Number.parseInt(setAndIndexMatch.groups.setIndex, 10),\n      nameQuery:\n        setAndIndexMatch.groups.nameQuery?.trim().toLocaleLowerCase() ?? \"\",\n      statusFilter: statusTokensRemoved.statusFilter,\n    };\n  }\n\n  const setAndIndexWithSpaceMatch = normalizedQuery.match(\n    /^(?<setId>\\d+)\\s+(?<setIndex>\\d+)(?:\\s+(?<nameQuery>.+))?$/,\n  );\n\n  if (setAndIndexWithSpaceMatch?.groups) {\n    return {\n      setId: Number.parseInt(setAndIndexWithSpaceMatch.groups.setId, 10),\n      setIndex: Number.parseInt(setAndIndexWithSpaceMatch.groups.setIndex, 10),\n      nameQuery:\n        setAndIndexWithSpaceMatch.groups.nameQuery\n          ?.trim()\n          .toLocaleLowerCase() ?? \"\",\n      statusFilter: statusTokensRemoved.statusFilter,\n    };\n  }\n\n  const setAndNameMatch = normalizedQuery.match(\n    /^(?<setId>\\d+)(?:\\s+(?<nameQuery>.+))$/,\n  );\n\n  if (setAndNameMatch?.groups) {\n    return {\n      setId: Number.parseInt(setAndNameMatch.groups.setId, 10),\n      setIndex: null,\n      nameQuery: setAndNameMatch.groups.nameQuery.trim().toLocaleLowerCase(),\n      statusFilter: statusTokensRemoved.statusFilter,\n    };\n  }\n\n  if (/^\\d+$/.test(normalizedQuery)) {\n    return {\n      setId: Number.parseInt(normalizedQuery, 10),\n      setIndex: null,\n      nameQuery: \"\",\n      statusFilter: statusTokensRemoved.statusFilter,\n    };\n  }\n\n  return {\n    setId: null,\n    setIndex: null,\n    nameQuery: normalizedQuery.toLocaleLowerCase(),\n    statusFilter: statusTokensRemoved.statusFilter,\n  };\n}\n\nexport function matchesStorySearch(\n  story: StorySearchable,\n  searchQuery: ParsedStorySearch | string | null | undefined,\n  options?: StorySearchOptions,\n) {\n  const parsedStorySearch =\n    typeof searchQuery === \"string\"\n      ? parseStorySearch(searchQuery, options)\n      : searchQuery;\n\n  if (parsedStorySearch === null || parsedStorySearch === undefined) {\n    return true;\n  }\n\n  if (parsedStorySearch.statusFilter !== null) {\n    const storyState = getStorySearchState(story);\n    if (storyState !== parsedStorySearch.statusFilter) {\n      return false;\n    }\n  }\n\n  if (\n    parsedStorySearch.setId !== null &&\n    story.set_id !== parsedStorySearch.setId\n  ) {\n    return false;\n  }\n\n  if (\n    parsedStorySearch.setIndex !== null &&\n    story.set_index !== parsedStorySearch.setIndex\n  ) {\n    return false;\n  }\n\n  if (\n    parsedStorySearch.nameQuery &&\n    !story.name.toLocaleLowerCase().includes(parsedStorySearch.nameQuery)\n  ) {\n    return false;\n  }\n\n  return true;\n}\n\nexport function formatStorySetLabel(\n  story: Pick<StorySearchable, \"set_id\" | \"set_index\">,\n) {\n  return `${story.set_id} - ${story.set_index}`;\n}\n\nfunction splitStatusTokens(searchQuery: string): {\n  remainingQuery: string;\n  statusFilter: StorySearchState | null;\n} {\n  let statusFilter: StorySearchState | null = null;\n  const remainingTokens: string[] = [];\n  const tokens = searchQuery.split(/\\s+/).filter(Boolean);\n  const allowBareStatusToken = tokens.length === 1;\n\n  for (const token of tokens) {\n    const statusToken = normalizeStorySearchStateToken(\n      token,\n      allowBareStatusToken,\n    );\n    if (statusToken !== null) {\n      statusFilter = statusToken;\n      continue;\n    }\n    remainingTokens.push(token);\n  }\n\n  return {\n    remainingQuery: remainingTokens.join(\" \"),\n    statusFilter,\n  };\n}\n\nfunction normalizeStorySearchStateToken(\n  token: string,\n  allowBareStatusToken: boolean,\n): StorySearchState | null {\n  const normalizedToken = token.trim().toLocaleLowerCase();\n  const hasStatusPrefix = normalizedToken.startsWith(\"status:\");\n  const hasHashPrefix = normalizedToken.startsWith(\"#\");\n  const hasExplicitPrefix = hasStatusPrefix || hasHashPrefix;\n  const rawStatus = hasStatusPrefix\n    ? normalizedToken.slice(\"status:\".length)\n    : hasHashPrefix\n      ? normalizedToken.slice(1)\n      : normalizedToken;\n\n  if (!rawStatus) return null;\n  if (!hasExplicitPrefix && !allowBareStatusToken) return null;\n\n  if (matchesStatusPrefix(rawStatus, [\"d\", \"dr\", \"dra\", \"draf\", \"draft\"])) {\n    return \"draft\";\n  }\n\n  if (\n    matchesStatusPrefix(rawStatus, [\n      \"f\",\n      \"fe\",\n      \"fee\",\n      \"feed\",\n      \"feedb\",\n      \"feedba\",\n      \"feedbac\",\n      \"feedback\",\n    ])\n  ) {\n    return \"feedback\";\n  }\n\n  if (\n    matchesStatusPrefix(rawStatus, [\n      \"fi\",\n      \"fin\",\n      \"fini\",\n      \"finis\",\n      \"finishe\",\n      \"finished\",\n    ])\n  ) {\n    return \"finished\";\n  }\n\n  if (\n    matchesStatusPrefix(rawStatus, [\n      \"p\",\n      \"pu\",\n      \"pub\",\n      \"publ\",\n      \"publi\",\n      \"publis\",\n      \"publish\",\n      \"published\",\n      \"public\",\n    ])\n  ) {\n    return \"published\";\n  }\n\n  return null;\n}\n\nfunction matchesStatusPrefix(rawStatus: string, acceptedValues: string[]) {\n  return acceptedValues.includes(rawStatus);\n}\n\nfunction getStorySearchState(\n  story: Pick<StorySearchable, \"status\" | \"public\">,\n): StorySearchState | null {\n  if (story.public || story.status === \"published\") return \"published\";\n  if (story.status === \"feedback\") return \"feedback\";\n  if (story.status === \"finished\") return \"finished\";\n  if (story.status === \"draft\") return \"draft\";\n  return null;\n}\n"
  },
  {
    "path": "src/lib/userInterface.ts",
    "content": "import { redirect } from \"next/navigation\";\nimport { fetchAuthQuery, isAuthenticated } from \"@/lib/auth-server\";\nimport { api } from \"@convex/_generated/api\";\nimport type { UserIdentity } from \"convex/server\";\n\ntype AuthUser = {\n  _id: string;\n  userId: string;\n  name?: string;\n  email?: string;\n  image?: string | null;\n  username?: string | null;\n  displayUsername?: string | null;\n  role?: string | null;\n};\n\ntype AppUser = Omit<AuthUser, \"role\" | \"userId\"> & {\n  userId: number;\n  rawRole?: string | null;\n  role: boolean;\n  admin: boolean;\n};\n\nconst toAppUser = (user: AuthUser | null): AppUser | null => {\n  if (!user) return null;\n\n  const parsedUserId = Number.parseInt(user.userId, 10);\n  if (Number.isNaN(parsedUserId)) return null;\n\n  const roleValue = typeof user.role === \"string\" ? user.role : \"\";\n\n  return {\n    ...user,\n    userId: parsedUserId,\n    rawRole: user.role ?? null,\n    role: roleValue === \"contributor\" || roleValue === \"admin\",\n    admin: roleValue === \"admin\",\n  };\n};\n\nexport async function getUser(\n  req?: unknown,\n  response?: unknown,\n): Promise<AppUser | null> {\n  const debugAuth = process.env.DEBUG_AUTH === \"true\";\n  const authed = await isAuthenticated();\n\n  if (debugAuth) {\n    console.log(\"[auth] isAuthenticated:\", authed);\n  }\n\n  if (!authed) return null;\n\n  try {\n    const user = (await fetchAuthQuery(\n      api.auth.getCurrentUser,\n    )) as AuthUser | null;\n    const appUser = toAppUser(user);\n    if (debugAuth) {\n      console.log(\"[auth] getAuthUser result:\", appUser);\n    }\n    return appUser;\n  } catch (error) {\n    if (debugAuth) {\n      console.log(\"[auth] getAuthUser error:\", error);\n    }\n    return null;\n  }\n}\n\nexport async function requireAdmin() {\n  const user = await getUser();\n\n  if (!isAdmin(user)) redirect(\"/auth/admin\");\n}\n\ntype RoleLike =\n  | UserIdentity\n  | AppUser\n  | {\n      role?: unknown;\n      admin?: unknown;\n    }\n  | null;\n\nexport function isAdmin(user: RoleLike) {\n  if (!user) return false;\n  if (typeof (user as AppUser).admin === \"boolean\")\n    return (user as AppUser).admin;\n  return (user as UserIdentity).role === \"admin\";\n}\n\nexport function isContributor(user: RoleLike) {\n  if (!user) return false;\n  if (typeof (user as AppUser).role === \"boolean\")\n    return (user as AppUser).role || (user as AppUser).admin === true;\n  const role = (user as UserIdentity).role;\n  return role === \"contributor\" || role === \"admin\";\n}\n"
  },
  {
    "path": "src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "src/styles/global.css",
    "content": "@import \"tailwindcss\";\n@import \"tw-animate-css\";\n@import \"shadcn/tailwind.css\";\n/*\n---break---*/\n@custom-variant dark (&:is(.dark *));\n\n@layer base {\n  html {\n    height: 100%;\n  }\n\n  body {\n    padding: 0;\n    margin: 0;\n    box-sizing: border-box;\n    min-height: 100%;\n    height: 100%;\n    line-height: 1.6;\n    font-family: var(--font-nunito);\n    color: var(--text-color);\n    background: var(--body-background);\n    font-size: calc(19 / 16 * 1rem);\n  }\n\n  * {\n    box-sizing: border-box;\n  }\n\n  a {\n    color: var(--text-color);\n  }\n\n  a:hover {\n    color: var(--link-hover);\n  }\n\n  h1 {\n    width: 100%;\n    text-align: center;\n  }\n\n  hr {\n    border: 0;\n    border-top: 2px solid var(--overview-hr);\n  }\n}\n\n.duostories_title {\n  width: 160px;\n  display: block;\n  float: left;\n  color: var(--duostories-title);\n  font-weight: bold;\n  font-size: 29px;\n  text-decoration: none;\n}\n\n.autoplay-slider {\n  appearance: none;\n  width: 100%;\n  height: 24px;\n  background: transparent;\n}\n\n.autoplay-slider:focus {\n  outline: none;\n}\n\n.autoplay-slider:disabled {\n  opacity: 0.55;\n}\n\n.autoplay-slider::-webkit-slider-runnable-track {\n  height: 8px;\n  border-radius: 9999px;\n  background: var(--progress-back);\n}\n\n.autoplay-slider::-webkit-slider-thumb {\n  -webkit-appearance: none;\n  appearance: none;\n  width: 20px;\n  height: 20px;\n  margin-top: -6px;\n  border: 3px solid var(--body-background);\n  border-radius: 9999px;\n  background: var(--button-blue-color);\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);\n}\n\n.autoplay-slider::-moz-range-track {\n  height: 8px;\n  border: 0;\n  border-radius: 9999px;\n  background: var(--progress-back);\n}\n\n.autoplay-slider::-moz-range-progress {\n  height: 8px;\n  border-radius: 9999px;\n  background: var(--button-blue-color);\n}\n\n.autoplay-slider::-moz-range-thumb {\n  width: 20px;\n  height: 20px;\n  border: 3px solid var(--body-background);\n  border-radius: 9999px;\n  background: var(--button-blue-color);\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);\n}\n\n.audio-cutter-region-content {\n  position: absolute;\n  top: 6px;\n  left: 8px;\n  display: flex;\n  max-width: calc(100% - 16px);\n  flex-direction: column;\n  align-items: flex-start;\n  gap: 4px;\n  pointer-events: none;\n}\n\n.audio-cutter-region-content__top-row {\n  display: inline-flex;\n  max-width: 100%;\n  align-items: center;\n  gap: 6px;\n}\n\n.audio-cutter-region-content__badge {\n  display: inline-flex;\n  min-width: 22px;\n  height: 22px;\n  flex-shrink: 0;\n  align-items: center;\n  justify-content: center;\n  padding: 0 7px;\n  border-radius: 9999px;\n  background: rgba(15, 95, 131, 0.92);\n  color: #fff;\n  font-size: 11px;\n  font-weight: 700;\n  line-height: 1;\n}\n\n.audio-cutter-region-content__controls {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  pointer-events: auto;\n}\n\n.audio-cutter-region-icon-button {\n  display: inline-flex;\n  width: 22px;\n  height: 22px;\n  align-items: center;\n  justify-content: center;\n  border: 1px solid rgba(255, 255, 255, 0.24);\n  border-radius: 9999px;\n  background: rgba(15, 95, 131, 0.9);\n  color: #fff;\n  cursor: pointer;\n  padding: 0;\n  pointer-events: auto;\n}\n\n.audio-cutter-region-icon-button--danger {\n  background: rgba(179, 59, 59, 0.9);\n}\n\n.audio-cutter-region-icon-button:focus-visible {\n  outline: 2px solid rgba(255, 255, 255, 0.95);\n  outline-offset: 1px;\n}\n\n.audio-cutter-region-content__label,\n.audio-cutter-region-content__join-hint {\n  padding: 2px 8px;\n  border-radius: 9999px;\n  color: #0f5f83;\n  font-size: 11px;\n  line-height: 1.2;\n  pointer-events: none;\n}\n\n.audio-cutter-region-content__label {\n  max-width: 120px;\n  overflow: hidden;\n  background: rgba(255, 255, 255, 0.92);\n  font-weight: 600;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.audio-cutter-region-content__join-hint {\n  background: rgba(255, 255, 255, 0.95);\n  font-weight: 700;\n  text-transform: lowercase;\n}\n\n@keyframes story-checkbutton-false-to-disabled {\n  0%,\n  66% {\n    background: var(--color_false_background);\n    border-color: var(--color_false_border-color);\n    color: var(--color_false_color);\n  }\n\n  to {\n    background: var(--color_disabled_background);\n    border-color: var(--color_disabled_border-color);\n    color: var(--color_disabled_color);\n  }\n}\n\n@keyframes story-footer-banner-slide {\n  0% {\n    transform: translateY(100%);\n  }\n\n  to {\n    transform: translateY(0);\n  }\n}\n\n@keyframes story-footer-check-pop {\n  0% {\n    opacity: 0;\n    transform: scale3d(0.7, 0.7, 1);\n  }\n\n  33% {\n    opacity: 1;\n    transform: scale3d(1.1, 1.1, 1);\n  }\n\n  66% {\n    transform: scale3d(0.9, 0.9, 1);\n  }\n\n  to {\n    transform: scaleX(1);\n  }\n}\n\n@keyframes story-finished-fade-in {\n  0% {\n    opacity: 0;\n  }\n\n  to {\n    opacity: 1;\n  }\n}\n\n@keyframes story-finished-star {\n  0% {\n    opacity: 0.5;\n    transform: rotate(-45deg) scale(1);\n  }\n\n  25% {\n    opacity: 0;\n    transform: rotate(-45deg) scale(0.5);\n  }\n\n  50% {\n    opacity: 0.5;\n    transform: rotate(-45deg) scale(1);\n  }\n\n  75% {\n    opacity: 0;\n    transform: rotate(-45deg) scale(0.5);\n  }\n\n  to {\n    opacity: 0.5;\n    transform: rotate(-45deg) scale(1);\n  }\n}\n\n@keyframes story-wordbutton-wrong {\n  0%,\n  to {\n    background-color: var(--color_base_border);\n    color: #4b4b4b;\n    transform: translateX(0);\n  }\n\n  12.5%,\n  37.5%,\n  62.5%,\n  87.5% {\n    background: var(--color_false_border-color);\n    color: var(--color_false_color);\n    transform: translateX(-4px);\n  }\n\n  25%,\n  50%,\n  75% {\n    background: var(--color_false_border-color);\n    color: var(--color_false_color);\n    transform: translateX(4px);\n  }\n}\n\n@keyframes story-wordbutton-wrong-inner {\n  0%,\n  to {\n    background-color: transparent;\n    color: #4b4b4b;\n  }\n\n  12.5%,\n  25%,\n  37.5%,\n  50%,\n  62.5%,\n  75%,\n  87.5% {\n    background: var(--color_false_background);\n    border-color: var(--color_false_border-color);\n    color: var(--color_false_color);\n  }\n}\n\n@keyframes story-wordbutton-right-to-disabled {\n  0%,\n  66% {\n    background-color: var(--color_right_border-color);\n    color: var(--color_right_color);\n  }\n\n  to {\n    background-color: var(--color_disabled_border-color);\n    color: var(--color_disabled_color);\n  }\n}\n\n@keyframes story-wordbutton-right-to-disabled-inner {\n  0%,\n  66% {\n    background-color: var(--color_right_background);\n    border-color: var(--color_right_border-color);\n    transform: translateY(-2px);\n  }\n\n  to {\n    background-color: var(--color_disabled_background);\n    border-color: var(--color_disabled_border-color);\n    transform: translateY(0);\n  }\n}\n\n@keyframes story-wordbutton-false-to-disabled {\n  0%,\n  66% {\n    background: var(--color_false_border-color);\n    color: var(--color_false_color);\n  }\n\n  to {\n    background: var(--color_disabled_border-color);\n    color: var(--color_disabled_color);\n  }\n}\n\n@keyframes story-wordbutton-false-to-disabled-inner {\n  0%,\n  66% {\n    background: var(--color_false_background);\n    border-color: var(--color_false_border-color);\n    transform: translateY(-2px);\n  }\n\n  to {\n    background: var(--color_disabled_background);\n    border-color: var(--color_disabled_border-color);\n    transform: translateY(0);\n  }\n}\n/*\noutline-width: 2px;\n  outline-offset: 2px;\n  outline-style: dotted;\n  outline-color: black;\nbutton:focus {\n  outline: none !important;\n}\na:focus {\n  outline: none !important;\n}*/\n\nbody,\nbody[data-theme=\"light\"] {\n  --title-color-dim: #777;\n\n  --header-border: lightgrey;\n\n  --overview-hr: #e5e5e5;\n\n  --duostories-title: #78c800;\n\n  --text-color: black;\n  --text-color-dim: #4c4c4c;\n  --link-hover: #78c800;\n  --body-background: white;\n\n  --button-background: #58cc02;\n  --button-color: white;\n  --button-border: #58a700;\n\n  --button-inactive-background: #e5e5e5;\n  --button-inactive-color: #afafaf;\n\n  --button-blue-background: #1cb0f6;\n  --button-blue-border: #1899d6;\n  --button-blue-color: var(--button-color);\n  --button-side-border: 0px;\n\n  --language-selector-hover-background: #ddf4ff;\n\n  --input-background: #f7f7f7;\n  --input-border: #e5e5e5;\n\n  --error-red: #ea2b2b;\n  --link-blue: #1cb0f6;\n\n  --profile-background: #1cb0f6;\n  --profile-text: white;\n\n  --story-button-gold: rgb(255, 177, 0);\n\n  --color_base_border: #e5e5e5;\n  --color_base_background: #fff;\n  --color_base_color: black;\n\n  --color_right_background: #d7feb9;\n  --color_right_border-color: #ade756;\n  --color_right_color: #7da13c;\n\n  --color_false_background: #feb9b9;\n  --color_false_border-color: #e7565b;\n  --color_false_color: #a13c3c;\n\n  --color_selected_background: #ddf4ff;\n  --color_selected_border-color: #84d8ff;\n  --color_selected_color: #1cb0f6;\n\n  --color_disabled_background: var(--color_base_background);\n  --color_disabled_border-color: #e5e5e5;\n  --color_disabled_color: #cfcfcf;\n\n  --editor-selection-background: #f3f9ff;\n  --editor-selection-border: #a4d1ff;\n  --editor-ssml: lightgray;\n\n  --footer-right-background: #d7ffb8;\n  --footer-right-color: #58a700;\n  --footer-icon-backgroud: white;\n\n  --progress-back: #e5e5e5;\n  --progress-inside: #78c800;\n  --progress-highlight: #fff;\n\n  --tooltip-backgroud: #f7f7f7;\n  --tooltip-border: #e5e5e5;\n  --tooltip-color: #4c4c4c;\n\n  --underline-dashed: #e5e5e5;\n  --underline-solid: #7b7b7b;\n  --underline-dashed-highlight: #7b7b7b;\n\n  --finished-star-gold: #ffc800;\n\n  --body-background-faint: #f2f2f2;\n\n  --svg-fill: #fbfbfb;\n  --svg-stroke: #eee;\n\n  --editor-hints-background: whitesmoke;\n\n  --background: var(--body-background);\n  --foreground: var(--text-color);\n  --card: var(--body-background);\n  --card-foreground: var(--text-color);\n  --popover: var(--body-background);\n  --popover-foreground: var(--text-color);\n  --primary: var(--button-background);\n  --primary-foreground: var(--button-color);\n  --secondary: var(--body-background-faint);\n  --secondary-foreground: var(--text-color);\n  --muted: var(--body-background-faint);\n  --muted-foreground: var(--text-color-dim);\n  --accent: var(--language-selector-hover-background);\n  --accent-foreground: var(--text-color);\n  --border: var(--header-border);\n  --input: var(--input-border);\n  /* Focus rings track the blue CTA styling in light mode. */\n  --ring: var(--button-blue-background);\n}\n\nbody[data-theme=\"dark\"] .ͼ2 .cm-gutters {\n  border-right: 1px solid #253b41;\n  background-color: #334046;\n}\n\nbody[data-theme=\"dark\"] .ͼ2 .cm-activeLineGutter {\n  background-color: #44575d;\n}\n\nbody[data-theme=\"dark\"] .ͼq {\n  color: #00ff3c;\n}\n\nbody[data-theme=\"dark\"] .ͼo {\n  color: #00ff3c;\n  opacity: 0.7;\n}\n\nbody[data-theme=\"dark\"] .ͼs {\n  color: #00ff3c;\n  opacity: 0.6;\n  border-bottom: 2px solid white;\n}\n\nbody[data-theme=\"dark\"] .ͼx {\n  color: #007b1d;\n  border-bottom: 2px solid white;\n  background: #c8c8c8;\n  border-radius: 10px;\n  opacity: 0.6;\n}\n\nbody[data-theme=\"dark\"] .ͼu {\n  opacity: 0.6;\n  border-bottom: 2px solid white;\n}\n\nbody[data-theme=\"dark\"] .ͼr {\n  color: #015cff;\n}\n\nbody[data-theme=\"dark\"] .ͼp {\n  color: #015cff;\n  opacity: 0.7;\n}\n\nbody[data-theme=\"dark\"] .ͼt {\n  color: #005bff;\n  opacity: 0.6;\n  border-bottom: 2px solid white;\n}\n\nbody[data-theme=\"dark\"] .ͼy {\n  color: #005cff;\n  border-bottom: 2px solid white;\n  background: #c1c1c1;\n  border-radius: 10px;\n  opacity: 0.6;\n}\n\nbody[data-theme=\"dark\"] {\n  --header-border: #37464f;\n\n  --overview-hr: #3c474b;\n\n  --text-color: #f1f7fb;\n  --text-color-dim: #f1f7fb;\n  --body-background: #131f22;\n\n  /* Keep the Duostories primary action green in dark mode. */\n  --button-background: #58cc02;\n\n  --language-selector-hover-background: #626b70;\n\n  --button-color: #131f22;\n\n  --input-background: #202f36;\n  --input-border: #37464f;\n\n  --error-red: #d84848;\n\n  --button-blue-background: #131f22;\n  --button-blue-border: #37464f;\n  --button-blue-color: #49c0f8;\n  --button-side-border: 2px;\n\n  --color_base_border: #37464f;\n  --color_base_background: #131f22;\n  --color_base_color: #f1f7fb;\n\n  --color_right_background: #202f36;\n  --color_right_border-color: #5f8428;\n  --color_right_color: #58cc02;\n\n  --color_false_background: #202f36;\n  --color_false_border-color: #842828;\n  --color_false_color: #cc0202;\n\n  --color_selected_background: #202f36;\n  --color_selected_border-color: #3f85a7;\n  --color_selected_color: #49c0f8;\n\n  --color_disabled_background: var(--color_base_background);\n  --color_disabled_border-color: #37464f;\n  --color_disabled_color: #37464f;\n\n  --progress-back: #37464f;\n  --footer-right-background: #37464f;\n  --footer-right-color: #79b933;\n\n  --editor-selection-background: #202f36;\n  --editor-selection-border: #3f85a7;\n  --editor-ssml: #555;\n\n  --progress-inside: #93d333;\n  --progress-highlight: #a9dc5c;\n\n  --tooltip-backgroud: #37464f;\n  --tooltip-border: #37464f;\n  --tooltip-color: #f1f7fb;\n\n  --underline-solid: #f1f7fb;\n  --underline-dashed: #37464f;\n  --underline-dashed-highlight: #f1f7fb;\n\n  --body-background-faint: #202f36;\n\n  --svg-fill: #202f36;\n  --svg-stroke: #2c464d;\n\n  --editor-hints-background: #313131;\n\n  --finished-star-gold: #ffc800;\n\n  --background: var(--body-background);\n  --foreground: var(--text-color);\n  --card: var(--body-background);\n  --card-foreground: var(--text-color);\n  --popover: var(--body-background);\n  --popover-foreground: var(--text-color);\n  --primary: var(--button-background);\n  --primary-foreground: var(--button-color);\n  --secondary: var(--body-background-faint);\n  --secondary-foreground: var(--text-color);\n  --muted: var(--body-background-faint);\n  --muted-foreground: var(--text-color-dim);\n  --accent: var(--language-selector-hover-background);\n  --accent-foreground: var(--text-color);\n  --border: var(--header-border);\n  --input: var(--input-border);\n  /* In dark mode, use the blue foreground token so focus rings stay visible. */\n  --ring: var(--button-blue-color);\n}\n\n.cm-editor {\n  height: 100%;\n}\n\n/*\n  6. Remove built-in form typography styles\n*/\ninput,\nbutton,\ntextarea,\nselect {\n  font-family: inherit;\n  font-size: inherit;\n}\n\n@keyframes spinnerFade1 {\n  0% {\n    opacity: 1;\n  }\n  33.33333% {\n    opacity: 0.25;\n  }\n  66.66667% {\n    opacity: 0.5;\n  }\n  to {\n    opacity: 1;\n  }\n}\n\n@keyframes spinnerFade2 {\n  0% {\n    opacity: 0.5;\n  }\n  33.33333% {\n    opacity: 1;\n  }\n  66.66667% {\n    opacity: 0.25;\n  }\n  to {\n    opacity: 0.5;\n  }\n}\n\n@keyframes spinnerFade3 {\n  0% {\n    opacity: 0.25;\n  }\n  33.33333% {\n    opacity: 0.5;\n  }\n  66.66667% {\n    opacity: 1;\n  }\n  to {\n    opacity: 0.25;\n  }\n}\n\n@keyframes placeholderShimmer {\n  0% {\n    background-position: -800px 0;\n  }\n  100% {\n    background-position: 800px 0;\n  }\n}\n\n/* language specifics */\n\n.en {\n  direction: ltr;\n  font-family: var(--font-nunito);\n}\n\n@font-face {\n  font-family: linja-pona;\n  src: url(\"/linjalipamanka-normal.otf\");\n  font-weight: normal;\n  font-style: normal;\n}\n\n.tok2 {\n  font-family: linja-pona !important;\n  font-feature-settings:\n    \"liga\" 1,\n    \"clig\" 1,\n    \"calt\" 1,\n    \"kern\" 1,\n    \"mark\" 1;\n  text-rendering: optimizeLegibility;\n  font-size: 1.5em;\n}\n\n/*\n---break---*/\n\n@theme inline {\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --radius-2xl: calc(var(--radius) + 8px);\n  --radius-3xl: calc(var(--radius) + 12px);\n  --radius-4xl: calc(var(--radius) + 16px);\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n}\n\n/*\n---break---*/\n\n:root {\n  --radius: 0.625rem;\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.129 0.042 264.695);\n  --card: oklch(1 0 0);\n  --card-foreground: oklch(0.129 0.042 264.695);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.129 0.042 264.695);\n  --primary: oklch(0.208 0.042 265.755);\n  --primary-foreground: oklch(0.984 0.003 247.858);\n  --secondary: oklch(0.968 0.007 247.896);\n  --secondary-foreground: oklch(0.208 0.042 265.755);\n  --muted: oklch(0.968 0.007 247.896);\n  --muted-foreground: oklch(0.554 0.046 257.417);\n  --accent: oklch(0.968 0.007 247.896);\n  --accent-foreground: oklch(0.208 0.042 265.755);\n  --destructive: oklch(0.577 0.245 27.325);\n  --border: oklch(0.929 0.013 255.508);\n  --input: oklch(0.929 0.013 255.508);\n  --ring: oklch(0.704 0.04 256.788);\n  --chart-1: oklch(0.646 0.222 41.116);\n  --chart-2: oklch(0.6 0.118 184.704);\n  --chart-3: oklch(0.398 0.07 227.392);\n  --chart-4: oklch(0.828 0.189 84.429);\n  --chart-5: oklch(0.769 0.188 70.08);\n  --sidebar: oklch(0.984 0.003 247.858);\n  --sidebar-foreground: oklch(0.129 0.042 264.695);\n  --sidebar-primary: oklch(0.208 0.042 265.755);\n  --sidebar-primary-foreground: oklch(0.984 0.003 247.858);\n  --sidebar-accent: oklch(0.968 0.007 247.896);\n  --sidebar-accent-foreground: oklch(0.208 0.042 265.755);\n  --sidebar-border: oklch(0.929 0.013 255.508);\n  --sidebar-ring: oklch(0.704 0.04 256.788);\n}\n\n/*\n---break---*/\n\n.dark {\n  --background: oklch(0.129 0.042 264.695);\n  --foreground: oklch(0.984 0.003 247.858);\n  --card: oklch(0.208 0.042 265.755);\n  --card-foreground: oklch(0.984 0.003 247.858);\n  --popover: oklch(0.208 0.042 265.755);\n  --popover-foreground: oklch(0.984 0.003 247.858);\n  --primary: oklch(0.929 0.013 255.508);\n  --primary-foreground: oklch(0.208 0.042 265.755);\n  --secondary: oklch(0.279 0.041 260.031);\n  --secondary-foreground: oklch(0.984 0.003 247.858);\n  --muted: oklch(0.279 0.041 260.031);\n  --muted-foreground: oklch(0.704 0.04 256.788);\n  --accent: oklch(0.279 0.041 260.031);\n  --accent-foreground: oklch(0.984 0.003 247.858);\n  --destructive: oklch(0.704 0.191 22.216);\n  --border: oklch(1 0 0 / 10%);\n  --input: oklch(1 0 0 / 15%);\n  --ring: oklch(0.551 0.027 264.364);\n  --chart-1: oklch(0.488 0.243 264.376);\n  --chart-2: oklch(0.696 0.17 162.48);\n  --chart-3: oklch(0.769 0.188 70.08);\n  --chart-4: oklch(0.627 0.265 303.9);\n  --chart-5: oklch(0.645 0.246 16.439);\n  --sidebar: oklch(0.208 0.042 265.755);\n  --sidebar-foreground: oklch(0.984 0.003 247.858);\n  --sidebar-primary: oklch(0.488 0.243 264.376);\n  --sidebar-primary-foreground: oklch(0.984 0.003 247.858);\n  --sidebar-accent: oklch(0.279 0.041 260.031);\n  --sidebar-accent-foreground: oklch(0.984 0.003 247.858);\n  --sidebar-border: oklch(1 0 0 / 10%);\n  --sidebar-ring: oklch(0.551 0.027 264.364);\n}\n\n/*\n---break---*/\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n    @apply bg-background text-foreground;\n  }\n}\n"
  },
  {
    "path": "src/types/lamejs.d.ts",
    "content": "declare module \"lamejs\" {\n  export class Mp3Encoder {\n    constructor(channels: number, sampleRate: number, kbps: number);\n    encodeBuffer(left: Int16Array, right?: Int16Array): Int8Array;\n    flush(): Int8Array;\n  }\n}\n\ndeclare module \"lamejs/src/js/*.js\" {\n  const value: unknown;\n  export = value;\n}\n"
  },
  {
    "path": "src/types/react-dom.d.ts",
    "content": "declare module \"react-dom\" {\n  import type * as React from \"react\";\n\n  export function createPortal(\n    children: React.ReactNode,\n    container: Element | DocumentFragment,\n    key?: null | string,\n  ): React.ReactPortal;\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"paths\": {\n      \"@/*\": [\n        \"./src/*\"\n      ],\n      \"@convex/*\": [\n        \"./convex/*\"\n      ]\n    },\n    \"target\": \"es2022\",\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"react-jsx\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ]\n  },\n  \"include\": [\n    \"process.d.ts\",\n    \"next-env.d.ts\",\n    \"next-auth.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \".next/types/**/*.ts\",\n    \".next/dev/types/**/*.ts\"\n  ],\n  \"exclude\": [\n    \"node_modules\",\n    \"**/cypress/**/*.ts\",\n    \".next/dev/types/**/*.ts\"\n  ]\n}\n"
  }
]