Initial commit: Learning Lab Express

Includes fixes found while reviewing the app:
- Results: open each result by a type+agent+id key, so a video and a
  material draft with the same id no longer open each other's popup
- AuthGate: clear the query cache during render, before pages build their
  queries, and remount pages on organization switch (pages no longer stay
  on skeletons for up to a minute after sign-in or refresh)
- Instruction: keep only failed rules after a partial approve, so retrying
  does not create the successful schedules twice; drop the monthly note
  once count or frequency is edited
- Parser/schedules: never schedule a count below one
- Audio: ignore the end event of a cancelled utterance when changing speed
- Downloads: revoke the object URL later so Safari/Firefox can read it
- Results: refetch SCORM content when any part's revision changes; show a
  failed video as failed instead of "still rendering"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-23 10:42:12 +07:00
commit f219b26f71
41 changed files with 8172 additions and 0 deletions

717
src/i18n.ts Normal file
View File

@@ -0,0 +1,717 @@
// UI language: English by default, Indonesian on request. A module-level store so API helpers
// and the instruction parser can translate too, not just components.
import { useSyncExternalStore } from 'react'
export type Lang = 'en' | 'id'
export const LANGS: { code: Lang; label: string }[] = [{ code: 'en', label: 'EN' }, { code: 'id', label: 'ID' }]
const en = {
// Layout
'nav.brain': 'Brain',
'nav.instruction': 'Instruction',
'nav.schedule': 'Schedule',
'nav.results': 'Results',
'nav.workspace': 'WORKSPACE',
'layout.learningSpace': 'Learning space',
'layout.breadcrumb': 'Workspace',
'layout.signOut': 'Sign out',
'layout.engine': 'learning-lab engine',
'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.',
'layout.footer': 'A workspace for knowledge that keeps growing.',
'layout.mainNav': 'Main navigation',
'layout.language': 'Language',
// Auth
'auth.loading': 'Loading…',
'auth.eyebrow': 'NEW LEARNING, WITHOUT STARTING FROM SCRATCH',
'auth.headline1': 'Team knowledge.',
'auth.headline2': 'New ideas.',
'auth.headline3': 'Learning that never stops.',
'auth.copy': 'Upload material to the Brain, give one instruction, and AI keeps creating new learning content on schedule.',
'auth.benefitUpload': 'Upload material to the Brain',
'auth.benefitSchedule': 'Schedule research and content',
'auth.benefitDownload': 'Open and download the results',
'auth.footer': 'Your knowledge. New possibilities.',
'auth.pickOrg': 'Choose an organization',
'auth.pickOrgCopy': 'Express Mode runs inside an organization so the Brain, schedules, and credits are shared with your team.',
// Shared
'common.loading': 'Loading…',
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.retry': 'Try again',
'common.credits': '{n} credits',
// Brain
'brain.title': 'Brain',
'brain.description': 'All team material is processed into one Brain that keeps growing. New content the AI creates goes in here too.',
'brain.metricMaterial': 'Material',
'brain.metricMaterialSub': 'documents in the workspace',
'brain.metricReady': 'In the Brain',
'brain.metricReadySub': 'knowledge base ready',
'brain.metricAction': 'Needs action',
'brain.metricActionSub': 'waiting for your approval',
'brain.listTitle': 'Material',
'brain.loading': 'Loading material…',
'brain.engineDown': 'Cannot reach the engine',
'brain.empty': 'No material yet',
'brain.emptyCopy': 'Upload documents to start filling the Brain.',
'brain.uploadTitle': 'Upload material to the Brain',
'brain.uploadCopy': 'Drag files anywhere on this page, or choose them from your computer. The engine reads them and adds them to the Brain.',
'brain.formats': 'Formats: {list}',
'brain.formatsLoading': 'Loading supported formats…',
'brain.chooseFiles': 'Choose files',
'brain.uploading': 'Uploading…',
'brain.uploaded': 'Uploaded',
'brain.unsupported': 'Unsupported format',
'brain.dropHere': 'Drop files to upload them to the Brain',
'brain.statusReady': 'In the Brain',
'brain.statusBuilding': 'Building Brain',
'brain.statusProcessing': 'Processing',
'brain.statusAnswer': 'Needs action',
'brain.statusFailed': 'Failed',
'brain.queued': 'Waiting in queue…',
'brain.versionWithCandidate': 'Looks like “{name}”. Is this a new version of it?',
'brain.versionGeneric': 'Is this a new version of an existing document?',
'brain.versionYes': 'Yes, new version',
'brain.versionNo': 'No, keep separate',
'brain.needsCredits': 'Not enough credits to add this to the Brain{need}. Top up, then try again.',
'brain.needsCreditsNeed': ' (needs {credits})',
'brain.failed': 'Could not add this to the Brain.',
'brain.retryWith': 'Try again ({credits})',
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
'brain.confirmCreditsFallback': 'credits',
'brain.addToBrain': 'Add to Brain',
'brain.delete': 'Delete {name}',
'brain.deleteConfirm': 'Delete “{name}” from the Brain?',
// Instruction
'ins.title': 'Instruction',
'ins.description': 'Write a plain instruction. The AI turns it into research and content schedules that keep running until you stop them.',
'ins.cardTitle': 'What should be created?',
'ins.label': 'Instruction',
'ins.placeholder': 'Example: make 3 quizzes per week and 2 summaries per day about leadership',
'ins.hint': 'Name the content type, how many, how often (per day / per week), and the topic. Internet research can be scheduled too.',
'ins.compose': 'Build schedule',
'ins.ex1': 'Make 2 summaries per day and 1 quiz per week about leadership',
'ins.ex2': 'Research the internet every day about leadership',
'ins.ex3': '3 roleplays per week about negotiating with customers',
'ins.ex4': 'Research every Monday about sales trends, then make 5 flashcards per week',
'ins.ex5': '1 video per week, 2 podcasts per week and 1 SCORM module per week about onboarding',
'ins.previewTitle': 'Schedules to create',
'ins.readyCount': '{n} ready',
'ins.notUnderstood': 'Instruction not understood',
'ins.notUnderstoodCopy': 'Try naming the content type and how often, e.g. “2 summaries per day”.',
'ins.skipped': 'Not understood and skipped: {parts}',
'ins.approveNote': 'Once approved, schedules start right away. Content is created from the Brain without repeating existing content, using workspace credits within the limits above.',
'ins.approve': 'Approve {n} schedules',
'ins.approving': 'Creating schedules…',
'ins.active': 'Active',
'ins.failed': 'Failed',
'ins.viewSchedule': 'View in Schedule',
'ins.kind': 'Content type',
'ins.count': 'Count',
'ins.frequency': 'Frequency',
'ins.perDay': 'per day',
'ins.perWeek': 'per week',
'ins.researchFrequency': 'Research frequency',
'ins.everyDay': 'Every day',
'ins.everyWeek': 'Every week',
'ins.weekday': 'Day',
'ins.topic': 'Topic',
'ins.topicPlaceholder': 'Topic (empty = whole Brain)',
'ins.autoApprove': 'New sources go straight into the Brain (no review first)',
'ins.notCreated': 'This schedule will not be created yet.',
'ins.tooMany': 'At most 50 items per week for one schedule.',
'ins.budget': 'Credit limit: {first}, {monthly} per month.',
'ins.budgetWeekly': '{n} per week',
'ins.budgetRun': '{n} per research run',
'ins.removeRule': 'Remove from list',
// Rules (parser output)
'rule.everyDay': 'Every day',
'rule.everyWeekday': 'Every {day}',
'rule.research': '{when}: internet research{about}',
'rule.about': ' about {topic}',
'rule.wholeBrain': ' from the whole Brain',
'rule.content': '{kind}: {count} {per}{about}',
'rule.monthNote': '{count} per month is scheduled as {weekly} per week.',
'kind.summary': 'Summary',
'kind.quiz': 'Quiz',
'kind.flashcards': 'Flashcards',
'kind.roleplay': 'Roleplay',
'kind.simulator': 'Simulation',
'kind.slide_deck': 'Slides',
'kind.video': 'Video',
'kind.audio': 'Audio',
'kind.scorm': 'SCORM',
'day.0': 'Monday', 'day.1': 'Tuesday', 'day.2': 'Wednesday', 'day.3': 'Thursday', 'day.4': 'Friday', 'day.5': 'Saturday', 'day.6': 'Sunday',
// Schedule
'sch.title': 'Schedule',
'sch.description': 'Research and content schedules that are running. Change the topic any time; the next content follows the new topic.',
'sch.newInstruction': 'New instruction',
'sch.cardTitle': 'Schedules',
'sch.loading': 'Loading schedules…',
'sch.loadFailed': 'Could not load schedules',
'sch.empty': 'No schedules yet',
'sch.emptyCopy': 'Give an instruction like “2 summaries per day about leadership” to start.',
'sch.nextResearch': 'Next research: {when}',
'sch.paused': 'Paused',
'sch.active': 'Active',
'sch.newTopic': 'New topic',
'sch.changeTopic': 'Change topic',
'sch.pause': 'Pause',
'sch.resume': 'Resume',
'sch.runNow': 'Run now',
'sch.history': 'History',
'sch.hideHistory': 'Hide history',
'sch.delete': 'Delete',
'sch.deleteConfirm': 'Delete the schedule “{name}”? Content already created stays.',
'sch.loadingProgress': 'Loading progress…',
'sch.planningOff': 'Automatic production is off in the engine: an admin needs to set a price for “agent.content.plan”.',
'sch.produceNow': 'Produce now',
'prod.runningTitle': 'Producing {n} new pieces',
'prod.lastTitle': 'Last production',
'prod.count': '{ready} of {total} ready',
'prod.stepTopics': 'AI chooses new topics',
'prod.stepStart': 'Starting',
'prod.stepCreate': 'Creating content ({ready}/{total})',
'prod.stepDone': 'Done',
'prod.failed': 'Production failed ({code}).',
'prod.someFailed': '{n} failed.',
'prod.shortfall': 'The Brain had new material for fewer pieces than asked ({n} short); add documents for more topics.',
'prod.viewResults': 'View in Results',
'sch.produceHowMany': 'How many pieces now? (up to {max} left this week)',
'sch.checkPrice': 'Check price',
'sch.checkingPrice': 'Checking…',
'sch.produceQuote': 'Planning {n} pieces costs {credits} credits. Each piece is then charged as it is created, within this schedule’s weekly limit of {budget} credits. Production starts within about a minute.',
'sch.startProduction': 'Start production',
'sch.starting': 'Starting…',
'sch.alreadyProducing': 'This schedule is already producing. Wait for it to finish first.',
'sch.weekFull': 'This week’s target is already used up. The next pieces come with next week’s schedule.',
'sch.thisWeek': 'This week: {ready} of {target} ready{failed}',
'sch.thisWeekFailed': ', {n} failed',
'sch.waitingFirst': 'Waiting for the first production slot',
'sch.loadingHistory': 'Loading history…',
'sch.neverRan': 'Has not run yet.',
'sch.scheduled': 'Scheduled',
'sch.manual': 'Manual',
'run.queued': 'Queued', 'run.planning': 'Planning', 'run.running': 'Running', 'run.completed': 'Completed', 'run.failed': 'Failed', 'run.cancelled': 'Cancelled',
// Results
'res.title': 'Results',
'res.description': 'Everything Express creates: text, quizzes, roleplays, video, audio, and SCORM.',
'res.empty': 'No results yet',
'res.emptyCopy': 'Content and sources from your schedules appear here. Give an instruction to start a schedule.',
'res.tabContent': 'Content',
'res.tabResearch': 'Research',
'res.pendingCount': '{n} to review',
'res.allContent': 'All content',
'res.foundSources': 'Sources found by research',
'res.search': 'Search titles…',
'res.filterKind': 'Filter by type',
'res.filterStatus': 'Filter by status',
'res.allKinds': 'All types',
'res.status.all': 'All statuses',
'res.status.review': 'Needs review',
'res.status.approved': 'Approved',
'res.status.working': 'In progress',
'res.status.failed': 'Failed or rejected',
'res.state.preparing': 'Preparing',
'res.state.generating': 'Generating',
'res.state.ready': 'Needs review',
'res.state.accepted': 'Approved',
'res.state.failed': 'Failed',
'res.state.rejected': 'Rejected',
'res.source.pending': 'Needs review',
'res.source.accepted': 'Added to Library',
'res.source.rejected': 'Rejected',
'res.loadFailed': 'Results could not be loaded',
'res.noMatch': 'Nothing matches',
'res.noMatchCopy': 'Try another keyword, type, or status.',
'res.noteContent': 'Download as PDF, Markdown or SCORM; slide decks also as PowerPoint, videos as MP4, audio scripts as text. Roleplay and simulation run interactively in Learning Studio.',
'res.noteResearch': 'Research finds web sources. Add the useful ones to the Library, then build their knowledge on the Brain page.',
'res.sourceCount': '{n} sources',
'res.sourceOne': '1 source',
'res.working': 'Being created…',
'res.open': 'Open',
'res.close': 'Close',
'res.reviewNotice': 'AI-generated draft. Check it, then approve it or reject it.',
'res.noContent': 'No content to show',
'res.noContentCopy': 'This item has not finished or its output is no longer available.',
'res.info': 'Content details',
'res.statusLabel': 'Status',
'res.schedule': 'Schedule',
'res.created': 'Created',
'res.sources': 'Sources',
'res.footNote': 'Created by Express from your Brain.',
'res.openStudio': 'Open in Learning Studio',
'res.download': 'Download',
'res.preparingFile': 'Preparing file…',
'res.pdfHint': 'Opens the print dialog: choose “Save as PDF”',
'res.pptxHint': 'Slides rendered by Learning Studio',
'res.mdHint': 'Plain text with headings, for editing',
'res.accept': 'Approve',
'res.reject': 'Reject',
'res.addToBrain': 'Add to Library',
'res.inBrain': 'see Brain',
'res.answer': 'Answer',
'res.slide': 'Slide {n}',
'res.field.description': 'Description',
'res.field.topic': 'Topic',
'res.field.objective': 'Objective',
'res.field.learning_objectives': 'Learning objectives',
'res.field.user_role': 'Learner role',
'res.field.ai_character': 'AI character',
'res.field.difficulty': 'Difficulty',
'res.field.context': 'Context',
// Media and SCORM (Phases 4, 5, 7)
'ins.noteVideo': 'One video per run, about {seconds} seconds in {scenes} scenes, made by Video Studio. Each video waits for your review in Results.',
'ins.noteAudio': 'Written as a narration script. Results plays it with the device voice and downloads it as text (no MP3 yet: the engine has no audio service for agents).',
'ins.noteScorm': 'Each module is a lesson with a 10-question quiz, downloaded from Results as a SCORM 1.2 package for your LMS.',
'ins.videoCadence': 'A video schedule makes one video per run: choose 1 per day or 1 per week.',
'ins.videoQuoteFailed': 'The video price could not be checked: {message}',
'ins.budgetVideo': '{n} per video',
'sch.nextVideo': 'Next video: {when}',
'sch.videoNow': 'Make a video now',
'sch.videoStarted': 'Video started. It shows in Results while it is being made.',
'sch.runStarted': 'Research started.',
'sch.deleted': 'Schedule deleted.',
'res.videoProgress': 'Rendering… {n}%',
'res.videoNotReady': 'The video is not ready yet',
'res.videoFailed': 'The video could not be made',
'res.videoFailedCopy': 'Rendering stopped before the video was finished.',
'res.downloadVideo': 'Download video (MP4)',
'res.creditsUsed': 'Credits used',
'res.accepted': 'Approved.',
'res.rejected': 'Rejected.',
'res.audioNotice': 'Played with this device’s voice, which can differ between devices. Download the script to record it or use it in a TTS tool.',
'res.fullScript': 'Show the full script',
'res.scriptTxt': 'Script (.txt)',
'res.scriptHint': 'Narration text, for recording or a TTS tool',
'res.quiz': 'Quiz',
'res.quizPending': 'The quiz is not ready yet.',
'res.scormModule': 'SCORM 1.2 module',
'res.scormMeta': 'Lesson + {n} questions · pass mark {mastery}%',
'res.scormHint': 'For an LMS: the lesson, marked complete when finished',
'res.scormQuizHint': 'For an LMS: lesson and quiz, reports score and pass/fail',
'res.scormReady': 'SCORM package downloaded. Upload the .zip to your LMS.',
'scorm.submit': 'Check answers',
'scorm.retry': 'Try again',
'scorm.complete': 'Mark as complete',
'scorm.completed': 'Completed ✓',
'scorm.score': 'Score: {score}%.',
'scorm.passed': 'Passed (pass mark {n}%).',
'scorm.failed': 'Not passed yet (pass mark {n}%).',
'scorm.answerAll': 'Answer every question first.',
'scorm.noLms': 'Opened outside an LMS: progress is not saved.',
'audio.copy': 'The narration script, read aloud by this device.',
'audio.unsupported': 'This browser cannot read text aloud. Download the script instead.',
'audio.play': 'Listen',
'audio.pause': 'Pause',
'audio.resume': 'Resume',
'audio.stop': 'Stop',
'audio.speed': 'Speed',
'audio.ready': '{n} sentences · press Listen to start',
'audio.sentence': 'Sentence {n} of {total}',
'brain.deleted': '“{name}” deleted.',
// Guide
'help.boxTitle': 'How Express works',
'help.boxBody': 'Four steps from your material to finished content.',
'help.open': 'View the guide',
'help.title': 'How Learning Lab Express works',
'help.subtitle': 'Brain → Instruction → Schedule → Results',
'help.brainTitle': 'Brain',
'help.brainBody': 'Upload your training material. Once its knowledge is built, the AI creates everything from it.',
'help.instructionTitle': 'Instruction',
'help.instructionBody': 'Say what you need in one sentence, e.g. “3 quizzes per week and 1 video per week about leadership”. Check the schedules, then approve them.',
'help.scheduleTitle': 'Schedule',
'help.scheduleBody': 'Schedules run on their own. Pause them, change the topic, or produce now; each stays within its credit limit.',
'help.resultsTitle': 'Results',
'help.resultsBody': 'Review what was made, approve or reject it, and download it: PDF, Markdown, PowerPoint, MP4, audio script, or SCORM for your LMS.',
'help.note': 'Content is AI-generated: always review it before using it. Credits come from your organization’s workspace.',
'help.done': 'Got it',
// Errors from the engine client
'err.unreachable': 'Cannot reach the learning-lab engine.',
'err.generic': 'The request to the engine failed.',
'err.credits': 'Not enough credits: needs {required}, balance {balance}.',
'err.creditsShort': 'Not enough credits.',
'err.docFailed': 'The document could not be processed.',
'err.timeout': 'Document processing took too long.',
'err.researchFailed': 'Research could not run ({code}).',
} as const
export type Key = keyof typeof en
const id: Record<Key, string> = {
'nav.brain': 'Otak',
'nav.instruction': 'Perintah',
'nav.schedule': 'Jadwal',
'nav.results': 'Hasil',
'nav.workspace': 'RUANG KERJA',
'layout.learningSpace': 'Ruang belajar',
'layout.breadcrumb': 'Ruang kerja',
'layout.signOut': 'Keluar',
'layout.engine': 'Engine learning-lab',
'layout.devStrip': 'Fase development: terhubung ke engine learning-lab. Hasil AI perlu ditinjau sebelum dipakai.',
'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
'layout.mainNav': 'Navigasi utama',
'layout.language': 'Bahasa',
'auth.loading': 'Memuat…',
'auth.eyebrow': 'BELAJAR BARU, TANPA MULAI DARI NOL',
'auth.headline1': 'Pengetahuan tim.',
'auth.headline2': 'Ide baru.',
'auth.headline3': 'Pembelajaran tanpa henti.',
'auth.copy': 'Unggah materi ke Otak, beri satu perintah, dan AI terus membuat konten pembelajaran baru sesuai jadwal.',
'auth.benefitUpload': 'Unggah materi ke Otak',
'auth.benefitSchedule': 'Jadwalkan riset dan konten',
'auth.benefitDownload': 'Buka dan unduh hasilnya',
'auth.footer': 'Pengetahuan Anda. Kemungkinan baru.',
'auth.pickOrg': 'Pilih organisasi',
'auth.pickOrgCopy': 'Express Mode berjalan di dalam organisasi agar Otak, jadwal, dan kredit dipakai bersama tim.',
'common.loading': 'Memuat…',
'common.save': 'Simpan',
'common.cancel': 'Batal',
'common.retry': 'Coba lagi',
'common.credits': '{n} kredit',
'brain.title': 'Otak',
'brain.description': 'Semua materi tim diproses menjadi satu Otak yang terus bertambah. Konten baru yang dihasilkan juga masuk ke sini.',
'brain.metricMaterial': 'Materi',
'brain.metricMaterialSub': 'dokumen di ruang kerja',
'brain.metricReady': 'Masuk Otak',
'brain.metricReadySub': 'knowledge base siap',
'brain.metricAction': 'Perlu tindakan',
'brain.metricActionSub': 'menunggu persetujuan Anda',
'brain.listTitle': 'Materi',
'brain.loading': 'Memuat materi…',
'brain.engineDown': 'Engine tidak dapat dihubungi',
'brain.empty': 'Belum ada materi',
'brain.emptyCopy': 'Unggah dokumen untuk mulai mengisi Otak.',
'brain.uploadTitle': 'Unggah materi ke Otak',
'brain.uploadCopy': 'Tarik file ke mana saja di halaman ini, atau pilih dari komputer. Engine membaca isinya lalu menambahkannya ke Otak.',
'brain.formats': 'Format: {list}',
'brain.formatsLoading': 'Memuat format yang didukung…',
'brain.chooseFiles': 'Pilih file',
'brain.uploading': 'Mengunggah…',
'brain.uploaded': 'Terunggah',
'brain.unsupported': 'Format tidak didukung',
'brain.dropHere': 'Lepaskan file untuk diunggah ke Otak',
'brain.statusReady': 'Masuk Otak',
'brain.statusBuilding': 'Membangun Otak',
'brain.statusProcessing': 'Diproses',
'brain.statusAnswer': 'Perlu tindakan',
'brain.statusFailed': 'Gagal',
'brain.queued': 'Menunggu antrean…',
'brain.versionWithCandidate': 'Mirip dengan “{name}”. Apakah ini versi barunya?',
'brain.versionGeneric': 'Apakah ini versi baru dari dokumen yang sudah ada?',
'brain.versionYes': 'Ya, versi baru',
'brain.versionNo': 'Bukan, simpan terpisah',
'brain.needsCredits': 'Kredit tidak cukup untuk memasukkan ke Otak{need}. Isi ulang kredit, lalu coba lagi.',
'brain.needsCreditsNeed': ' (butuh {credits})',
'brain.failed': 'Gagal memasukkan ke Otak.',
'brain.retryWith': 'Coba lagi ({credits})',
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
'brain.confirmCreditsFallback': 'kredit',
'brain.addToBrain': 'Masukkan ke Otak',
'brain.delete': 'Hapus {name}',
'brain.deleteConfirm': 'Hapus “{name}” dari Otak?',
'ins.title': 'Perintah',
'ins.description': 'Tulis instruksi biasa. AI menyusunnya menjadi jadwal riset dan pembuatan konten, lalu terus berjalan sampai Anda menghentikannya.',
'ins.cardTitle': 'Apa yang perlu dibuat?',
'ins.label': 'Instruksi',
'ins.placeholder': 'Contoh: bikin 3 kuis per minggu dan 2 ringkasan per hari soal leadership',
'ins.hint': 'Sebut jenis konten, jumlah, frekuensi (per hari / per minggu), dan topiknya. Riset internet juga bisa dijadwalkan.',
'ins.compose': 'Susun jadwal',
'ins.ex1': 'Bikin 2 ringkasan per hari dan 1 kuis per minggu soal leadership',
'ins.ex2': 'Riset internet tiap hari soal leadership',
'ins.ex3': '3 roleplay per minggu tentang negosiasi dengan pelanggan',
'ins.ex4': 'Riset tiap Senin soal tren sales, lalu buat 5 flashcard per minggu',
'ins.ex5': '1 video per minggu, 2 podcast per minggu dan 1 modul SCORM per minggu soal onboarding',
'ins.previewTitle': 'Jadwal yang akan dibuat',
'ins.readyCount': '{n} siap',
'ins.notUnderstood': 'Instruksi belum dipahami',
'ins.notUnderstoodCopy': 'Coba sebut jenis konten dan frekuensinya, misalnya “2 ringkasan per hari”.',
'ins.skipped': 'Bagian ini belum dipahami dan dilewati: {parts}',
'ins.approveNote': 'Setelah disetujui, jadwal langsung aktif. Konten dibuat dari materi di Otak tanpa mengulang yang sudah ada, dan memakai kredit ruang kerja sesuai batas di atas.',
'ins.approve': 'Setujui {n} jadwal',
'ins.approving': 'Membuat jadwal…',
'ins.active': 'Aktif',
'ins.failed': 'Gagal',
'ins.viewSchedule': 'Lihat di Jadwal',
'ins.kind': 'Jenis konten',
'ins.count': 'Jumlah',
'ins.frequency': 'Frekuensi',
'ins.perDay': 'per hari',
'ins.perWeek': 'per minggu',
'ins.researchFrequency': 'Frekuensi riset',
'ins.everyDay': 'Setiap hari',
'ins.everyWeek': 'Setiap minggu',
'ins.weekday': 'Hari',
'ins.topic': 'Topik',
'ins.topicPlaceholder': 'Topik (kosong = seluruh Otak)',
'ins.autoApprove': 'Sumber baru langsung masuk ke Otak (tanpa ditinjau dulu)',
'ins.notCreated': 'Jadwal ini belum dibuat.',
'ins.tooMany': 'Maksimal 50 konten per minggu untuk satu jadwal.',
'ins.budget': 'Batas kredit: {first}, {monthly} per bulan.',
'ins.budgetWeekly': '{n} per minggu',
'ins.budgetRun': '{n} per riset',
'ins.removeRule': 'Hapus dari daftar',
'rule.everyDay': 'Setiap hari',
'rule.everyWeekday': 'Setiap {day}',
'rule.research': '{when}: riset internet{about}',
'rule.about': ' tentang {topic}',
'rule.wholeBrain': ' dari seluruh Otak',
'rule.content': '{kind}: {count} {per}{about}',
'rule.monthNote': '{count} per bulan dijadwalkan sebagai {weekly} per minggu.',
'kind.summary': 'Ringkasan',
'kind.quiz': 'Kuis',
'kind.flashcards': 'Flashcard',
'kind.roleplay': 'Roleplay',
'kind.simulator': 'Simulasi',
'kind.slide_deck': 'Slide',
'kind.video': 'Video',
'kind.audio': 'Audio',
'kind.scorm': 'SCORM',
'day.0': 'Senin', 'day.1': 'Selasa', 'day.2': 'Rabu', 'day.3': 'Kamis', 'day.4': 'Jumat', 'day.5': 'Sabtu', 'day.6': 'Minggu',
'sch.title': 'Jadwal',
'sch.description': 'Jadwal riset dan pembuatan konten yang sedang berjalan. Ubah topik kapan saja; konten berikutnya mengikuti topik baru.',
'sch.newInstruction': 'Perintah baru',
'sch.cardTitle': 'Jadwal',
'sch.loading': 'Memuat jadwal…',
'sch.loadFailed': 'Jadwal tidak dapat dimuat',
'sch.empty': 'Belum ada jadwal',
'sch.emptyCopy': 'Beri perintah seperti “2 ringkasan per hari soal leadership” untuk mulai.',
'sch.nextResearch': 'Riset berikutnya: {when}',
'sch.paused': 'Dijeda',
'sch.active': 'Aktif',
'sch.newTopic': 'Topik baru',
'sch.changeTopic': 'Ganti topik',
'sch.pause': 'Jeda',
'sch.resume': 'Lanjutkan',
'sch.runNow': 'Jalankan sekarang',
'sch.history': 'Riwayat',
'sch.hideHistory': 'Sembunyikan riwayat',
'sch.delete': 'Hapus',
'sch.deleteConfirm': 'Hapus jadwal “{name}”? Konten yang sudah dibuat tetap ada.',
'sch.loadingProgress': 'Memuat progres…',
'sch.planningOff': 'Produksi otomatis belum aktif di engine: admin perlu mengatur harga “agent.content.plan”.',
'sch.produceNow': 'Produksi sekarang',
'prod.runningTitle': 'Memproduksi {n} konten baru',
'prod.lastTitle': 'Produksi terakhir',
'prod.count': '{ready} dari {total} siap',
'prod.stepTopics': 'AI memilih topik baru',
'prod.stepStart': 'Memulai',
'prod.stepCreate': 'Membuat konten ({ready}/{total})',
'prod.stepDone': 'Selesai',
'prod.failed': 'Produksi gagal ({code}).',
'prod.someFailed': '{n} gagal.',
'prod.shortfall': 'Brain hanya punya materi baru untuk lebih sedikit konten dari yang diminta (kurang {n}); tambah dokumen untuk topik lain.',
'prod.viewResults': 'Lihat di Results',
'sch.produceHowMany': 'Berapa konten sekarang? (sisa {max} untuk minggu ini)',
'sch.checkPrice': 'Cek harga',
'sch.checkingPrice': 'Mengecek…',
'sch.produceQuote': 'Merencanakan {n} konten memakai {credits} kredit. Setiap konten lalu dipotong kreditnya saat dibuat, dalam batas mingguan jadwal ini sebesar {budget} kredit. Produksi mulai dalam sekitar satu menit.',
'sch.startProduction': 'Mulai produksi',
'sch.starting': 'Memulai…',
'sch.alreadyProducing': 'Jadwal ini sedang berproduksi. Tunggu sampai selesai dulu.',
'sch.weekFull': 'Target minggu ini sudah terpakai. Konten berikutnya ikut jadwal minggu depan.',
'sch.thisWeek': 'Minggu ini: {ready} dari {target} siap{failed}',
'sch.thisWeekFailed': ', {n} gagal',
'sch.waitingFirst': 'Menunggu jadwal produksi pertama',
'sch.loadingHistory': 'Memuat riwayat…',
'sch.neverRan': 'Belum pernah berjalan.',
'sch.scheduled': 'Terjadwal',
'sch.manual': 'Manual',
'run.queued': 'Antre', 'run.planning': 'Merencanakan', 'run.running': 'Berjalan', 'run.completed': 'Selesai', 'run.failed': 'Gagal', 'run.cancelled': 'Dibatalkan',
'res.title': 'Hasil',
'res.description': 'Semua konten yang dihasilkan Express: teks, kuis, roleplay, video, audio, dan SCORM.',
'res.empty': 'Belum ada hasil',
'res.emptyCopy': 'Konten dan sumber dari jadwal Anda tampil di sini. Beri perintah untuk memulai jadwal.',
'res.tabContent': 'Konten',
'res.tabResearch': 'Riset',
'res.pendingCount': '{n} perlu ditinjau',
'res.allContent': 'Semua konten',
'res.foundSources': 'Sumber hasil riset',
'res.search': 'Cari judul…',
'res.filterKind': 'Filter jenis',
'res.filterStatus': 'Filter status',
'res.allKinds': 'Semua jenis',
'res.status.all': 'Semua status',
'res.status.review': 'Perlu ditinjau',
'res.status.approved': 'Disetujui',
'res.status.working': 'Sedang dibuat',
'res.status.failed': 'Gagal atau ditolak',
'res.state.preparing': 'Menyiapkan',
'res.state.generating': 'Membuat',
'res.state.ready': 'Perlu ditinjau',
'res.state.accepted': 'Disetujui',
'res.state.failed': 'Gagal',
'res.state.rejected': 'Ditolak',
'res.source.pending': 'Perlu ditinjau',
'res.source.accepted': 'Masuk Library',
'res.source.rejected': 'Ditolak',
'res.loadFailed': 'Hasil tidak dapat dimuat',
'res.noMatch': 'Tidak ada yang cocok',
'res.noMatchCopy': 'Coba kata kunci, jenis, atau status lain.',
'res.noteContent': 'Unduh sebagai PDF, Markdown, atau SCORM; slide deck juga sebagai PowerPoint, video sebagai MP4, naskah audio sebagai teks. Roleplay dan simulasi dijalankan secara interaktif di Learning Studio.',
'res.noteResearch': 'Riset menemukan sumber dari web. Masukkan yang berguna ke Library, lalu bangun pengetahuannya di halaman Brain.',
'res.sourceCount': '{n} sumber',
'res.sourceOne': '1 sumber',
'res.working': 'Sedang dibuat…',
'res.open': 'Buka',
'res.close': 'Tutup',
'res.reviewNotice': 'Draft buatan AI. Periksa dulu, lalu setujui atau tolak.',
'res.noContent': 'Tidak ada konten untuk ditampilkan',
'res.noContentCopy': 'Item ini belum selesai atau hasilnya sudah tidak tersedia.',
'res.info': 'Informasi konten',
'res.statusLabel': 'Status',
'res.schedule': 'Jadwal',
'res.created': 'Dibuat',
'res.sources': 'Sumber',
'res.footNote': 'Dibuat oleh Express dari Brain Anda.',
'res.openStudio': 'Buka di Learning Studio',
'res.download': 'Unduh',
'res.preparingFile': 'Menyiapkan file…',
'res.pdfHint': 'Membuka dialog cetak: pilih “Save as PDF” / “Simpan sebagai PDF”',
'res.pptxHint': 'Slide dari Learning Studio',
'res.mdHint': 'Teks dengan judul, untuk disunting',
'res.accept': 'Setujui',
'res.reject': 'Tolak',
'res.addToBrain': 'Masukkan ke Library',
'res.inBrain': 'lihat Brain',
'res.answer': 'Jawaban',
'res.slide': 'Slide {n}',
'res.field.description': 'Deskripsi',
'res.field.topic': 'Topik',
'res.field.objective': 'Tujuan',
'res.field.learning_objectives': 'Tujuan pembelajaran',
'res.field.user_role': 'Peran peserta',
'res.field.ai_character': 'Karakter AI',
'res.field.difficulty': 'Tingkat kesulitan',
'res.field.context': 'Konteks',
// Media dan SCORM (Fase 4, 5, 7)
'ins.noteVideo': 'Satu video per proses, sekitar {seconds} detik dalam {scenes} adegan, dibuat oleh Video Studio. Setiap video menunggu tinjauan Anda di Hasil.',
'ins.noteAudio': 'Dibuat sebagai naskah narasi. Hasil memutarnya dengan suara perangkat dan mengunduhnya sebagai teks (belum MP3: engine belum punya layanan audio untuk agen).',
'ins.noteScorm': 'Setiap modul berisi materi dan kuis 10 soal, diunduh dari Hasil sebagai paket SCORM 1.2 untuk LMS Anda.',
'ins.videoCadence': 'Jadwal video membuat satu video per proses: pilih 1 per hari atau 1 per minggu.',
'ins.videoQuoteFailed': 'Harga video tidak bisa dicek: {message}',
'ins.budgetVideo': '{n} per video',
'sch.nextVideo': 'Video berikutnya: {when}',
'sch.videoNow': 'Buat video sekarang',
'sch.videoStarted': 'Video mulai dibuat. Video tampil di Hasil selama proses berjalan.',
'sch.runStarted': 'Riset dimulai.',
'sch.deleted': 'Jadwal dihapus.',
'res.videoProgress': 'Merender… {n}%',
'res.videoNotReady': 'Video belum siap',
'res.videoFailed': 'Video gagal dibuat',
'res.videoFailedCopy': 'Proses render berhenti sebelum video selesai.',
'res.downloadVideo': 'Unduh video (MP4)',
'res.creditsUsed': 'Kredit terpakai',
'res.accepted': 'Disetujui.',
'res.rejected': 'Ditolak.',
'res.audioNotice': 'Diputar dengan suara perangkat ini, yang bisa berbeda antarperangkat. Unduh naskahnya untuk direkam atau dipakai di alat TTS.',
'res.fullScript': 'Lihat naskah lengkap',
'res.scriptTxt': 'Naskah (.txt)',
'res.scriptHint': 'Teks narasi, untuk direkam atau alat TTS',
'res.quiz': 'Kuis',
'res.quizPending': 'Kuisnya belum siap.',
'res.scormModule': 'Modul SCORM 1.2',
'res.scormMeta': 'Materi + {n} soal · nilai lulus {mastery}%',
'res.scormHint': 'Untuk LMS: materi, ditandai selesai setelah dibaca',
'res.scormQuizHint': 'Untuk LMS: materi dan kuis, melaporkan nilai dan lulus/tidak',
'res.scormReady': 'Paket SCORM terunduh. Unggah file .zip ke LMS Anda.',
'scorm.submit': 'Periksa jawaban',
'scorm.retry': 'Coba lagi',
'scorm.complete': 'Tandai selesai',
'scorm.completed': 'Selesai ✓',
'scorm.score': 'Nilai: {score}%.',
'scorm.passed': 'Lulus (nilai lulus {n}%).',
'scorm.failed': 'Belum lulus (nilai lulus {n}%).',
'scorm.answerAll': 'Jawab semua soal terlebih dahulu.',
'scorm.noLms': 'Dibuka di luar LMS: progres tidak disimpan.',
'audio.copy': 'Naskah narasi, dibacakan oleh perangkat ini.',
'audio.unsupported': 'Browser ini tidak bisa membacakan teks. Unduh naskahnya saja.',
'audio.play': 'Dengarkan',
'audio.pause': 'Jeda',
'audio.resume': 'Lanjutkan',
'audio.stop': 'Hentikan',
'audio.speed': 'Kecepatan',
'audio.ready': '{n} kalimat · tekan Dengarkan untuk mulai',
'audio.sentence': 'Kalimat {n} dari {total}',
'brain.deleted': '“{name}” dihapus.',
// Panduan
'help.boxTitle': 'Cara kerja Express',
'help.boxBody': 'Empat langkah dari materi sampai konten jadi.',
'help.open': 'Lihat panduan',
'help.title': 'Cara kerja Learning Lab Express',
'help.subtitle': 'Otak → Perintah → Jadwal → Hasil',
'help.brainTitle': 'Otak',
'help.brainBody': 'Unggah materi pelatihan. Setelah pengetahuannya dibangun, AI membuat semua konten dari situ.',
'help.instructionTitle': 'Perintah',
'help.instructionBody': 'Tulis kebutuhan Anda dalam satu kalimat, misalnya “3 kuis per minggu dan 1 video per minggu soal leadership”. Periksa jadwalnya, lalu setujui.',
'help.scheduleTitle': 'Jadwal',
'help.scheduleBody': 'Jadwal berjalan sendiri. Jeda, ganti topik, atau produksi sekarang; masing-masing tetap dalam batas kreditnya.',
'help.resultsTitle': 'Hasil',
'help.resultsBody': 'Tinjau hasilnya, setujui atau tolak, lalu unduh: PDF, Markdown, PowerPoint, MP4, naskah audio, atau SCORM untuk LMS.',
'help.note': 'Konten dibuat AI: selalu tinjau sebelum dipakai. Kredit diambil dari ruang kerja organisasi Anda.',
'help.done': 'Mengerti',
'err.unreachable': 'Engine learning-lab tidak dapat dihubungi.',
'err.generic': 'Permintaan ke engine gagal.',
'err.credits': 'Kredit tidak cukup: butuh {required}, saldo {balance}.',
'err.creditsShort': 'Kredit tidak cukup.',
'err.docFailed': 'Dokumen gagal diproses.',
'err.timeout': 'Pemrosesan dokumen melebihi batas waktu.',
'err.researchFailed': 'Riset gagal dijalankan ({code}).',
}
const dictionaries: Record<Lang, Record<Key, string>> = { en, id }
const STORAGE = 'lle-lang'
function initial(): Lang {
try {
const saved = localStorage.getItem(STORAGE)
if (saved === 'en' || saved === 'id') return saved
} catch { /* storage blocked: fall back to the default */ }
return 'en'
}
let lang: Lang = initial()
const listeners = new Set<() => void>()
if (typeof document !== 'undefined') document.documentElement.lang = lang
export const getLang = () => lang
export function setLang(next: Lang) {
if (next === lang) return
lang = next
if (typeof document !== 'undefined') document.documentElement.lang = next
try { localStorage.setItem(STORAGE, next) } catch { /* per-device preference only */ }
listeners.forEach(l => l())
}
export function t(key: Key, vars?: Record<string, string | number>): string {
const text = dictionaries[lang][key] ?? en[key]
return vars ? text.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : text
}
/** Re-renders the component when the language changes; returns the active language. */
export function useLang(): Lang {
return useSyncExternalStore(cb => { listeners.add(cb); return () => listeners.delete(cb) }, getLang)
}
/** BCP 47 locale for dates and numbers. */
export const locale = () => (lang === 'id' ? 'id-ID' : 'en-US')