diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..65acd98 --- /dev/null +++ b/.env.production @@ -0,0 +1,5 @@ +# Used by `vite build` (Netlify deploys are built locally). Both values are public. +# Without these, the build falls back to learning-lab's dev Clerk key and localhost Studio, +# so production users sign in to the wrong Clerk instance and see no data. +VITE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsubGVhcm5pbmdzdHVkaW8uYmF3YW5hLmNvbSQ +VITE_STUDIO_URL=https://learningstudio.bawana.com diff --git a/README.md b/README.md index 60cd888..3276cbd 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ npm test # tes parser, jadwal, SCORM, dan ekspor (Vitest) - Clerk key dibaca dari `VITE_CLERK_PUBLISHABLE_KEY`, atau otomatis dari `../learning-lab/backend/.env`. - Tombol "Open in Learning Studio" di Results membuka `VITE_STUDIO_URL` (default `http://localhost:5173`). - Dua bahasa: English (default) dan Indonesia, lewat tombol EN/ID di topbar dan halaman login. Teks ada di `src/i18n.ts`; pilihan disimpan per perangkat. +- Instruction berbentuk chat dengan agen (`src/express/agent.ts`): bila ada yang belum jelas (bagian yang tidak dipahami, frekuensi video, hari riset, topik), agen langsung bertanya di chat dengan opsi jawaban, lalu menawarkan untuk memulai jadwal. Pemahamannya tetap memakai parser deterministik, bukan LLM. - Login wajib memakai akun Clerk dengan organisasi aktif: agents, jadwal, dan kredit semuanya per organisasi. | Folder | Isi | diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..eb46442 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,24 @@ +[build] + command = "npm run build" + publish = "dist" + +[build.environment] + NODE_VERSION = "22" + +# Same-origin /api, as the Vite proxy does in dev: the engine needs no CORS setup. +[[redirects]] + from = "/api/*" + to = "https://learningstudio.bawana.com/api/:splat" + status = 200 + force = true + +# BrowserRouter: /brain, /results, ... all load index.html. +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 + +[[headers]] + for = "/assets/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" diff --git a/src/App.tsx b/src/App.tsx index 70405e3..a17b464 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { AuthGate } from './auth/AuthGate' import { AppLayout } from './components/AppLayout' import { BrainPage } from './pages/Brain' +import { ProductionPage } from './pages/Production' import { InstructionPage } from './pages/Instruction' import { SchedulePage } from './pages/Schedule' import { ResultsPage } from './pages/Results' @@ -11,6 +12,7 @@ export default function App() { }> + } /> } /> } /> } /> @@ -21,7 +23,7 @@ export default function App() { } /> } /> } /> - } /> + } /> ) diff --git a/src/api/engine.ts b/src/api/engine.ts index f1c3503..ded8112 100644 --- a/src/api/engine.ts +++ b/src/api/engine.ts @@ -49,6 +49,7 @@ function asSource(d: EngineDocument): Source { export const listDocuments = async () => (await request('/upload/list')).map(asSource) export const documentStatus = (id: number) => request(`/upload/${id}`) export const deleteDocument = (id: number) => request(`/upload/${id}`, { method: 'DELETE' }) +export const documentFile = (id: number) => request(`/upload/${id}/file`, { as: 'blob' }) export async function supportedExtensions(): Promise> { const f = await request<{ extensions: string[] }>('/upload/supported-formats') @@ -97,6 +98,12 @@ export async function waitReady(id: number, onProgress?: (d: EngineDocument) => } } +// ── Workspace wallet ── + +// The organization's credit balance: Express spends workspace credits, never personal ones. +export const workspaceCredits = async () => + (await request<{ organization: { credit_balance: number } | null }>('/organizations/me')).organization?.credit_balance ?? null + // ── Compose (text content) ── export interface ComposeSet { id: number; name: string } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 7c6215f..369a76f 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,17 +1,30 @@ import { useEffect, useRef, useState } from 'react' -import { useClerk, useOrganization, useUser } from '@clerk/react' +import { useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/react' +import { useQuery } from '@tanstack/react-query' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n' +import { workspaceCredits } from '../api/engine' import { Brand, Icon, type IconName } from './Icon' import { Toaster } from './Toast' -// Express Mode has four doors: feed the Brain, give an instruction, watch the schedule, collect results. -export const NAV: { to: string; label: Key; icon: IconName }[] = [ - { to: '/brain', label: 'nav.brain', icon: 'brain' }, - { to: '/instruction', label: 'nav.instruction', icon: 'spark' }, - { to: '/schedule', label: 'nav.schedule', icon: 'calendar' }, - { to: '/results', label: 'nav.results', icon: 'layers' }, +// Learning Studio (learning-lab/frontend); both apps share one Clerk instance, so the user stays signed in. +const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL + +// The Production dashboard shows what Express is making; the four settings doors run it: +// feed the Brain, give an instruction, watch the schedule, collect results. +export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: IconName }[] }[] = [ + { label: 'nav.dashboard', items: [{ to: '/production', label: 'nav.production', icon: 'chart' }] }, + { + label: 'nav.settings', + items: [ + { to: '/brain', label: 'nav.brain', icon: 'brain' }, + { to: '/instruction', label: 'nav.instruction', icon: 'spark' }, + { to: '/schedule', label: 'nav.schedule', icon: 'calendar' }, + { to: '/results', label: 'nav.results', icon: 'layers' }, + ], + }, ] +export const NAV = NAV_GROUPS.flatMap(g => g.items) export function LanguageSwitch() { const lang = useLang() @@ -57,32 +70,87 @@ function HelpDialog({ onClose }: { onClose: () => void }) { ) } +// Polled because agents and schedules also spend credits outside this tab; main.tsx refreshes it after every mutation. +function CreditBalance() { + const credits = useQuery({ queryKey: ['credits'], queryFn: workspaceCredits, refetchInterval: 30_000 }) + if (credits.data == null) return null + return ( + + {t('common.credits', { n: credits.data.toLocaleString(locale()) })} + + ) +} + +// The Learning space card switches the active organization. AuthGate keys the pages by orgId and +// clears the query cache, so Brain, schedules, results and credits all reload for the new one. +function OrgSwitcher() { + const [open, setOpen] = useState(false) + const ref = useRef(null) + const { organization } = useOrganization() + const { setActive, userMemberships } = useOrganizationList({ userMemberships: { infinite: true } }) + + useEffect(() => { + if (!open) return + const onClick = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false) } + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('mousedown', onClick) + document.addEventListener('keydown', onKey) + return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKey) } + }, [open]) + + const choose = (id: string) => { + setOpen(false) + if (id !== organization?.id) void setActive?.({ organization: id }) + } + + return ( +
+ + {open && ( +
    + {userMemberships.data?.map(({ organization: org }) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} + export function AppLayout() { useLang() const [help, setHelp] = useState(false) const { user } = useUser() - const { organization } = useOrganization() const { signOut } = useClerk() const { pathname } = useLocation() const current = NAV.find(n => pathname.startsWith(n.to)) const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-' - const today = new Intl.DateTimeFormat(locale(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) return (