feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it
This commit is contained in:
@@ -56,6 +56,7 @@
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dayjs": "^1.11.10",
|
||||
"encoding": "^0.1.13",
|
||||
"hls.js": "^1.6.16",
|
||||
"hotkeys-js": "^3.11.2",
|
||||
"html-to-image": "^1.11.11",
|
||||
"is-valid-domain": "^0.1.6",
|
||||
@@ -65,6 +66,7 @@
|
||||
"ky": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^7.0.5",
|
||||
"mediabunny": "^1.45.2",
|
||||
"openai-edge": "^1.2.2",
|
||||
"papaparse": "^5.4.1",
|
||||
"pluralize": "^8.0.0",
|
||||
|
||||
@@ -10,61 +10,9 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
|
||||
|
||||
%sveltekit.head%
|
||||
<style id="app-loading-styles">
|
||||
#app-loading-indicator {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
z-index: 99999;
|
||||
}
|
||||
#app-loading-indicator .spinner {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
color: #1d4ed8;
|
||||
animation: app-loading-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes app-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
#app-loading-indicator p {
|
||||
margin-top: 16px;
|
||||
color: #6b7280;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover" class="">
|
||||
<div id="app-loading-indicator">
|
||||
<svg
|
||||
class="spinner"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<path d="M12 2v4" />
|
||||
<path d="m16.2 7.8 2.9-2.9" />
|
||||
<path d="M18 12h4" />
|
||||
<path d="m16.2 16.2 2.9 2.9" />
|
||||
<path d="M12 18v4" />
|
||||
<path d="m4.9 19.1 2.9-2.9" />
|
||||
<path d="M2 12h4" />
|
||||
<path d="m4.9 4.9 2.9 2.9" />
|
||||
</svg>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div style="display: contents" class="theme-container">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -41,6 +41,8 @@ export const handle: Handle = async (args) => {
|
||||
return proxyRequestToApi(event.request);
|
||||
}
|
||||
|
||||
const cookieString = event.cookies.getAll().map((c) => `${c.name}=${c.value}`);
|
||||
console.log('event.cookies', cookieString);
|
||||
const sessionData = await getSessionData(event.cookies);
|
||||
|
||||
if (sessionData) {
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
CreateAccountWorkspaceRequest,
|
||||
DeleteAccountWorkspaceRequest,
|
||||
GetAccountUsageRequest,
|
||||
ListAccountWorkspacesRequest
|
||||
ListAccountWorkspacesRequest,
|
||||
ViewAsStudentTokenRequest
|
||||
} from '../utils/types';
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import { ZCreateWorkspace, type TCreateWorkspace } from '@cio/utils/validation/account';
|
||||
@@ -29,6 +30,20 @@ class AccountApi extends BaseApiWithErrors {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a short-lived login-link token for the current user. Used by the
|
||||
* "View as student" handoff to auto-authenticate the teacher on the org domain.
|
||||
* Returns the token, or undefined on failure.
|
||||
*/
|
||||
async createViewAsStudentToken(): Promise<string | undefined> {
|
||||
const result = await this.execute<ViewAsStudentTokenRequest>({
|
||||
requestFn: () => classroomio.account['view-as-student-token'].$post(),
|
||||
logContext: 'creating view-as-student token'
|
||||
});
|
||||
|
||||
return result?.data.token;
|
||||
}
|
||||
|
||||
async loadUsage() {
|
||||
return this.execute<GetAccountUsageRequest>({
|
||||
requestFn: () => classroomio.account.usage.$get(),
|
||||
|
||||
@@ -4,6 +4,7 @@ export type ListAccountWorkspacesRequest = typeof classroomio.account.workspaces
|
||||
export type CreateAccountWorkspaceRequest = typeof classroomio.account.workspaces.$post;
|
||||
export type DeleteAccountWorkspaceRequest = (typeof classroomio.account.workspaces)[':workspaceId']['$delete'];
|
||||
export type GetAccountUsageRequest = typeof classroomio.account.usage.$get;
|
||||
export type ViewAsStudentTokenRequest = (typeof classroomio.account)['view-as-student-token']['$post'];
|
||||
|
||||
type ListSuccess = Extract<InferResponseType<ListAccountWorkspacesRequest>, { success: true }>;
|
||||
type UsageSuccess = Extract<InferResponseType<GetAccountUsageRequest>, { success: true }>;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Chat-mode vs Run-mode — where the split lives and why it's fragile
|
||||
|
||||
The AI assistant has two execution modes that share most of their plumbing. This doc enumerates exactly where each mode is decided, surfaced, and routed, and the three places the split is currently load-bearing on heuristics rather than enforcement.
|
||||
|
||||
## Mode definitions
|
||||
|
||||
- **Chat mode** — interactive, request/response. The user types, the API streams an assistant turn, and tool calls execute _inline inside that turn_. No `agent_run` row. Lives in the foreground HTTP process. Bounded by AI SDK `stopWhen` (currently `stepCountIs(...)`) and the user's session.
|
||||
- **Run mode** — durable background execution. The user (or the agent on the user's behalf) creates an `agent_run` row; a BullMQ job is enqueued; the worker process drives the same tools to completion across many rounds. Pollable. Cancellable. Resumable.
|
||||
|
||||
Both modes call the **same tool implementations** (`packages/core/src/services/agent/chat-tools.ts`) against the **same schemas** (`agent-tool-schemas.ts`). The only difference is _who is driving the loop and where_.
|
||||
|
||||
## Where the split is decided
|
||||
|
||||
### 1. Trigger — only one path creates a run
|
||||
|
||||
- `apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte:617` — `handleImplementPlan(editedPlan)`. Called when the user clicks **Implement Plan** in the plan-view card.
|
||||
- `apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts:291` — `aiAssistantApi.createRun(...)` posts to the API.
|
||||
- `apps/api/src/routes/agent/runs.ts:41` — `POST /agent/runs` inserts the row and enqueues a BullMQ job.
|
||||
|
||||
Everything else — every other user message, every tool call the model makes during a chat turn — is **chat mode by default**. There is no second trigger.
|
||||
|
||||
### 2. Worker — what actually executes a run
|
||||
|
||||
- `apps/jobs/src/workers/agent-course-generation.ts` — the BullMQ worker that pulls run jobs.
|
||||
- `packages/core/src/services/agent/course-generation-runner.ts` — `runAgentCourseGenerationJob(...)`, the loop the worker invokes. Streams tool calls, persists steps, respects cancel/pause.
|
||||
|
||||
### 3. Chat — the other endpoint
|
||||
|
||||
- `apps/api/src/routes/agent/agent.ts` — the `/agent/chat` route. Streams an assistant turn via the AI SDK with the same tool set bound. This is where every chat-mode tool call actually fires.
|
||||
|
||||
### 4. UI mode switching
|
||||
|
||||
- `ai-course-chat.svelte:97-103` — the status sets that classify a run:
|
||||
- `RUN_MODE_STATUSES` — `queued|running|waiting_for_input|paused|failed|canceled`. Any of these keeps the panel in "running" mode.
|
||||
- `RUN_RESUMABLE_STATUSES` — `paused|waiting_for_input|canceled`. Controls the Resume button.
|
||||
- `INJECTABLE_RUN_STATUSES` — `completed`. The only status that auto-injects a synthetic summary message.
|
||||
- `ai-course-chat.svelte:898-903` — `panelMode` derivation. Reads `aiAssistantApi.currentRun.status` and flips the bottom of the chat between `RunMonitorPane` and `ChatInput`.
|
||||
- `ai-course-chat.svelte:1320-1346` — render switch.
|
||||
|
||||
### 5. Prompt — the _de facto_ routing logic
|
||||
|
||||
- `packages/ai-assistant/src/prompt/teacher.ts:55-104` — Plan Mode vs Agent Mode, plus "Driving an approved plan to completion" and "Re-engaging a course where an approved plan exists".
|
||||
|
||||
This file is where the model is _told_ to use `generate_course_plan` for big work and inline tools for small edits. **It is the only thing keeping the model from doing everything inline in chat.** No code rejects a chat-mode call to a "heavy" tool.
|
||||
|
||||
## Why the split is fragile
|
||||
|
||||
### 1. Mode routing is LLM-decided, not enforced
|
||||
|
||||
The same tool set is bound in both modes. Whether `update_lesson_content` for ten lessons runs inline (chat) or via a plan→run is decided entirely by how the model interprets the system prompt. Drift in the prompt, a new model with different defaults, or a user prompt that subtly bypasses the plan flow — any of these route heavy work into chat mode where there's no retry, no resume, no progress card, and no cancel button.
|
||||
|
||||
**Observed:** a transcript where the agent updated three lessons inline across one streamed turn. None of it ran through the durable runner. The user only realized when they noticed there was no run history entry.
|
||||
|
||||
### 2. Run can stick at `queued`
|
||||
|
||||
Run creation enqueues to BullMQ. If the worker can't pick the job up (worker crashed, Redis blip, module-resolution boot failure, schema mismatch on payload), the run sits at `queued` indefinitely. The frontend polls run status but has no "worker hasn't moved past queued in N seconds" guardrail. The user sees a stalled progress card with no error.
|
||||
|
||||
**Observed:** `ERR_MODULE_NOT_FOUND` from a cross-app import made the worker boot fail silently from the user's perspective. Cause was only visible in worker logs.
|
||||
|
||||
### 3. Resume/Dismiss/chat boundary
|
||||
|
||||
Canceled run → user clicks Dismiss → `aiAssistantApi.currentRun = null` → `panelMode` flips to `planning` → chat input returns. From here the user can type anything, and the model handles it as a fresh chat turn — including content edits — without the prompt-level "this was canceled, reconcile first" hint being load-bearing.
|
||||
|
||||
We mitigate (3) at the prompt level only:
|
||||
|
||||
- `chat-history.ts:TOOLS_WITH_FULL_OUTPUT` now includes `get_course_structure` and `get_exercise_details` so the model can see what was already created.
|
||||
- `teacher.ts` has the "Re-engaging a course where an approved plan exists" rule telling the model to call `get_course_structure` first.
|
||||
|
||||
Both are prompt-trust mitigations. Neither is enforcement.
|
||||
|
||||
## Tools that are shared between modes
|
||||
|
||||
Every tool defined in `chat-tools.ts` is callable from both endpoints. The notable mutation-heavy ones:
|
||||
|
||||
- `update_lesson_content` — writes full lesson HTML. A single call can be 5–20k tokens of output.
|
||||
- `add_questions` / `update_questions` — can add/update arbitrary-sized question batches in one call.
|
||||
- `create_lesson`, `update_lesson`, `create_exercise`, `update_exercise`.
|
||||
- `update_course_landing_page` — full landing-page mutation, including image generation.
|
||||
- `go_live_course` — publishes the course.
|
||||
|
||||
Reads (`get_course_structure`, `get_lesson_content`, `get_exercise_details`) are also shared and have no execution-cost concern.
|
||||
|
||||
## Recommended hardening (not implemented)
|
||||
|
||||
Three bite-sized steps that move the routing decision from prompt-land into code-land:
|
||||
|
||||
1. **Gate heavy tools to run-mode at the API layer.** Whitelist a small set of cheap chat-mode tools; reject the rest from `/agent/chat` with a structured error that the model can read and translate into "I need to make a plan for this." This stops mode drift.
|
||||
2. **Surface run health.** If `currentRun.status === 'queued'` for >30s with no `lastHeartbeat`, render a "Worker not responding" affordance with a retry button. Stops silent stalls.
|
||||
3. **Tighten the chat-after-cancel boundary.** Either keep the canceled-run pane visible alongside the chat input (so Resume is always one click away) or disable chat input until the user explicitly Dismisses. Removes the "type continue and hope the prompt does the right thing" failure mode.
|
||||
|
||||
None of these unify the two modes — that's a multi-month rewrite. They just stop the LLM and the worker queue from being single points of failure for routing.
|
||||
@@ -4,9 +4,19 @@
|
||||
import ChatHeader from '$features/ai-assistant/chat-header.svelte';
|
||||
import ChatMessageList from '$features/ai-assistant/chat-message-list.svelte';
|
||||
import ChatInput from '$features/ai-assistant/chat-input.svelte';
|
||||
import RunMonitorPane from '$features/ai-assistant/run-monitor-pane.svelte';
|
||||
import ChatChangesCard from '$features/ai-assistant/chat-changes-card.svelte';
|
||||
import {
|
||||
buildRunSummaryMetadata,
|
||||
extractChangedItems,
|
||||
extractChangedItemsFromMessages,
|
||||
formatRunSummaryCounts,
|
||||
type ExerciseTitleLookup
|
||||
} from '$features/ai-assistant/utils/run-summary';
|
||||
import type { AiAssistantRunSummaryMetadata, RunChangedItem } from '$features/ai-assistant/utils/types';
|
||||
import { resolve } from '$app/paths';
|
||||
import { getCompletedToolLine, getPendingToolLine, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
|
||||
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
||||
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
|
||||
import {
|
||||
getAgentToolInput,
|
||||
getAgentToolName,
|
||||
@@ -43,6 +53,7 @@
|
||||
AiAssistantMessage,
|
||||
AiAssistantMessageMetadata,
|
||||
AiAssistantTemplateMetadata,
|
||||
AgentRunStep,
|
||||
UploadedDocument
|
||||
} from '$features/ai-assistant/utils/types';
|
||||
import { getCourseTemplate, type CourseTemplateId, type TemplateFormField } from '@cio/ai-assistant';
|
||||
@@ -66,7 +77,30 @@
|
||||
let agentMutationProgressThresholdsTriggered = new SvelteSet<number>();
|
||||
let lastSeenStreamingFlag = false;
|
||||
|
||||
const CONTINUE_IMPLEMENTATION_PROMPT = 'Continue implementing the plan from where you left off.';
|
||||
/** Tracks the active durable run so we reset thresholds when a new run starts. */
|
||||
let lastSeenDurableRunId = '';
|
||||
/** Last step count seen for the active durable run; lets us skip recompute on no-op polls. */
|
||||
let lastSeenDurableStepCount = 0;
|
||||
/** Run ids we've already injected a synthetic summary message for in this session. */
|
||||
let lastInjectedRunSummaryId = '';
|
||||
/**
|
||||
* Statuses that warrant injecting a synthetic summary message into the
|
||||
* conversation. Only `completed` is auto-injected — canceled runs are
|
||||
* resumable, so the summary is deferred until the user explicitly
|
||||
* dismisses the canceled run.
|
||||
*/
|
||||
const INJECTABLE_RUN_STATUSES = new Set(['completed']);
|
||||
/**
|
||||
* Statuses that keep the panel in run-mode (composer hidden). `canceled`
|
||||
* stays here so the user can either Resume or Dismiss — only `completed`
|
||||
* unconditionally flips back to planning.
|
||||
*/
|
||||
const RUN_MODE_STATUSES = new Set(['queued', 'running', 'waiting_for_input', 'paused', 'failed', 'canceled']);
|
||||
|
||||
const RUN_POLL_INTERVAL_MS = 3_000;
|
||||
const RUN_POLL_STATUSES = new Set(['queued', 'running', 'waiting_for_input']);
|
||||
const RUN_RESUMABLE_STATUSES = new Set(['paused', 'waiting_for_input', 'canceled']);
|
||||
const RUN_TERMINAL_STATUSES = new Set(['completed', 'failed', 'canceled', 'paused']);
|
||||
|
||||
// Read course id from the route. The chat panel is only mounted inside the
|
||||
// course content layout, so `page.params.id` is always the active course.
|
||||
@@ -84,7 +118,10 @@
|
||||
|
||||
let statusFetchedForCourseId: string | null = $state(null);
|
||||
let conversationsLoadedForCourseId: string | null = $state(null);
|
||||
let runsLoadedForCourseId: string | null = $state(null);
|
||||
let lastRunStatusSig = $state('');
|
||||
let activeConversationId: string | null = $state(null);
|
||||
let isLoadingHistory = $state(false);
|
||||
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
|
||||
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
|
||||
|
||||
@@ -153,24 +190,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
let pendingLoadId: string | null = null;
|
||||
|
||||
async function loadConversation(conversationId: string) {
|
||||
if (!courseId) return;
|
||||
|
||||
pendingLoadId = conversationId;
|
||||
await aiAssistantApi.loadConversation(conversationId);
|
||||
|
||||
// Another switch was requested while this one was in flight — drop this result.
|
||||
if (pendingLoadId !== conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = aiAssistantApi.currentConversation;
|
||||
|
||||
if (conversation) {
|
||||
const loadedMessages = (conversation.messages ?? []) as AiAssistantMessage[];
|
||||
// Only overwrite in-memory messages if the loaded conversation actually has saved
|
||||
// messages. An empty result means the conversation was just created and the first
|
||||
// message hasn't been persisted yet — overwriting would wipe the optimistic message
|
||||
// that chat.sendMessage already placed in the UI.
|
||||
if (loadedMessages.length > 0) {
|
||||
chat.messages = loadedMessages;
|
||||
}
|
||||
setActiveConversationId(courseId, conversationId);
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chat.status === 'streaming' || chat.status === 'submitted') {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedMessages = (conversation.messages ?? []) as AiAssistantMessage[];
|
||||
|
||||
// Only overwrite in-memory messages if the loaded conversation actually has saved
|
||||
// messages. An empty result means the conversation was just created and the first
|
||||
// message hasn't been persisted yet — overwriting would wipe the optimistic message
|
||||
// that chat.sendMessage already placed in the UI.
|
||||
if (loadedMessages.length > 0) {
|
||||
chat.messages = loadedMessages;
|
||||
}
|
||||
|
||||
setActiveConversationId(courseId, conversationId);
|
||||
}
|
||||
|
||||
async function startNewChat() {
|
||||
@@ -232,20 +285,41 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Content Ask AI bar (and org home) set this before opening the panel; loading a
|
||||
// saved conversation would race handleSend and wipe the optimistic user message.
|
||||
const pendingPrompt = get(initialChatPrompt);
|
||||
|
||||
if (pendingPrompt?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listForCourseId = courseId;
|
||||
const savedId = getActiveConversationId(listForCourseId);
|
||||
|
||||
aiAssistantApi.listConversations(listForCourseId).then(() => {
|
||||
if (courseId !== listForCourseId) return;
|
||||
isLoadingHistory = true;
|
||||
aiAssistantApi
|
||||
.listConversations(listForCourseId)
|
||||
.then(async () => {
|
||||
if (courseId !== listForCourseId) return;
|
||||
if (activeConversationId) return;
|
||||
if (chat.status === 'streaming' || chat.status === 'submitted') return;
|
||||
|
||||
if (activeConversationId) return;
|
||||
if (savedId) {
|
||||
await loadConversation(savedId);
|
||||
} else if (aiAssistantApi.conversations.length > 0) {
|
||||
await loadConversation(aiAssistantApi.conversations[0].id);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (courseId === listForCourseId) {
|
||||
isLoadingHistory = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (savedId) {
|
||||
loadConversation(savedId);
|
||||
} else if (aiAssistantApi.conversations.length > 0) {
|
||||
loadConversation(aiAssistantApi.conversations[0].id);
|
||||
}
|
||||
});
|
||||
if (runsLoadedForCourseId !== courseId) {
|
||||
runsLoadedForCourseId = courseId;
|
||||
void aiAssistantApi.loadActiveRun(courseId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -503,26 +577,146 @@
|
||||
chat.stop();
|
||||
}
|
||||
|
||||
function isRunActive(status: string | undefined): boolean {
|
||||
return status === 'queued' || status === 'running' || status === 'waiting_for_input';
|
||||
}
|
||||
|
||||
function getRunToolName(step: AgentRunStep): string | null {
|
||||
if (step.stepType.startsWith('tool:')) {
|
||||
return step.stepType.slice('tool:'.length);
|
||||
}
|
||||
|
||||
const toolName = step.input?.toolName;
|
||||
|
||||
return typeof toolName === 'string' && toolName.length > 0 ? toolName : null;
|
||||
}
|
||||
|
||||
function getRunStepInput(step: AgentRunStep): unknown {
|
||||
const args = step.input?.args;
|
||||
|
||||
return args ?? step.input ?? undefined;
|
||||
}
|
||||
|
||||
function getRunStepResult(step: AgentRunStep): unknown {
|
||||
const output = step.output;
|
||||
|
||||
if (!output || typeof output !== 'object') {
|
||||
return output ?? undefined;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(output, 'result')) {
|
||||
return (output as Record<string, unknown>).result;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function getRunProgressStatus(status: string): ProgressStep['status'] {
|
||||
if (status === 'completed') return 'completed';
|
||||
if (status === 'running') return 'in_progress';
|
||||
if (status === 'failed' || status === 'canceled') return 'failed';
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function toRunProgressStep(step: AgentRunStep): ProgressStep {
|
||||
const toolName = getRunToolName(step);
|
||||
const status = getRunProgressStatus(step.status);
|
||||
|
||||
if (!toolName) {
|
||||
return {
|
||||
status,
|
||||
line: { shape: 'i18n', key: 'ai_assistant.run_checkpoint' }
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
line:
|
||||
status === 'completed'
|
||||
? getCompletedToolLine(toolName, getRunStepResult(step))
|
||||
: getPendingToolLine(toolName, getRunStepInput(step))
|
||||
};
|
||||
}
|
||||
|
||||
async function handleImplementPlan(editedPlan: unknown) {
|
||||
if (chat.status === 'streaming') return;
|
||||
if (!courseId) return;
|
||||
|
||||
const existingRunStatus = aiAssistantApi.currentRun?.run.status;
|
||||
if (isRunActive(existingRunStatus)) return;
|
||||
|
||||
const conversationId = await ensureActiveConversation(courseId);
|
||||
if (!conversationId) return;
|
||||
if (!editedPlan || typeof editedPlan !== 'object') return;
|
||||
|
||||
inputValue = '';
|
||||
await persistFinishedChat(chat.messages as AiAssistantMessage[], conversationId);
|
||||
|
||||
chat.sendMessage({
|
||||
text: 'Implement this plan.',
|
||||
metadata: {
|
||||
plan: {
|
||||
action: 'implement_course_plan',
|
||||
payload: editedPlan
|
||||
}
|
||||
}
|
||||
await aiAssistantApi.createRun({
|
||||
courseId,
|
||||
conversationId,
|
||||
approvedPlan: editedPlan as Record<string, unknown>,
|
||||
model: selectedModel
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCancelRun() {
|
||||
const runId = aiAssistantApi.currentRun?.run.id;
|
||||
if (!runId) return;
|
||||
|
||||
await aiAssistantApi.cancelRun(runId);
|
||||
refreshCourseStateAfterChat();
|
||||
}
|
||||
|
||||
async function handleRetryRun() {
|
||||
const runId = aiAssistantApi.currentRun?.run.id;
|
||||
if (!runId) return;
|
||||
|
||||
await aiAssistantApi.retryRun(runId);
|
||||
}
|
||||
|
||||
async function handleResumeRun() {
|
||||
const runId = aiAssistantApi.currentRun?.run.id;
|
||||
if (!runId) return;
|
||||
|
||||
await aiAssistantApi.resumeRun(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk away from a canceled run: inject the run-summary message into the
|
||||
* conversation (the same one we used to auto-write on cancel) and clear
|
||||
* the local currentRun so the panel flips back to planning. The run row
|
||||
* itself stays in the DB — user could still find it in run history.
|
||||
*/
|
||||
async function handleDismissRun() {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
if (!runDetail || !activeConversationId) return;
|
||||
if (runDetail.run.conversationId !== activeConversationId) return;
|
||||
|
||||
const summaryMeta = buildRunSummaryMetadata({ runDetail, resolveExerciseTitle });
|
||||
if (summaryMeta) {
|
||||
const alreadyInConversation = (chat.messages as AiAssistantMessage[]).some(
|
||||
(msg) => (msg.metadata as AiAssistantMessageMetadata | undefined)?.runSummary?.runId === summaryMeta.runId
|
||||
);
|
||||
|
||||
if (!alreadyInConversation) {
|
||||
const summaryMessage: AiAssistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: buildRunSummaryText(summaryMeta) }],
|
||||
metadata: { runSummary: summaryMeta } satisfies AiAssistantMessageMetadata
|
||||
};
|
||||
|
||||
chat.messages = [...(chat.messages as AiAssistantMessage[]), summaryMessage];
|
||||
lastInjectedRunSummaryId = summaryMeta.runId;
|
||||
void aiAssistantApi.saveMessages(activeConversationId, chat.messages as AiAssistantMessage[]);
|
||||
}
|
||||
}
|
||||
|
||||
aiAssistantApi.currentRun = null;
|
||||
}
|
||||
|
||||
async function handleAskPlanChanges(message: string) {
|
||||
const trimmed = message.trim();
|
||||
|
||||
@@ -535,11 +729,6 @@
|
||||
chat.sendMessage({ text: trimmed });
|
||||
}
|
||||
|
||||
function handleResume() {
|
||||
inputValue = CONTINUE_IMPLEMENTATION_PROMPT;
|
||||
void handleSend();
|
||||
}
|
||||
|
||||
function handleMentionClick(route: string) {
|
||||
goto(resolve(route, {}));
|
||||
}
|
||||
@@ -686,6 +875,263 @@
|
||||
return { steps, currentActionLine, isStopped, titleKey, hasMutations };
|
||||
});
|
||||
|
||||
const durableRunState = $derived.by(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
|
||||
if (!runDetail || runDetail.run.courseId !== courseId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const run = runDetail.run;
|
||||
const steps = runDetail.steps.map(toRunProgressStep);
|
||||
|
||||
const titleKey =
|
||||
run.status === 'completed'
|
||||
? 'ai_assistant.run_completed'
|
||||
: run.status === 'failed'
|
||||
? 'ai_assistant.run_failed'
|
||||
: run.status === 'canceled'
|
||||
? 'ai_assistant.run_canceled'
|
||||
: 'ai_assistant.run_running';
|
||||
|
||||
const waitingLine: ToolLineUi | undefined =
|
||||
steps.length === 0 && run.status !== 'failed' && run.status !== 'canceled'
|
||||
? { shape: 'i18n' as const, key: 'ai_assistant.run_waiting_for_worker' }
|
||||
: undefined;
|
||||
const currentActionLine = steps.find((step) => step.status === 'in_progress')?.line ?? waitingLine;
|
||||
|
||||
return {
|
||||
titleKey,
|
||||
steps,
|
||||
currentActionLine,
|
||||
isStopped: run.status === 'failed' || run.status === 'canceled' || run.status === 'paused',
|
||||
error: run.lastError?.message,
|
||||
canCancel: run.status === 'queued' || run.status === 'running' || run.status === 'waiting_for_input',
|
||||
canRetry: run.status === 'failed',
|
||||
canResume: RUN_RESUMABLE_STATUSES.has(run.status)
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Decides what the bottom of the chat panel is showing: the composer
|
||||
* (`planning`) or the durable run monitor (`running`). The run must belong
|
||||
* to the active conversation — a paused run on a different conversation
|
||||
* should not hijack this one. Completed/canceled runs flip back to
|
||||
* planning even if `currentRun` is still in memory.
|
||||
*/
|
||||
const panelMode = $derived.by<'planning' | 'running'>(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
if (!runDetail) return 'planning';
|
||||
if (runDetail.run.courseId !== courseId) return 'planning';
|
||||
if (activeConversationId && runDetail.run.conversationId !== activeConversationId) return 'planning';
|
||||
return RUN_MODE_STATUSES.has(runDetail.run.status) ? 'running' : 'planning';
|
||||
});
|
||||
|
||||
/**
|
||||
* True when any historical `PlanView` card in this conversation should default
|
||||
* to its collapsed form. We minimize either when the plan card has been
|
||||
* pushed back by newer assistant content (no longer the latest message) or
|
||||
* the moment implementation kicks off — both signal the user's attention is
|
||||
* elsewhere. `PlanView` itself preserves manual expand/collapse overrides.
|
||||
*/
|
||||
const planShouldCollapse = $derived(panelMode === 'running');
|
||||
|
||||
/** Statuses where the worker is still actively executing — hide the changes card. */
|
||||
const ACTIVELY_EXECUTING = new Set(['queued', 'running', 'waiting_for_input']);
|
||||
|
||||
/**
|
||||
* Build a map of every exercise id → title from the loaded course outline so
|
||||
* the changes extractor can resolve titles for exercise-section actions
|
||||
* (which only expose the parent exercise id, not its title).
|
||||
*/
|
||||
const resolveExerciseTitle = $derived.by<ExerciseTitleLookup>(() => {
|
||||
const course = courseApi.course;
|
||||
if (!course?.content) return () => undefined;
|
||||
|
||||
const titles = new Map<string, string>();
|
||||
|
||||
const visitItems = (items: { id: string; title?: string; type?: unknown }[] | undefined) => {
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
// Heuristic: only the exercise content type has a route-relevant id we care about.
|
||||
// We index everything that has a title — the lookup is exercise-scoped at call time.
|
||||
if (item.id && typeof item.title === 'string') {
|
||||
titles.set(item.id, item.title);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visitItems(course.content.items as unknown as { id: string; title?: string; type?: unknown }[] | undefined);
|
||||
for (const section of course.content.sections ?? []) {
|
||||
visitItems((section as unknown as { items?: { id: string; title?: string; type?: unknown }[] }).items);
|
||||
}
|
||||
|
||||
return (exerciseId: string) => titles.get(exerciseId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-item changes the agent just made to this conversation's content.
|
||||
* Three sources, in priority order:
|
||||
* 1. The active background run, once it has stopped executing — covers
|
||||
* Agent-mode work (BullMQ runner) with a Completed/Canceled/Paused status.
|
||||
* 2. The latest persisted `runSummary.changes` on a message — survives
|
||||
* refresh for runs that have already injected their summary message.
|
||||
* 3. Inline chat-mode tool calls aggregated from `chat.messages` — covers
|
||||
* conversations where the agent updated content via tool calls embedded
|
||||
* directly in assistant messages (no background run involved). Carries
|
||||
* no status label.
|
||||
*/
|
||||
const changedItems = $derived.by<RunChangedItem[]>(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
if (runDetail && runDetail.run.conversationId === activeConversationId) {
|
||||
if (!ACTIVELY_EXECUTING.has(runDetail.run.status)) {
|
||||
return extractChangedItems(runDetail, resolveExerciseTitle);
|
||||
}
|
||||
}
|
||||
|
||||
const messages = chat.messages as AiAssistantMessage[];
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const summary = (messages[i].metadata as AiAssistantMessageMetadata | undefined)?.runSummary;
|
||||
if (summary?.changes && summary.changes.length > 0) {
|
||||
return summary.changes;
|
||||
}
|
||||
}
|
||||
|
||||
return extractChangedItemsFromMessages(messages, resolveExerciseTitle);
|
||||
});
|
||||
|
||||
function runStatusLabel(status: string): string {
|
||||
if (status === 'completed') return t.get('ai_assistant.changes_card.status.completed');
|
||||
if (status === 'failed') return t.get('ai_assistant.changes_card.status.failed');
|
||||
if (status === 'canceled') return t.get('ai_assistant.changes_card.status.canceled');
|
||||
if (status === 'paused') return t.get('ai_assistant.changes_card.status.paused');
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Status label shown next to the count in the changes card header ("Completed", …). */
|
||||
const changedItemsStatusLabel = $derived.by<string | null>(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
if (runDetail && runDetail.run.conversationId === activeConversationId) {
|
||||
if (!ACTIVELY_EXECUTING.has(runDetail.run.status)) {
|
||||
return runStatusLabel(runDetail.run.status) || null;
|
||||
}
|
||||
}
|
||||
|
||||
const messages = chat.messages as AiAssistantMessage[];
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const summary = (messages[i].metadata as AiAssistantMessageMetadata | undefined)?.runSummary;
|
||||
if (summary?.changes && summary.changes.length > 0) {
|
||||
return runStatusLabel(summary.status) || null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
function buildRunSummaryText(metadata: AiAssistantRunSummaryMetadata): string {
|
||||
const countsText = formatRunSummaryCounts(metadata.counts);
|
||||
|
||||
if (metadata.status === 'completed') {
|
||||
return countsText
|
||||
? t.get('ai_assistant.run_summary.completed_with_counts', { counts: countsText })
|
||||
: t.get('ai_assistant.run_summary.completed');
|
||||
}
|
||||
|
||||
if (metadata.status === 'canceled') {
|
||||
return countsText
|
||||
? t.get('ai_assistant.run_summary.canceled_with_counts', { counts: countsText })
|
||||
: t.get('ai_assistant.run_summary.canceled');
|
||||
}
|
||||
|
||||
if (metadata.status === 'failed') {
|
||||
return metadata.error
|
||||
? t.get('ai_assistant.run_summary.failed_with_reason', { reason: metadata.error })
|
||||
: t.get('ai_assistant.run_summary.failed');
|
||||
}
|
||||
|
||||
return countsText
|
||||
? t.get('ai_assistant.run_summary.paused_with_counts', { counts: countsText })
|
||||
: t.get('ai_assistant.run_summary.paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a synthetic assistant message summarising the run the first time
|
||||
* we observe it reach a settled terminal status. Persists so a refresh
|
||||
* still surfaces the summary — and dedupes via metadata.runSummary.runId
|
||||
* so we don't double-write across refreshes.
|
||||
*/
|
||||
$effect(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
if (!runDetail) return;
|
||||
if (runDetail.run.courseId !== courseId) return;
|
||||
if (!activeConversationId) return;
|
||||
if (runDetail.run.conversationId !== activeConversationId) return;
|
||||
if (!INJECTABLE_RUN_STATUSES.has(runDetail.run.status)) return;
|
||||
|
||||
const runId = runDetail.run.id;
|
||||
if (runId === lastInjectedRunSummaryId) return;
|
||||
|
||||
const alreadyInConversation = (chat.messages as AiAssistantMessage[]).some(
|
||||
(msg) => (msg.metadata as AiAssistantMessageMetadata | undefined)?.runSummary?.runId === runId
|
||||
);
|
||||
|
||||
if (alreadyInConversation) {
|
||||
lastInjectedRunSummaryId = runId;
|
||||
return;
|
||||
}
|
||||
|
||||
const summaryMeta = buildRunSummaryMetadata({ runDetail, resolveExerciseTitle });
|
||||
if (!summaryMeta) return;
|
||||
|
||||
const summaryMessage: AiAssistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: buildRunSummaryText(summaryMeta) }],
|
||||
metadata: { runSummary: summaryMeta } satisfies AiAssistantMessageMetadata
|
||||
};
|
||||
|
||||
chat.messages = [...(chat.messages as AiAssistantMessage[]), summaryMessage];
|
||||
lastInjectedRunSummaryId = runId;
|
||||
|
||||
void aiAssistantApi.saveMessages(activeConversationId, chat.messages as AiAssistantMessage[]);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const run = aiAssistantApi.currentRun?.run;
|
||||
|
||||
if (!run) {
|
||||
lastRunStatusSig = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSig = `${run.id}:${run.status}`;
|
||||
|
||||
if (nextSig === lastRunStatusSig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSig = lastRunStatusSig;
|
||||
lastRunStatusSig = nextSig;
|
||||
|
||||
if (previousSig.startsWith(`${run.id}:`) && RUN_TERMINAL_STATUSES.has(run.status)) {
|
||||
refreshCourseStateAfterChat();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const run = aiAssistantApi.currentRun?.run;
|
||||
|
||||
if (!run || !RUN_POLL_STATUSES.has(run.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pollTimer = window.setInterval(() => {
|
||||
void aiAssistantApi.loadRun(run.id);
|
||||
}, RUN_POLL_INTERVAL_MS);
|
||||
|
||||
return () => window.clearInterval(pollTimer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const streamingNow = isStreaming;
|
||||
|
||||
@@ -729,6 +1175,49 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Mirrors the inline-streaming threshold refresh for durable background runs
|
||||
// (the /agent/runs path). Recomputes only when the run's step count actually
|
||||
// changes, so 2s polls that bring no new steps don't trigger work.
|
||||
$effect(() => {
|
||||
const runDetail = aiAssistantApi.currentRun;
|
||||
|
||||
if (!runDetail || runDetail.run.courseId !== courseId || !RUN_POLL_STATUSES.has(runDetail.run.status)) {
|
||||
lastSeenDurableRunId = '';
|
||||
lastSeenDurableStepCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = runDetail.run.id;
|
||||
|
||||
if (runId !== lastSeenDurableRunId) {
|
||||
agentMutationProgressThresholdsTriggered.clear();
|
||||
lastSeenDurableRunId = runId;
|
||||
lastSeenDurableStepCount = 0;
|
||||
}
|
||||
|
||||
const total = runDetail.steps.length;
|
||||
if (total === 0 || total === lastSeenDurableStepCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSeenDurableStepCount = total;
|
||||
|
||||
const completedStepCount = runDetail.steps.filter((step) => step.status === 'completed').length;
|
||||
const stepThresholds = [
|
||||
...new Set(
|
||||
AGENT_STEP_PROGRESS_REFRESH_RATIOS.map((ratio) => Math.min(total, Math.max(1, Math.ceil(total * ratio))))
|
||||
)
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
for (const threshold of stepThresholds) {
|
||||
if (completedStepCount >= threshold && !agentMutationProgressThresholdsTriggered.has(threshold)) {
|
||||
agentMutationProgressThresholdsTriggered.add(threshold);
|
||||
|
||||
refreshCourseStateAfterChat();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const conversationTitle = $derived(
|
||||
aiAssistantApi.conversations.find((c) => c.id === activeConversationId)?.title ?? null
|
||||
);
|
||||
@@ -818,9 +1307,11 @@
|
||||
<ChatMessageList
|
||||
messages={chat.messages}
|
||||
{isStreaming}
|
||||
{isLoadingHistory}
|
||||
{isStudent}
|
||||
{courseId}
|
||||
{planExecutionState}
|
||||
{planShouldCollapse}
|
||||
{quickActions}
|
||||
onQuickAction={handleQuickAction}
|
||||
onImplementPlan={handleImplementPlan}
|
||||
@@ -828,27 +1319,48 @@
|
||||
onSubmitTemplateAnswers={handleSubmitTemplateAnswers}
|
||||
onSkipTemplateForm={handleSkipTemplateForm}
|
||||
onStop={handleStop}
|
||||
onResume={handleResume}
|
||||
onMentionClick={handleMentionClick}
|
||||
/>
|
||||
|
||||
<ChatInput
|
||||
bind:inputValue
|
||||
{isStreaming}
|
||||
{isExhausted}
|
||||
{isUploading}
|
||||
{uploadedDocument}
|
||||
{mentionItems}
|
||||
{selectedModel}
|
||||
{isStudent}
|
||||
{tutorBlocked}
|
||||
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
||||
error={chat.error}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onSelectModel={handleSelectModel}
|
||||
onLockedModelSelect={handleLockedModelSelect}
|
||||
/>
|
||||
{#if panelMode === 'running' && durableRunState}
|
||||
<RunMonitorPane
|
||||
state={durableRunState}
|
||||
{courseId}
|
||||
onCancel={handleCancelRun}
|
||||
onResume={handleResumeRun}
|
||||
onRetry={handleRetryRun}
|
||||
onDismiss={handleDismissRun}
|
||||
runStatus={aiAssistantApi.currentRun?.run.status ?? null}
|
||||
queuedSince={aiAssistantApi.currentRun?.run.createdAt ?? null}
|
||||
onMentionClick={handleMentionClick}
|
||||
/>
|
||||
{:else}
|
||||
{#if changedItems.length > 0}
|
||||
<ChatChangesCard
|
||||
items={changedItems}
|
||||
{courseId}
|
||||
onNavigate={handleMentionClick}
|
||||
statusLabel={changedItemsStatusLabel}
|
||||
/>
|
||||
{/if}
|
||||
<ChatInput
|
||||
bind:inputValue
|
||||
{isStreaming}
|
||||
{isExhausted}
|
||||
{isUploading}
|
||||
{uploadedDocument}
|
||||
{mentionItems}
|
||||
{selectedModel}
|
||||
{isStudent}
|
||||
{tutorBlocked}
|
||||
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
||||
error={chat.error}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onSelectModel={handleSelectModel}
|
||||
onLockedModelSelect={handleLockedModelSelect}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,21 @@ import type {
|
||||
AgentConversation,
|
||||
AgentConversationCreateData,
|
||||
AgentConversationSummary,
|
||||
AgentRunDetail,
|
||||
AgentRunSummary,
|
||||
AgentStatusData,
|
||||
AiAssistantMessage,
|
||||
CompactConversationRequest,
|
||||
CompactConversationSuccess
|
||||
} from '../utils/types';
|
||||
import type { AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
class AiAssistantApi extends BaseApiWithErrors {
|
||||
status: AgentStatusData | null = $state(null);
|
||||
conversations: AgentConversationSummary[] = $state([]);
|
||||
currentConversation: AgentConversation | null = $state(null);
|
||||
runs: AgentRunSummary[] = $state([]);
|
||||
currentRun: AgentRunDetail | null = $state(null);
|
||||
|
||||
async fetchStatus(courseId: string) {
|
||||
await this.execute<typeof classroomio.agent.status.$get>({
|
||||
@@ -250,6 +255,144 @@ class AiAssistantApi extends BaseApiWithErrors {
|
||||
|
||||
return compacted;
|
||||
}
|
||||
|
||||
async listRuns(courseId: string): Promise<AgentRunSummary[]> {
|
||||
let runs: AgentRunSummary[] = [];
|
||||
|
||||
await this.execute<typeof classroomio.agent.runs.$get>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs.$get({
|
||||
query: { courseId }
|
||||
}),
|
||||
logContext: 'listing agent runs',
|
||||
onSuccess: (result) => {
|
||||
runs = result.data as AgentRunSummary[];
|
||||
this.runs = runs;
|
||||
}
|
||||
});
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
async loadActiveRun(courseId: string): Promise<AgentRunDetail | null> {
|
||||
const runs = await this.listRuns(courseId);
|
||||
const active = runs.find((run) =>
|
||||
['queued', 'running', 'waiting_for_input', 'paused', 'failed'].includes(run.status)
|
||||
);
|
||||
|
||||
if (!active) {
|
||||
this.currentRun = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.loadRun(active.id);
|
||||
}
|
||||
|
||||
async createRun(input: {
|
||||
courseId: string;
|
||||
conversationId: string;
|
||||
approvedPlan: Record<string, unknown>;
|
||||
model: AgentModelId;
|
||||
}): Promise<AgentRunDetail | null> {
|
||||
let runDetail: AgentRunDetail | null = null;
|
||||
|
||||
await this.execute<typeof classroomio.agent.runs.$post>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs.$post({
|
||||
json: {
|
||||
courseId: input.courseId,
|
||||
conversationId: input.conversationId,
|
||||
approvedPlan: input.approvedPlan,
|
||||
model: input.model,
|
||||
phase: 'implementing'
|
||||
}
|
||||
}),
|
||||
logContext: 'creating agent run',
|
||||
onSuccess: (result) => {
|
||||
runDetail = result.data as AgentRunDetail;
|
||||
this.currentRun = runDetail;
|
||||
this.runs = [runDetail.run, ...this.runs.filter((run) => run.id !== runDetail.run.id)];
|
||||
}
|
||||
});
|
||||
|
||||
return runDetail;
|
||||
}
|
||||
|
||||
async loadRun(runId: string): Promise<AgentRunDetail | null> {
|
||||
let runDetail: AgentRunDetail | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.runs)[':runId']['$get']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs[':runId'].$get({
|
||||
param: { runId }
|
||||
}),
|
||||
logContext: 'loading agent run',
|
||||
onSuccess: (result) => {
|
||||
runDetail = result.data as AgentRunDetail;
|
||||
this.currentRun = runDetail;
|
||||
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
|
||||
}
|
||||
});
|
||||
|
||||
return runDetail;
|
||||
}
|
||||
|
||||
async cancelRun(runId: string): Promise<AgentRunDetail | null> {
|
||||
let runDetail: AgentRunDetail | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.runs)[':runId']['cancel']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs[':runId'].cancel.$post({
|
||||
param: { runId }
|
||||
}),
|
||||
logContext: 'canceling agent run',
|
||||
onSuccess: (result) => {
|
||||
runDetail = result.data as AgentRunDetail;
|
||||
this.currentRun = runDetail;
|
||||
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
|
||||
}
|
||||
});
|
||||
|
||||
return runDetail;
|
||||
}
|
||||
|
||||
async retryRun(runId: string): Promise<AgentRunDetail | null> {
|
||||
let runDetail: AgentRunDetail | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.runs)[':runId']['retry']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs[':runId'].retry.$post({
|
||||
param: { runId }
|
||||
}),
|
||||
logContext: 'retrying agent run',
|
||||
onSuccess: (result) => {
|
||||
runDetail = result.data as AgentRunDetail;
|
||||
this.currentRun = runDetail;
|
||||
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
|
||||
}
|
||||
});
|
||||
|
||||
return runDetail;
|
||||
}
|
||||
|
||||
async resumeRun(runId: string): Promise<AgentRunDetail | null> {
|
||||
let runDetail: AgentRunDetail | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.runs)[':runId']['resume']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.runs[':runId'].resume.$post({
|
||||
param: { runId }
|
||||
}),
|
||||
logContext: 'resuming agent run',
|
||||
onSuccess: (result) => {
|
||||
runDetail = result.data as AgentRunDetail;
|
||||
this.currentRun = runDetail;
|
||||
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
|
||||
}
|
||||
});
|
||||
|
||||
return runDetail;
|
||||
}
|
||||
}
|
||||
|
||||
export const aiAssistantApi = new AiAssistantApi();
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { RunChangedItem } from '$features/ai-assistant/utils/types';
|
||||
|
||||
interface Props {
|
||||
items: RunChangedItem[];
|
||||
courseId: string;
|
||||
onNavigate: (route: string) => void;
|
||||
/** Optional status label rendered next to the count ("Completed", "Canceled", …). */
|
||||
statusLabel?: string | null;
|
||||
}
|
||||
|
||||
let { items, courseId, onNavigate, statusLabel = null }: Props = $props();
|
||||
|
||||
let isExpanded = $state(true);
|
||||
|
||||
function toggle() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
function pathFor(item: RunChangedItem): string {
|
||||
if (item.targetType === 'lesson') {
|
||||
return resolve(`/courses/${courseId}/lessons/${item.targetId}`, {});
|
||||
}
|
||||
|
||||
return resolve(`/courses/${courseId}/exercises/${item.targetId}`, {});
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background rounded-t-lg border-t text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
class="hover:ui:bg-muted/40 flex w-full cursor-pointer items-center gap-2 rounded-t-lg px-3 py-1.5 text-left"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={isExpanded ? $t('ai_assistant.changes_card.collapse') : $t('ai_assistant.changes_card.expand')}
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDownIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
{:else}
|
||||
<ChevronRightIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
{/if}
|
||||
<span class="ui:text-foreground font-medium">
|
||||
{$t('ai_assistant.changes_card.summary_count', { count: items.length })}
|
||||
</span>
|
||||
{#if statusLabel}
|
||||
<span class="ui:text-muted-foreground">· {statusLabel}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<ul class="max-h-48 overflow-y-auto">
|
||||
{#each items as item (item.targetType + ':' + item.targetId)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onNavigate(pathFor(item))}
|
||||
class="hover:ui:bg-muted/40 flex w-full cursor-pointer items-center gap-2 px-3 py-1 text-left"
|
||||
>
|
||||
{#if item.targetType === 'lesson'}
|
||||
<LessonIcon size={12} />
|
||||
{:else}
|
||||
<ExerciseIcon size={12} />
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="ui:text-primary min-w-0 flex-1 truncate font-medium underline decoration-dotted underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
|
||||
{#if item.tokens}
|
||||
<span class="ui:text-muted-foreground shrink-0 tabular-nums">{formatTokens(item.tokens)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import MessageBubble from '$features/ai-assistant/message-bubble.svelte';
|
||||
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -26,9 +26,12 @@
|
||||
interface Props {
|
||||
messages: AiAssistantMessage[];
|
||||
isStreaming: boolean;
|
||||
isLoadingHistory: boolean;
|
||||
isStudent: boolean;
|
||||
courseId: string;
|
||||
planExecutionState: PlanExecutionState | null;
|
||||
/** When true (e.g. implementation has started), historical PlanView cards default to collapsed. */
|
||||
planShouldCollapse?: boolean;
|
||||
quickActions: QuickActionOption[];
|
||||
onQuickAction: (action: string) => void;
|
||||
onImplementPlan: (editedPlan: unknown) => void;
|
||||
@@ -40,16 +43,17 @@
|
||||
}) => void;
|
||||
onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void;
|
||||
onStop: () => void;
|
||||
onResume: () => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
messages,
|
||||
isStreaming,
|
||||
isLoadingHistory,
|
||||
isStudent,
|
||||
courseId,
|
||||
planExecutionState,
|
||||
planShouldCollapse = false,
|
||||
quickActions,
|
||||
onQuickAction,
|
||||
onImplementPlan,
|
||||
@@ -57,7 +61,6 @@
|
||||
onSubmitTemplateAnswers,
|
||||
onSkipTemplateForm,
|
||||
onStop,
|
||||
onResume,
|
||||
onMentionClick
|
||||
}: Props = $props();
|
||||
|
||||
@@ -152,7 +155,11 @@
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4" bind:this={messagesContainer} onscroll={handleScroll}>
|
||||
{#if isEmpty}
|
||||
{#if isLoadingHistory}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Spinner class="ui:size-6 ui:text-muted-foreground" />
|
||||
</div>
|
||||
{:else if isEmpty}
|
||||
<!-- Empty state with quick actions -->
|
||||
<div class="flex h-full flex-col items-center justify-center gap-4">
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
@@ -192,6 +199,7 @@
|
||||
{courseId}
|
||||
{isStreaming}
|
||||
{isLast}
|
||||
{planShouldCollapse}
|
||||
{onImplementPlan}
|
||||
{onAskPlanChanges}
|
||||
{onSubmitTemplateAnswers}
|
||||
@@ -219,11 +227,6 @@
|
||||
onStop={isStreaming ? onStop : undefined}
|
||||
isStopped={planExecutionState.isStopped}
|
||||
/>
|
||||
{#if planExecutionState.isStopped}
|
||||
<Button size="sm" variant="outline" onclick={onResume} class="mt-2 w-full"
|
||||
>{$t('ai_assistant.resume')}</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { tick } from 'svelte';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Kbd from '@cio/ui/base/kbd';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { sendPromptToAssistant } from '$features/ai-assistant/utils/store';
|
||||
|
||||
interface Props {
|
||||
/** Width classes matching the page content column (e.g. mx-auto w-full max-w-3xl). */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: contentClass = 'mx-auto w-full max-w-3xl' }: Props = $props();
|
||||
|
||||
const pageShellClass = 'mx-auto w-[90%] max-w-4xl px-3 sm:px-4 lg:max-w-5xl';
|
||||
|
||||
let expanded = $state(false);
|
||||
let draft = $state('');
|
||||
let textareaRef: HTMLTextAreaElement | null = $state(null);
|
||||
|
||||
const isMac = $derived(browser && /Mac|iPhone|iPad|iPod/.test(navigator.platform));
|
||||
|
||||
const expandIn = { y: 20, duration: 280, easing: cubicOut };
|
||||
const expandOut = { y: 12, duration: 200, easing: cubicOut };
|
||||
const collapseIn = { duration: 200, easing: cubicOut };
|
||||
const collapseOut = { duration: 150, easing: cubicOut };
|
||||
|
||||
function shouldIgnoreGlobalShortcut() {
|
||||
const active = document.activeElement;
|
||||
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tag = active.tagName;
|
||||
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (active instanceof HTMLElement && active.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function expand() {
|
||||
expanded = true;
|
||||
await tick();
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
function collapse() {
|
||||
expanded = false;
|
||||
}
|
||||
|
||||
function handleTextareaBlur() {
|
||||
collapse();
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const trimmed = draft.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendPromptToAssistant(trimmed);
|
||||
draft = '';
|
||||
collapse();
|
||||
}
|
||||
|
||||
function handleTextareaKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
collapse();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Enter' || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'i') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldIgnoreGlobalShortcut()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void expand();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
|
||||
{#if expanded}
|
||||
<div
|
||||
class="ui:bg-background ui:border-border fixed right-0 bottom-0 left-0 z-40 border-t py-3 shadow-sm md:left-[var(--sidebar-width,0px)]"
|
||||
in:fly={expandIn}
|
||||
out:fly={expandOut}
|
||||
>
|
||||
<div class={pageShellClass}>
|
||||
<div class={contentClass}>
|
||||
<Textarea
|
||||
bind:ref={textareaRef}
|
||||
bind:value={draft}
|
||||
rows={1}
|
||||
placeholder={$t('ai_assistant.content_input_placeholder')}
|
||||
aria-label={$t('ai_assistant.ask_ai_input_aria')}
|
||||
class="ui:min-h-10 ui:max-h-64 ui:resize-none ui:rounded-none ui:shadow-none"
|
||||
onkeydown={handleTextareaKeydown}
|
||||
onblur={handleTextareaBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="pointer-events-none fixed right-0 bottom-4 left-0 z-40 md:left-[var(--sidebar-width,0px)]"
|
||||
in:fade={collapseIn}
|
||||
out:fade={collapseOut}
|
||||
>
|
||||
<div class={pageShellClass}>
|
||||
<div class={`flex justify-center ${contentClass}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="ui:bg-background ui:shadow-sm pointer-events-auto"
|
||||
aria-label={$t('ai_assistant.ask_ai_expand_aria')}
|
||||
onclick={() => expand()}
|
||||
onmouseenter={() => expand()}
|
||||
>
|
||||
{$t('ai_assistant.ask_ai')}
|
||||
<Separator orientation="vertical" class="ui:!h-4" />
|
||||
<span class="ui:text-muted-foreground flex items-center gap-0.5 font-normal">
|
||||
{#if isMac}
|
||||
<Kbd.Root>⌘</Kbd.Root>
|
||||
{:else}
|
||||
<Kbd.Root>Ctrl</Kbd.Root>
|
||||
{/if}
|
||||
<Kbd.Root>I</Kbd.Root>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as AiCourseChat } from './ai-course-chat.svelte';
|
||||
export { default as ContentAskAiBar } from './content-ask-ai-bar.svelte';
|
||||
export { default as PlanView } from './plan-view.svelte';
|
||||
export { default as ProgressCard } from './progress-card.svelte';
|
||||
export {
|
||||
@@ -8,6 +9,7 @@ export {
|
||||
openAiAssistant,
|
||||
closeAiAssistant,
|
||||
quoteInChat,
|
||||
sendPromptToAssistant,
|
||||
setChatDraft,
|
||||
toggleAiAssistant
|
||||
} from './utils/store';
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
onAskPlanChanges: (message: string) => void;
|
||||
isStreaming: boolean;
|
||||
isLast?: boolean;
|
||||
/** When true, historical PlanView cards default to their collapsed form. */
|
||||
planShouldCollapse?: boolean;
|
||||
onSubmitTemplateAnswers: (payload: {
|
||||
templateId: CourseTemplateId;
|
||||
answers: Record<string, string>;
|
||||
@@ -57,9 +59,17 @@
|
||||
onSkipTemplateForm,
|
||||
onMentionClick,
|
||||
isStreaming,
|
||||
isLast = false
|
||||
isLast = false,
|
||||
planShouldCollapse = false
|
||||
}: Props = $props();
|
||||
|
||||
/**
|
||||
* Collapse a PlanView card when implementation has started anywhere in the
|
||||
* conversation OR when newer assistant messages have pushed it back. The
|
||||
* very first plan card the teacher sees stays expanded.
|
||||
*/
|
||||
const planCardDefaultCollapsed = $derived(planShouldCollapse || !isLast);
|
||||
|
||||
function localizePendingTool(toolName: string): string {
|
||||
const vars = getPendingToolI18nVars(toolName);
|
||||
const key = getPendingToolI18nKey(toolName);
|
||||
@@ -170,6 +180,7 @@
|
||||
onImplement={onImplementPlan}
|
||||
onAskChanges={onAskPlanChanges}
|
||||
isBusy={isStreaming}
|
||||
defaultCollapsed={planCardDefaultCollapsed}
|
||||
/>
|
||||
</div>
|
||||
{:else if toolName === 'ask_template_questions' && (toolStatus === 'completed' || toolStatus === 'in_progress')}
|
||||
|
||||
@@ -25,9 +25,33 @@
|
||||
/** When true, disables the inline change-request composer (agent is already working). */
|
||||
isBusy?: boolean;
|
||||
remainingTokens?: number;
|
||||
/**
|
||||
* When true on mount, the card starts collapsed (header-only). When the
|
||||
* prop flips from false → true and the user hasn't manually toggled the
|
||||
* card, it collapses automatically. Manual user toggles are sticky.
|
||||
*/
|
||||
defaultCollapsed?: boolean;
|
||||
}
|
||||
|
||||
let { plan, onImplement, onAskChanges, isBusy = false, remainingTokens }: Props = $props();
|
||||
let { plan, onImplement, onAskChanges, isBusy = false, remainingTokens, defaultCollapsed = false }: Props = $props();
|
||||
|
||||
let isExpanded = $state(!defaultCollapsed);
|
||||
/** Sticky once the user clicks the top-level chevron; defeats the auto-collapse signal. */
|
||||
let userToggled = $state(false);
|
||||
|
||||
function toggleCard() {
|
||||
userToggled = true;
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the parent flips defaultCollapsed (e.g. implementation just began),
|
||||
* collapse — unless the user has explicitly opened the card already.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (userToggled) return;
|
||||
isExpanded = !defaultCollapsed;
|
||||
});
|
||||
|
||||
let changeRequest = $state('');
|
||||
const canSendChange = $derived(!isBusy && changeRequest.trim().length > 0);
|
||||
@@ -122,167 +146,184 @@
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background rounded-lg border">
|
||||
<!-- Plan header -->
|
||||
<div class="border-b px-3 py-2">
|
||||
{#if editingField === 'plan-title'}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-sm font-semibold focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing('plan-title')}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left text-sm font-semibold"
|
||||
>
|
||||
{editablePlan.title}
|
||||
<PencilIcon size={10} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{editablePlan.sections.length} sections, {totalLessons} lessons{totalExercises > 0
|
||||
? `, ${totalExercises} exercises`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Plan header: chevron + title + counts. Chevron toggles the whole card. -->
|
||||
<div class="flex items-start gap-2 {isExpanded ? 'border-b' : ''} px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleCard}
|
||||
class="ui:text-muted-foreground hover:ui:text-foreground mt-0.5 shrink-0"
|
||||
aria-label={isExpanded ? $t('ai_assistant.plan_view.collapse') : $t('ai_assistant.plan_view.expand')}
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDownIcon size={14} />
|
||||
{:else}
|
||||
<ChevronRightIcon size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Sections tree -->
|
||||
<div class="max-h-[300px] overflow-y-auto p-2">
|
||||
{#each editablePlan.sections as section, sectionIndex (sectionIndex)}
|
||||
<div class="mb-1">
|
||||
<div class="flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium">
|
||||
<button onclick={() => toggleSection(sectionIndex)} class="flex-shrink-0">
|
||||
{#if expandedSections.has(sectionIndex)}
|
||||
<ChevronDownIcon size={12} />
|
||||
{:else}
|
||||
<ChevronRightIcon size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if editingField === `section-${sectionIndex}-title`}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.sections[sectionIndex].title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary flex-1 rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`section-${sectionIndex}-title`)}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left"
|
||||
>
|
||||
Section {section.order}: {section.title}
|
||||
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if expandedSections.has(sectionIndex)}
|
||||
<div class="ml-5 space-y-1">
|
||||
{#each section.items as item, itemIndex (item.order)}
|
||||
<div class="flex items-start gap-1.5 rounded px-2 py-1 text-xs">
|
||||
{#if item.type === 'exercise'}
|
||||
<FileQuestionIcon size={11} class="ui:text-primary mt-0.5 flex-shrink-0" />
|
||||
{:else}
|
||||
<BookOpenIcon size={11} class="ui:text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if editingField === `item-${sectionIndex}-${itemIndex}-title`}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-xs font-medium focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-title`)}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left font-medium"
|
||||
>
|
||||
{#if item.type === 'exercise'}
|
||||
<span class="ui:text-primary">{item.title}</span>
|
||||
{:else}
|
||||
{item.title}
|
||||
{/if}
|
||||
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if editingField === `item-${sectionIndex}-${itemIndex}-desc`}
|
||||
<textarea
|
||||
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].description}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
rows={2}
|
||||
class="ui:bg-muted/50 ui:text-muted-foreground focus:ui:ring-primary mt-0.5 w-full rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
></textarea>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-desc`)}
|
||||
class="group ui:text-muted-foreground hover:ui:text-foreground mt-0.5 flex items-start gap-1 text-left"
|
||||
>
|
||||
<span class="line-clamp-2">{item.description}</span>
|
||||
<PencilIcon size={9} class="mt-0.5 flex-shrink-0 opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if item.type === 'lesson' && item.hasExercise}
|
||||
<div class="ui:text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
<FileQuestionIcon size={10} />
|
||||
<span>Exercise included</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Cost estimate + actions -->
|
||||
<div class="space-y-2 border-t px-3 py-2">
|
||||
{#if estimatedTokens > 0}
|
||||
<p class="text-xs {isHighCost ? 'text-amber-600 dark:text-amber-400' : 'ui:text-muted-foreground'}">
|
||||
Estimated cost: ~{estimatedTokens.toLocaleString()} tokens
|
||||
{#if costPercentage !== null}
|
||||
({costPercentage}% of remaining)
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button size="sm" onclick={() => onImplement(editablePlan)} class="w-full">
|
||||
{$t('ai_assistant.plan_implement')}
|
||||
</Button>
|
||||
<div class="flex items-end gap-2">
|
||||
<Textarea
|
||||
bind:value={changeRequest}
|
||||
placeholder={$t('ai_assistant.plan_ask_changes_placeholder')}
|
||||
rows={1}
|
||||
disabled={isBusy}
|
||||
onkeydown={handleChangeKeydown}
|
||||
class="ui:min-h-9! ui:resize-none ui:py-2 ui:text-sm"
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if editingField === 'plan-title'}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-sm font-semibold focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="default"
|
||||
disabled={!canSendChange}
|
||||
onclick={submitChangeRequest}
|
||||
aria-label={$t('ai_assistant.plan_ask_changes_send_aria')}
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing('plan-title')}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left text-sm font-semibold"
|
||||
>
|
||||
<ArrowUpIcon class="ui:size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{editablePlan.title}
|
||||
<PencilIcon size={10} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{editablePlan.sections.length} sections, {totalLessons} lessons{totalExercises > 0
|
||||
? `, ${totalExercises} exercises`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isExpanded}
|
||||
<!-- Sections tree -->
|
||||
<div class="max-h-[300px] overflow-y-auto p-2">
|
||||
{#each editablePlan.sections as section, sectionIndex (sectionIndex)}
|
||||
<div class="mb-1">
|
||||
<div class="flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium">
|
||||
<button onclick={() => toggleSection(sectionIndex)} class="flex-shrink-0">
|
||||
{#if expandedSections.has(sectionIndex)}
|
||||
<ChevronDownIcon size={12} />
|
||||
{:else}
|
||||
<ChevronRightIcon size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if editingField === `section-${sectionIndex}-title`}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.sections[sectionIndex].title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary flex-1 rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`section-${sectionIndex}-title`)}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left"
|
||||
>
|
||||
Section {section.order}: {section.title}
|
||||
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if expandedSections.has(sectionIndex)}
|
||||
<div class="ml-5 space-y-1">
|
||||
{#each section.items as item, itemIndex (item.order)}
|
||||
<div class="flex items-start gap-1.5 rounded px-2 py-1 text-xs">
|
||||
{#if item.type === 'exercise'}
|
||||
<FileQuestionIcon size={11} class="ui:text-primary mt-0.5 flex-shrink-0" />
|
||||
{:else}
|
||||
<BookOpenIcon size={11} class="ui:text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if editingField === `item-${sectionIndex}-${itemIndex}-title`}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-xs font-medium focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-title`)}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left font-medium"
|
||||
>
|
||||
{#if item.type === 'exercise'}
|
||||
<span class="ui:text-primary">{item.title}</span>
|
||||
{:else}
|
||||
{item.title}
|
||||
{/if}
|
||||
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if editingField === `item-${sectionIndex}-${itemIndex}-desc`}
|
||||
<textarea
|
||||
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].description}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
rows={2}
|
||||
class="ui:bg-muted/50 ui:text-muted-foreground focus:ui:ring-primary mt-0.5 w-full rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
></textarea>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-desc`)}
|
||||
class="group ui:text-muted-foreground hover:ui:text-foreground mt-0.5 flex items-start gap-1 text-left"
|
||||
>
|
||||
<span class="line-clamp-2">{item.description}</span>
|
||||
<PencilIcon size={9} class="mt-0.5 flex-shrink-0 opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if item.type === 'lesson' && item.hasExercise}
|
||||
<div class="ui:text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
<FileQuestionIcon size={10} />
|
||||
<span>Exercise included</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Cost estimate + actions -->
|
||||
<div class="space-y-2 border-t px-3 py-2">
|
||||
{#if estimatedTokens > 0}
|
||||
<p class="text-xs {isHighCost ? 'text-amber-600 dark:text-amber-400' : 'ui:text-muted-foreground'}">
|
||||
Estimated cost: ~{estimatedTokens.toLocaleString()} tokens
|
||||
{#if costPercentage !== null}
|
||||
({costPercentage}% of remaining)
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button size="sm" onclick={() => onImplement(editablePlan)} class="w-full">
|
||||
{$t('ai_assistant.plan_implement')}
|
||||
</Button>
|
||||
<div class="flex items-end gap-2">
|
||||
<Textarea
|
||||
bind:value={changeRequest}
|
||||
placeholder={$t('ai_assistant.plan_ask_changes_placeholder')}
|
||||
rows={1}
|
||||
disabled={isBusy}
|
||||
onkeydown={handleChangeKeydown}
|
||||
class="ui:min-h-9! ui:resize-none ui:py-2 ui:text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="default"
|
||||
disabled={!canSendChange}
|
||||
onclick={submitChangeRequest}
|
||||
aria-label={$t('ai_assistant.plan_ask_changes_send_aria')}
|
||||
>
|
||||
<ArrowUpIcon class="ui:size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
@@ -19,9 +21,26 @@
|
||||
onStop?: () => void;
|
||||
isStopped?: boolean;
|
||||
error?: string;
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
let { titleKey, steps, courseId, onNavigate, currentActionLine, onStop, isStopped = false, error }: Props = $props();
|
||||
let {
|
||||
titleKey,
|
||||
steps,
|
||||
courseId,
|
||||
onNavigate,
|
||||
currentActionLine,
|
||||
onStop,
|
||||
isStopped = false,
|
||||
error,
|
||||
showTitle = true
|
||||
}: Props = $props();
|
||||
|
||||
/** Per-char delay for typewriter — adjusted down for long lines to stay under TYPEWRITER_MAX_MS total. */
|
||||
const TYPEWRITER_MS_PER_CHAR = 25;
|
||||
const TYPEWRITER_MAX_MS = 1500;
|
||||
/** "At the bottom" tolerance — same idea as `chat-message-list.svelte`. */
|
||||
const SCROLL_PIN_THRESHOLD = 40;
|
||||
|
||||
function stepRowKey(step: ProgressStep, index: number): string {
|
||||
if (step.line.shape === 'i18n') {
|
||||
@@ -39,32 +58,147 @@
|
||||
return `${index}-exo-${step.line.exerciseId}-${step.line.action}-${step.line.count}-${step.line.title}`;
|
||||
}
|
||||
|
||||
// ─── Typewriter ────────────────────────────────────────────────────────────
|
||||
// Only i18n-shape lines animate (those are plain text). Lines containing
|
||||
// mention links render in full immediately — partial-rendering a mention
|
||||
// would look broken.
|
||||
|
||||
/** Step keys whose text has finished animating (or never needed to). */
|
||||
let animatedKeys = new SvelteSet<string>();
|
||||
/** key → currently-displayed substring during animation. */
|
||||
let animatingTexts = new SvelteMap<string, string>();
|
||||
|
||||
function resolveI18nText(line: ToolLineUi): string | null {
|
||||
if (line.shape !== 'i18n') return null;
|
||||
return t.get(line.key, line.vars ?? {});
|
||||
}
|
||||
|
||||
function startTypewriter(key: string, fullText: string) {
|
||||
if (fullText.length === 0) {
|
||||
animatedKeys.add(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Squeeze the per-char delay so even long lines finish under the cap.
|
||||
const perCharDelay = Math.max(
|
||||
15,
|
||||
Math.min(TYPEWRITER_MS_PER_CHAR, Math.floor(TYPEWRITER_MAX_MS / fullText.length))
|
||||
);
|
||||
let revealed = 0;
|
||||
animatingTexts.set(key, '');
|
||||
|
||||
const handle = setInterval(() => {
|
||||
revealed += 1;
|
||||
animatingTexts.set(key, fullText.slice(0, revealed));
|
||||
|
||||
if (revealed >= fullText.length) {
|
||||
clearInterval(handle);
|
||||
animatingTexts.delete(key);
|
||||
animatedKeys.add(key);
|
||||
}
|
||||
}, perCharDelay);
|
||||
}
|
||||
|
||||
// Detect newly arrived steps every render. Reading `steps` ties this effect
|
||||
// to step list growth.
|
||||
$effect(() => {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const key = stepRowKey(steps[i], i);
|
||||
if (animatedKeys.has(key) || animatingTexts.has(key)) continue;
|
||||
|
||||
const text = resolveI18nText(steps[i].line);
|
||||
if (text === null) {
|
||||
// Non-i18n shape — render in full, skip the typewriter.
|
||||
animatedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
startTypewriter(key, text);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Auto-scroll ───────────────────────────────────────────────────────────
|
||||
let stepsContainer: HTMLDivElement | undefined = $state();
|
||||
let isPinnedToBottom = $state(true);
|
||||
let lastSeenStepCount = 0;
|
||||
|
||||
function handleScroll() {
|
||||
if (!stepsContainer) return;
|
||||
|
||||
const distanceFromBottom = stepsContainer.scrollHeight - stepsContainer.scrollTop - stepsContainer.clientHeight;
|
||||
isPinnedToBottom = distanceFromBottom <= SCROLL_PIN_THRESHOLD;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (steps.length === lastSeenStepCount) return;
|
||||
|
||||
const grew = steps.length > lastSeenStepCount;
|
||||
lastSeenStepCount = steps.length;
|
||||
|
||||
if (grew && isPinnedToBottom) {
|
||||
void tick().then(() => {
|
||||
if (stepsContainer) {
|
||||
stepsContainer.scrollTop = stepsContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const completedCount = $derived(steps.filter((s) => s.status === 'completed').length);
|
||||
const totalCount = $derived(steps.length);
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background rounded-lg border">
|
||||
<div class="border-b px-3 py-2">
|
||||
<h4 class="text-sm font-semibold">{$t(titleKey)}</h4>
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t('ai_assistant.plan_progress', { completed: completedCount, total: totalCount })}
|
||||
</p>
|
||||
<!-- Header: title, progress count, and the prominent "now doing" strip -->
|
||||
<div class="space-y-1.5 border-b px-3 py-2">
|
||||
{#if showTitle}
|
||||
<h4 class="text-sm font-semibold">{$t(titleKey)}</h4>
|
||||
{/if}
|
||||
{#if totalCount > 0}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t('ai_assistant.plan_progress', { completed: completedCount, total: totalCount })}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if currentActionLine && !isStopped}
|
||||
<div class="ui:bg-muted/40 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
|
||||
<LoaderIcon size={14} class="ui:text-primary shrink-0 animate-spin" />
|
||||
<span class="ui:text-muted-foreground shrink-0 text-[10px] font-medium tracking-wide uppercase">
|
||||
{$t('ai_assistant.run_monitor.now_doing')}
|
||||
</span>
|
||||
<span class="ui:text-foreground min-w-0 flex-1 truncate">
|
||||
<ToolLine line={currentActionLine} {courseId} {onNavigate} />
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="max-h-75 space-y-0.5 overflow-y-auto p-2">
|
||||
<div bind:this={stepsContainer} onscroll={handleScroll} class="max-h-75 space-y-0.5 overflow-y-auto p-2">
|
||||
{#each steps as step, i (stepRowKey(step, i))}
|
||||
<div class="flex items-center gap-2 rounded px-2 py-1 text-xs {step.indent ? 'ml-4' : ''}">
|
||||
{@const rowKey = stepRowKey(step, i)}
|
||||
{@const animatingSlice = animatingTexts.get(rowKey)}
|
||||
{@const isAnimating = animatingSlice !== undefined && step.line.shape === 'i18n'}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded px-2 py-1 text-xs {step.indent ? 'ml-4' : ''} {step.status ===
|
||||
'in_progress'
|
||||
? 'animate-pulse'
|
||||
: ''}"
|
||||
>
|
||||
{#if step.status === 'completed'}
|
||||
<CheckIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<CheckIcon size={14} class="ui:text-primary shrink-0" />
|
||||
{:else if step.status === 'in_progress'}
|
||||
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
|
||||
<LoaderIcon size={14} class="ui:text-primary shrink-0 animate-spin" />
|
||||
{:else if step.status === 'failed'}
|
||||
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
|
||||
<AlertCircleIcon size={14} class="shrink-0 text-red-500" />
|
||||
{:else}
|
||||
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
<CircleIcon size={14} class="ui:text-muted-foreground shrink-0" />
|
||||
{/if}
|
||||
<span class={step.status === 'completed' ? '' : step.status === 'pending' ? 'ui:text-muted-foreground' : ''}>
|
||||
<ToolLine line={step.line} {courseId} {onNavigate} />
|
||||
{#if isAnimating}
|
||||
{animatingSlice}
|
||||
{:else}
|
||||
<ToolLine line={step.line} {courseId} {onNavigate} />
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -75,12 +209,6 @@
|
||||
<p class="text-xs text-red-600 dark:text-red-400">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if currentActionLine && !isStopped}
|
||||
<div class="ui:text-muted-foreground text-xs leading-snug">
|
||||
<ToolLine line={currentActionLine} {courseId} {onNavigate} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isStopped}
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('ai_assistant.stopped_content_kept')}</p>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CheckCircleIcon from '@lucide/svelte/icons/check-circle-2';
|
||||
import XCircleIcon from '@lucide/svelte/icons/x-circle';
|
||||
import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle';
|
||||
import PauseIcon from '@lucide/svelte/icons/pause-circle';
|
||||
|
||||
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
|
||||
|
||||
interface RunMonitorState {
|
||||
titleKey: string;
|
||||
steps: ProgressStep[];
|
||||
currentActionLine?: ToolLineUi;
|
||||
isStopped: boolean;
|
||||
error?: string;
|
||||
canCancel: boolean;
|
||||
canRetry: boolean;
|
||||
canResume: boolean;
|
||||
}
|
||||
|
||||
/** Queued for longer than this with no worker pickup = surface "worker not responding". */
|
||||
const STUCK_QUEUED_THRESHOLD_MS = 30_000;
|
||||
|
||||
interface Props {
|
||||
state: RunMonitorState;
|
||||
courseId: string;
|
||||
/** Underlying run status; used to enable Dismiss only on canceled runs. */
|
||||
runStatus?: string | null;
|
||||
/** When run is `queued`, the ISO timestamp it was enqueued. Lets us flag stuck-queued runs. */
|
||||
queuedSince?: string | null;
|
||||
onCancel: () => void;
|
||||
onResume: () => void;
|
||||
onRetry: () => void;
|
||||
onDismiss: () => void;
|
||||
onRetryStuck?: () => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
state: runState,
|
||||
courseId,
|
||||
runStatus = null,
|
||||
queuedSince = null,
|
||||
onCancel,
|
||||
onResume,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
onRetryStuck,
|
||||
onMentionClick
|
||||
}: Props = $props();
|
||||
|
||||
const isCanceled = $derived(runStatus === 'canceled');
|
||||
|
||||
// `now` ticks every 5s so the stuck-queued detector keeps re-evaluating even
|
||||
// when the run row itself isn't changing (which is exactly the symptom we're
|
||||
// trying to catch — the worker isn't moving the row past `queued`).
|
||||
let now = $state(Date.now());
|
||||
onMount(() => {
|
||||
const id = setInterval(() => (now = Date.now()), 5_000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
const isStuckQueued = $derived.by(() => {
|
||||
if (runStatus !== 'queued' || !queuedSince) return false;
|
||||
const queuedAt = Date.parse(queuedSince);
|
||||
if (!Number.isFinite(queuedAt)) return false;
|
||||
return now - queuedAt > STUCK_QUEUED_THRESHOLD_MS;
|
||||
});
|
||||
|
||||
// Two-step Dismiss: first click reveals a confirm/cancel pair so the user
|
||||
// can't accidentally walk away from a canceled run and lose the Resume button.
|
||||
let isConfirmingDismiss = $state(false);
|
||||
|
||||
function requestDismiss() {
|
||||
isConfirmingDismiss = true;
|
||||
}
|
||||
|
||||
function confirmDismiss() {
|
||||
isConfirmingDismiss = false;
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
function cancelDismiss() {
|
||||
isConfirmingDismiss = false;
|
||||
}
|
||||
|
||||
// The titleKey already covers running / completed / failed / canceled — pick
|
||||
// a matching icon so the pane state is readable at a glance without parsing
|
||||
// the text.
|
||||
const StatusIcon = $derived.by(() => {
|
||||
if (runState.error || runState.canRetry) return XCircleIcon;
|
||||
if (runState.canResume) return PauseIcon;
|
||||
if (runState.isStopped) return AlertTriangleIcon;
|
||||
if (runState.titleKey === 'ai_assistant.run_completed') return CheckCircleIcon;
|
||||
return LoaderIcon;
|
||||
});
|
||||
|
||||
const isWorking = $derived(!runState.isStopped && runState.titleKey !== 'ai_assistant.run_completed');
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ui:bg-background flex w-full flex-col gap-3 border-t px-4 py-3"
|
||||
role="region"
|
||||
aria-label={$t('ai_assistant.run_monitor_aria')}
|
||||
>
|
||||
<div class="ui:text-foreground flex items-center gap-2 text-sm font-medium">
|
||||
<StatusIcon size={16} class={isWorking ? 'ui:text-primary animate-spin' : 'ui:text-muted-foreground'} />
|
||||
<span>{$t(runState.titleKey)}</span>
|
||||
</div>
|
||||
|
||||
<ProgressCard
|
||||
titleKey={runState.titleKey}
|
||||
steps={runState.steps}
|
||||
currentActionLine={runState.currentActionLine}
|
||||
{courseId}
|
||||
onNavigate={onMentionClick}
|
||||
onStop={undefined}
|
||||
isStopped={runState.isStopped}
|
||||
error={runState.error}
|
||||
showTitle={false}
|
||||
/>
|
||||
|
||||
{#if isStuckQueued}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
||||
role="alert"
|
||||
>
|
||||
<AlertTriangleIcon size={14} class="mt-0.5 shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium">{$t('ai_assistant.run_monitor.stuck_queued.title')}</p>
|
||||
<p class="mt-0.5">{$t('ai_assistant.run_monitor.stuck_queued.body')}</p>
|
||||
</div>
|
||||
{#if onRetryStuck}
|
||||
<Button size="sm" variant="outline" onclick={onRetryStuck} class="shrink-0">
|
||||
{$t('ai_assistant.retry')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if runState.canCancel}
|
||||
<Button size="sm" variant="outline" onclick={onCancel} class="flex-1">
|
||||
{$t('ai_assistant.run_monitor.cancel')}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if runState.canRetry}
|
||||
<Button size="sm" variant="outline" onclick={onRetry} class="flex-1">
|
||||
{$t('ai_assistant.retry')}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if runState.canResume}
|
||||
<Button size="sm" variant="default" onclick={onResume} class="flex-1">
|
||||
{$t('ai_assistant.resume')}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if isCanceled && !isConfirmingDismiss}
|
||||
<Button size="sm" variant="ghost" onclick={requestDismiss} class="flex-1">
|
||||
{$t('ai_assistant.run_monitor.dismiss')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isCanceled && isConfirmingDismiss}
|
||||
<div class="ui:bg-muted/30 flex flex-col gap-2 rounded border px-3 py-2 text-xs">
|
||||
<p class="ui:text-foreground">{$t('ai_assistant.run_monitor.dismiss_confirm_body')}</p>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onclick={cancelDismiss} class="flex-1">
|
||||
{$t('ai_assistant.run_monitor.dismiss_cancel')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onclick={confirmDismiss} class="flex-1">
|
||||
{$t('ai_assistant.run_monitor.dismiss_confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="ui:text-muted-foreground text-[11px] leading-snug">
|
||||
{$t('ai_assistant.run_monitor.input_disabled_hint')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Course, CourseContentItem } from '$features/course/utils/types';
|
||||
import { getCourseContent } from '$features/course/utils/content';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
|
||||
function findContentItem(
|
||||
course: Course | null,
|
||||
contentId: string,
|
||||
contentType: typeof ContentType.Lesson | typeof ContentType.Exercise
|
||||
): CourseContentItem | undefined {
|
||||
const content = getCourseContent(course);
|
||||
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
|
||||
|
||||
return items.find((item) => item.id === contentId && item.type === contentType);
|
||||
}
|
||||
|
||||
export function isCourseContentLockedForStudent(
|
||||
course: Course | null,
|
||||
contentId: string | undefined,
|
||||
contentType: typeof ContentType.Lesson | typeof ContentType.Exercise | null
|
||||
): boolean {
|
||||
if (!contentId || !contentType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const item = findContentItem(course, contentId, contentType);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (item.isUnlocked ?? true) === false;
|
||||
}
|
||||
|
||||
export function getContentAskAiBarWidthClass(options: {
|
||||
lessonId: string | undefined;
|
||||
isLessonEditMode: boolean;
|
||||
}): string {
|
||||
if (options.lessonId) {
|
||||
return options.isLessonEditMode ? 'mx-auto w-full lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl';
|
||||
}
|
||||
|
||||
return 'mx-auto w-full md:max-w-3xl';
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import type {
|
||||
AgentRunDetail,
|
||||
AgentRunStep,
|
||||
AiAssistantMessage,
|
||||
AiAssistantRunSummaryMetadata,
|
||||
AiAssistantRunSummaryStatus,
|
||||
RunChangeAction,
|
||||
RunChangedItem
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Look up an exercise title by id. Caller supplies the function — keeps this
|
||||
* file decoupled from any specific course shape on the dashboard side.
|
||||
*/
|
||||
export type ExerciseTitleLookup = (exerciseId: string) => string | undefined;
|
||||
|
||||
const STATUSES_TERMINAL: ReadonlySet<AiAssistantRunSummaryStatus> = new Set([
|
||||
'completed',
|
||||
'failed',
|
||||
'canceled',
|
||||
'paused'
|
||||
]);
|
||||
|
||||
export function isRunSummaryStatus(status: string | undefined | null): status is AiAssistantRunSummaryStatus {
|
||||
return !!status && STATUSES_TERMINAL.has(status as AiAssistantRunSummaryStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate tool-call counts from a run's checkpointed steps into the four
|
||||
* categories the summary message surfaces (sections / lessons / exercises /
|
||||
* question blocks). Anything else the worker did is intentionally omitted —
|
||||
* the message should read at a glance, not log every tool call.
|
||||
*/
|
||||
function countToolCalls(runDetail: AgentRunDetail): AiAssistantRunSummaryMetadata['counts'] {
|
||||
const counts: Required<AiAssistantRunSummaryMetadata['counts']> = {
|
||||
sections: 0,
|
||||
lessons: 0,
|
||||
exercises: 0,
|
||||
questionBlocks: 0
|
||||
};
|
||||
|
||||
for (const step of runDetail.steps) {
|
||||
if (step.status !== 'completed') continue;
|
||||
const toolName = step.stepType.startsWith('tool:') ? step.stepType.slice('tool:'.length) : null;
|
||||
if (!toolName) continue;
|
||||
|
||||
if (toolName === 'create_section') counts.sections += 1;
|
||||
else if (toolName === 'create_lesson') counts.lessons += 1;
|
||||
else if (toolName === 'create_exercise') counts.exercises += 1;
|
||||
else if (toolName === 'add_questions') counts.questionBlocks += 1;
|
||||
}
|
||||
|
||||
// Trim zeros so consumers can render only what was actually created.
|
||||
const trimmed: AiAssistantRunSummaryMetadata['counts'] = {};
|
||||
if (counts.sections) trimmed.sections = counts.sections;
|
||||
if (counts.lessons) trimmed.lessons = counts.lessons;
|
||||
if (counts.exercises) trimmed.exercises = counts.exercises;
|
||||
if (counts.questionBlocks) trimmed.questionBlocks = counts.questionBlocks;
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getStepToolName(step: AgentRunStep): string | null {
|
||||
if (!step.stepType.startsWith('tool:')) return null;
|
||||
return step.stepType.slice('tool:'.length);
|
||||
}
|
||||
|
||||
function getStepResult(step: AgentRunStep): Record<string, unknown> | null {
|
||||
const output = step.output as { result?: unknown } | null;
|
||||
if (!output || typeof output !== 'object') return null;
|
||||
const result = output.result;
|
||||
if (!result || typeof result !== 'object') return null;
|
||||
return result as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStepArgs(step: AgentRunStep): Record<string, unknown> | null {
|
||||
const input = step.input as { args?: unknown } | null;
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
const args = input.args;
|
||||
if (!args || typeof args !== 'object') return null;
|
||||
return args as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an action to an existing changed item, dedup-aware. `questions_added`
|
||||
* and `questions_updated` accumulate counts; the rest are flagged once.
|
||||
*/
|
||||
function mergeAction(item: RunChangedItem, action: RunChangeAction): void {
|
||||
if (action.kind === 'questions_added' || action.kind === 'questions_updated') {
|
||||
const existing = item.actions.find((a) => a.kind === action.kind) as
|
||||
| { kind: typeof action.kind; count: number }
|
||||
| undefined;
|
||||
|
||||
if (existing) {
|
||||
existing.count += action.count;
|
||||
return;
|
||||
}
|
||||
|
||||
item.actions.push(action);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.actions.some((a) => a.kind === action.kind)) return;
|
||||
item.actions.push(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-agnostic aggregator. Caller hands a stream of `{ tool, result, args }`
|
||||
* tuples (one per completed tool call) and gets back the per-item summary used
|
||||
* by the changes card. Lets the same mapping table serve both run steps and
|
||||
* inline chat-mode tool parts.
|
||||
*/
|
||||
interface ToolCallView {
|
||||
tool: string;
|
||||
result: Record<string, unknown> | null;
|
||||
args: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function aggregateToolCalls(
|
||||
calls: Iterable<ToolCallView>,
|
||||
resolveExerciseTitle: ExerciseTitleLookup
|
||||
): RunChangedItem[] {
|
||||
const byKey = new Map<string, RunChangedItem>();
|
||||
|
||||
function upsert(
|
||||
targetType: 'lesson' | 'exercise',
|
||||
targetId: string,
|
||||
titleFromStep: string | null,
|
||||
action: RunChangeAction,
|
||||
tokensDelta: number | null
|
||||
): void {
|
||||
const key = `${targetType}:${targetId}`;
|
||||
let item = byKey.get(key);
|
||||
|
||||
if (!item) {
|
||||
const title =
|
||||
titleFromStep ?? (targetType === 'exercise' ? (resolveExerciseTitle(targetId) ?? 'Exercise') : 'Lesson');
|
||||
item = { targetType, targetId, title, actions: [] };
|
||||
byKey.set(key, item);
|
||||
} else if (titleFromStep && (item.title === 'Lesson' || item.title === 'Exercise')) {
|
||||
item.title = titleFromStep;
|
||||
}
|
||||
|
||||
mergeAction(item, action);
|
||||
|
||||
if (tokensDelta !== null && tokensDelta > 0) {
|
||||
item.tokens = (item.tokens ?? 0) + tokensDelta;
|
||||
}
|
||||
}
|
||||
|
||||
for (const { tool, result, args } of calls) {
|
||||
if (tool === 'create_lesson' || tool === 'update_lesson') {
|
||||
const id = asString(result?.id);
|
||||
if (!id) continue;
|
||||
upsert(
|
||||
'lesson',
|
||||
id,
|
||||
asString(result?.title),
|
||||
tool === 'create_lesson' ? { kind: 'created' } : { kind: 'metadata_updated' },
|
||||
null
|
||||
);
|
||||
} else if (tool === 'update_lesson_content') {
|
||||
const id = asString(result?.lessonId);
|
||||
if (!id) continue;
|
||||
const contentLength = asNumber(result?.contentLength);
|
||||
const tokens = contentLength !== null ? Math.ceil(contentLength / 4) : null;
|
||||
upsert('lesson', id, asString(result?.lessonTitle), { kind: 'content_written' }, tokens);
|
||||
} else if (tool === 'create_exercise' || tool === 'update_exercise') {
|
||||
const id = asString(result?.id);
|
||||
if (!id) continue;
|
||||
upsert(
|
||||
'exercise',
|
||||
id,
|
||||
asString(result?.title),
|
||||
tool === 'create_exercise' ? { kind: 'created' } : { kind: 'metadata_updated' },
|
||||
null
|
||||
);
|
||||
} else if (tool === 'add_questions') {
|
||||
const id = asString(result?.exerciseId);
|
||||
if (!id) continue;
|
||||
const count = asNumber(result?.addedCount) ?? 0;
|
||||
if (count <= 0) continue;
|
||||
upsert('exercise', id, asString(result?.exerciseTitle), { kind: 'questions_added', count }, count);
|
||||
} else if (tool === 'update_questions') {
|
||||
const id = asString(result?.exerciseId);
|
||||
if (!id) continue;
|
||||
const count = asNumber(result?.updatedCount) ?? 0;
|
||||
if (count <= 0) continue;
|
||||
upsert('exercise', id, asString(result?.exerciseTitle), { kind: 'questions_updated', count }, count);
|
||||
} else if (tool === 'create_exercise_section' || tool === 'update_exercise_section') {
|
||||
const id = asString(args?.exerciseId);
|
||||
if (!id) continue;
|
||||
upsert(
|
||||
'exercise',
|
||||
id,
|
||||
null,
|
||||
tool === 'create_exercise_section' ? { kind: 'section_added' } : { kind: 'section_edited' },
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a run's completed tool steps and aggregate them into a list of unique
|
||||
* lesson/exercise items with the actions performed on each. Sections,
|
||||
* landing-page edits, structure reads, etc. are intentionally dropped — the
|
||||
* UI surface is "what content did the teacher just get".
|
||||
*/
|
||||
export function extractChangedItems(
|
||||
runDetail: AgentRunDetail,
|
||||
resolveExerciseTitle: ExerciseTitleLookup = () => undefined
|
||||
): RunChangedItem[] {
|
||||
function* toCalls(): Generator<ToolCallView> {
|
||||
for (const step of runDetail.steps) {
|
||||
if (step.status !== 'completed') continue;
|
||||
const tool = getStepToolName(step);
|
||||
if (!tool) continue;
|
||||
yield { tool, result: getStepResult(step), args: getStepArgs(step) };
|
||||
}
|
||||
}
|
||||
|
||||
return aggregateToolCalls(toCalls(), resolveExerciseTitle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same aggregation, but sourced from inline chat-mode tool parts on
|
||||
* `AiAssistantMessage.parts`. Chat parts use `type: 'tool-<name>'`, place the
|
||||
* tool's return value directly on `part.output` (no `result` wrapper), and
|
||||
* carry tool args directly on `part.input` (no `args` wrapper) — so we adapt
|
||||
* to those shapes before delegating to the shared aggregator.
|
||||
*/
|
||||
export function extractChangedItemsFromMessages(
|
||||
messages: AiAssistantMessage[],
|
||||
resolveExerciseTitle: ExerciseTitleLookup = () => undefined
|
||||
): RunChangedItem[] {
|
||||
function* toCalls(): Generator<ToolCallView> {
|
||||
for (const message of messages) {
|
||||
const parts = (message as { parts?: unknown }).parts;
|
||||
if (!Array.isArray(parts)) continue;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!isRecord(part)) continue;
|
||||
const type = typeof part.type === 'string' ? part.type : '';
|
||||
if (!type.startsWith('tool-')) continue;
|
||||
if (part.state !== 'output-available') continue;
|
||||
|
||||
const tool = type.slice('tool-'.length);
|
||||
const result = isRecord(part.output) ? part.output : null;
|
||||
const args = isRecord(part.input) ? part.input : null;
|
||||
yield { tool, result, args };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aggregateToolCalls(toCalls(), resolveExerciseTitle);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export interface BuildRunSummaryInput {
|
||||
runDetail: AgentRunDetail;
|
||||
resolveExerciseTitle?: ExerciseTitleLookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the metadata block that gets attached to the synthetic summary
|
||||
* message. Caller is responsible for formatting it into i18n text.
|
||||
*/
|
||||
export function buildRunSummaryMetadata({
|
||||
runDetail,
|
||||
resolveExerciseTitle
|
||||
}: BuildRunSummaryInput): AiAssistantRunSummaryMetadata | null {
|
||||
const status = runDetail.run.status;
|
||||
if (!isRunSummaryStatus(status)) return null;
|
||||
|
||||
const changes = extractChangedItems(runDetail, resolveExerciseTitle);
|
||||
|
||||
return {
|
||||
runId: runDetail.run.id,
|
||||
status,
|
||||
counts: countToolCalls(runDetail),
|
||||
changes: changes.length > 0 ? changes : undefined,
|
||||
finishedAt: runDetail.run.finishedAt ?? new Date().toISOString(),
|
||||
error: runDetail.run.lastError?.message
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRunSummaryCounts(counts: AiAssistantRunSummaryMetadata['counts']): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (counts.sections) parts.push(`${counts.sections} section${counts.sections === 1 ? '' : 's'}`);
|
||||
if (counts.lessons) parts.push(`${counts.lessons} lesson${counts.lessons === 1 ? '' : 's'}`);
|
||||
if (counts.exercises) parts.push(`${counts.exercises} exercise${counts.exercises === 1 ? '' : 's'}`);
|
||||
if (counts.questionBlocks) {
|
||||
parts.push(`${counts.questionBlocks} question block${counts.questionBlocks === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
@@ -51,6 +51,18 @@ export function clearInitialChatModel() {
|
||||
initialChatModel.set(null);
|
||||
}
|
||||
|
||||
/** Opens the AI assistant panel and sends `prompt` as the next user message. */
|
||||
export function sendPromptToAssistant(prompt: string) {
|
||||
const trimmed = prompt.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInitialChatPrompt(trimmed);
|
||||
openAiAssistant();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending composer action picked up by the chat component:
|
||||
* - `append` adds the text to whatever is already in the input (panel was open).
|
||||
|
||||
@@ -34,12 +34,59 @@ export interface AiAssistantCompactionMetadata {
|
||||
originalMessageCount: number;
|
||||
}
|
||||
|
||||
export type AiAssistantRunSummaryStatus = 'completed' | 'failed' | 'canceled' | 'paused';
|
||||
|
||||
/**
|
||||
* Per-item action describing what the agent did to a lesson or exercise.
|
||||
* `questions_added` / `questions_updated` carry counts; the rest are atomic
|
||||
* flags that dedupe by `kind` when aggregating multiple tool calls against
|
||||
* the same target.
|
||||
*/
|
||||
export type RunChangeAction =
|
||||
| { kind: 'created' }
|
||||
| { kind: 'metadata_updated' }
|
||||
| { kind: 'content_written' }
|
||||
| { kind: 'questions_added'; count: number }
|
||||
| { kind: 'questions_updated'; count: number }
|
||||
| { kind: 'section_added' }
|
||||
| { kind: 'section_edited' };
|
||||
|
||||
export interface RunChangedItem {
|
||||
targetType: 'lesson' | 'exercise';
|
||||
targetId: string;
|
||||
title: string;
|
||||
/** Aggregated actions; `questions_*` counts accumulate. */
|
||||
actions: RunChangeAction[];
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks an assistant message as the terminal summary of a durable run.
|
||||
* Used to (1) render the summary card and (2) prevent duplicate injection
|
||||
* after a refresh when the message is already persisted in the conversation.
|
||||
*/
|
||||
export interface AiAssistantRunSummaryMetadata {
|
||||
runId: string;
|
||||
status: AiAssistantRunSummaryStatus;
|
||||
counts: {
|
||||
sections?: number;
|
||||
lessons?: number;
|
||||
exercises?: number;
|
||||
questionBlocks?: number;
|
||||
};
|
||||
/** Per-item changes for the top-of-chat ChangesCard; empty array means no lesson/exercise touched. */
|
||||
changes?: RunChangedItem[];
|
||||
finishedAt: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantMessageMetadata {
|
||||
attachment?: AiAssistantMessageAttachment;
|
||||
tokenUsage?: AiAssistantMessageTokenUsage;
|
||||
continuation?: AiAssistantMessageContinuation;
|
||||
template?: AiAssistantTemplateMetadata;
|
||||
compaction?: AiAssistantCompactionMetadata;
|
||||
runSummary?: AiAssistantRunSummaryMetadata;
|
||||
}
|
||||
|
||||
export type AiAssistantMessage = UIMessage<AiAssistantMessageMetadata>;
|
||||
@@ -83,3 +130,20 @@ export type CompactConversationSuccess = Extract<InferResponseType<CompactConver
|
||||
export type GenerateCourseTitleRequest = (typeof classroomio.agent)['generate-course-title']['$post'];
|
||||
export type GenerateCourseTitleSuccess = Extract<InferResponseType<GenerateCourseTitleRequest>, { success: true }>;
|
||||
export type GenerateCourseTitleData = GenerateCourseTitleSuccess['data'];
|
||||
|
||||
export type AgentRunsListRequest = typeof classroomio.agent.runs.$get;
|
||||
export type AgentRunsListSuccess = Extract<InferResponseType<AgentRunsListRequest>, { success: true }>;
|
||||
export type AgentRunsListData = AgentRunsListSuccess['data'];
|
||||
export type AgentRunSummary = AgentRunsListData[number];
|
||||
|
||||
export type AgentRunCreateRequest = typeof classroomio.agent.runs.$post;
|
||||
export type AgentRunCreateSuccess = Extract<InferResponseType<AgentRunCreateRequest>, { success: true }>;
|
||||
export type AgentRunDetail = AgentRunCreateSuccess['data'];
|
||||
export type AgentRun = AgentRunDetail['run'];
|
||||
export type AgentRunStep = AgentRunDetail['steps'][number];
|
||||
export type AgentRunEvent = AgentRunDetail['events'][number];
|
||||
|
||||
export type AgentRunGetRequest = (typeof classroomio.agent.runs)[':runId']['$get'];
|
||||
export type AgentRunCancelRequest = (typeof classroomio.agent.runs)[':runId']['cancel']['$post'];
|
||||
export type AgentRunRetryRequest = (typeof classroomio.agent.runs)[':runId']['retry']['$post'];
|
||||
export type AgentRunResumeRequest = (typeof classroomio.agent.runs)[':runId']['resume']['$post'];
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script module lang="ts">
|
||||
let pending: Promise<typeof import('@cio/ui/base/chart')> | null = null;
|
||||
function loadChart() {
|
||||
pending ??= import('@cio/ui/base/chart');
|
||||
return pending;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Chart from '@cio/ui/base/chart';
|
||||
import type * as Chart from '@cio/ui/base/chart';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
|
||||
@@ -42,9 +50,11 @@
|
||||
<Spinner class="ui:text-muted-foreground size-6" />
|
||||
</div>
|
||||
{:else if hasData && data}
|
||||
<Chart.ChartContainer class="h-[280px] w-full" config={chartConfig}>
|
||||
<Chart.AreaChart data={data.sparkline} x="date" axis="x" {series} />
|
||||
</Chart.ChartContainer>
|
||||
{#await loadChart() then C}
|
||||
<C.ChartContainer class="h-[280px] w-full" config={chartConfig}>
|
||||
<C.AreaChart data={data.sparkline} x="date" axis="x" {series} />
|
||||
</C.ChartContainer>
|
||||
{/await}
|
||||
{:else}
|
||||
<Empty icon={TrendingUpIcon} title={$t('analytics.trend.empty')} class="h-[280px]" />
|
||||
{/if}
|
||||
|
||||
@@ -66,11 +66,11 @@ class AppInitApi extends BaseApi {
|
||||
logContext: 'fetching account',
|
||||
onSuccess: (data) => {
|
||||
this.data = data;
|
||||
licenseApi.setFeatures(data.licenseFeatures);
|
||||
this.setupStores(params);
|
||||
licenseApi.syncFromAccount(data.licenseFeatures, get(currentOrg));
|
||||
setupAnalyticsBasedOnLicense(
|
||||
data.profile?.id ? { id: data.profile.id, email: data.profile.email, name: data.profile.fullname } : undefined
|
||||
);
|
||||
this.setupStores(params);
|
||||
this.setUserAnalytics();
|
||||
this.routeUserToNextPage(params);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getFirstOrg, getOrgBySiteName, getOrgsByCustomDomain } from '$features/org/api/org.server';
|
||||
|
||||
import { dev } from '$app/environment';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
@@ -53,15 +54,10 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
|
||||
// Custom domain
|
||||
if (isURLCustomDomain(url)) {
|
||||
console.log('it is custom domain');
|
||||
const apiKeyHeaders = getApiKeyHeaders();
|
||||
const orgs = await getOrgsByCustomDomain(url.host, true, apiKeyHeaders);
|
||||
console.log('orgs', orgs);
|
||||
const org = await lookupOrgFromWorker(url.origin, { customDomain: url.host });
|
||||
|
||||
if (!orgs || orgs.length === 0) {
|
||||
return response;
|
||||
}
|
||||
if (!org) return response;
|
||||
|
||||
const org = orgs[0];
|
||||
response.org = org as AccountOrg;
|
||||
response.isOrgSite = true;
|
||||
response.orgSiteName = response.org?.siteName || '';
|
||||
@@ -82,9 +78,7 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
|
||||
response.orgSiteName = debugMode ? _orgSiteName : subdomain;
|
||||
|
||||
if (response.orgSiteName) {
|
||||
const apiKeyHeaders = getApiKeyHeaders();
|
||||
const org = await getOrgBySiteName(response.orgSiteName, apiKeyHeaders);
|
||||
response.org = org ?? null;
|
||||
response.org = await lookupOrgFromWorker(url.origin, { siteName: response.orgSiteName });
|
||||
}
|
||||
|
||||
const shouldDeleteCookie = !response.org && _orgSiteName;
|
||||
@@ -96,6 +90,47 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
|
||||
return response;
|
||||
}
|
||||
|
||||
// Resolve the org via the tenant-router Worker. We call the *same origin* the
|
||||
// browser hit (e.g. https://<tenant>.myclassroomio.com) because that host is
|
||||
// fronted by the Worker, which serves /internal/org-lookup from KV (≈5ms) or
|
||||
// Hyperdrive→Postgres on a miss (≈50ms). No API/Render hop, no extra env var.
|
||||
//
|
||||
// In dev / self-hosted there is no Worker in front, so fall back to the
|
||||
// direct API call (same path the code used before the Worker cache existed).
|
||||
async function lookupOrgFromWorker(
|
||||
origin: string,
|
||||
params: { siteName?: string; customDomain?: string }
|
||||
): Promise<AccountOrg | null> {
|
||||
if (dev || PUBLIC_IS_SELFHOSTED === 'true' || origin.includes('localhost')) {
|
||||
const apiKeyHeaders = getApiKeyHeaders();
|
||||
if (params.siteName) {
|
||||
return (await getOrgBySiteName(params.siteName, apiKeyHeaders)) ?? null;
|
||||
}
|
||||
if (params.customDomain) {
|
||||
const orgs = await getOrgsByCustomDomain(params.customDomain, true, apiKeyHeaders);
|
||||
return (orgs?.[0] as AccountOrg) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
if (params.siteName) qs.set('siteName', params.siteName);
|
||||
if (params.customDomain) qs.set('customDomain', params.customDomain);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${origin}/internal/org-lookup?${qs}`, {
|
||||
headers: getApiKeyHeaders().headers
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
const json = (await res.json()) as { success: boolean; data: AccountOrg | null };
|
||||
return json.success ? json.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isURLCustomDomain(url: URL) {
|
||||
if (url.host.includes('localhost')) {
|
||||
return false;
|
||||
|
||||
@@ -723,6 +723,10 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
* reflects the asset's new thumbnail without waiting for a reload.
|
||||
*/
|
||||
updateLessonVideoThumbnail(videoIndex: number, thumbnailUrl: string) {
|
||||
this.updateLessonVideoMetadata(videoIndex, { thumbnailUrl });
|
||||
}
|
||||
|
||||
updateLessonVideoMetadata(videoIndex: number, patch: Record<string, unknown>) {
|
||||
if (!this.lesson) return;
|
||||
|
||||
const videos = Array.isArray(this.lesson.videos) ? [...this.lesson.videos] : [];
|
||||
@@ -736,7 +740,7 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
|
||||
videos[videoIndex] = {
|
||||
...target,
|
||||
metadata: { ...metadata, thumbnailUrl }
|
||||
metadata: { ...metadata, ...patch }
|
||||
};
|
||||
|
||||
this.lesson = { ...this.lesson, videos };
|
||||
|
||||
+25
-13
@@ -1,5 +1,13 @@
|
||||
<script module lang="ts">
|
||||
let pending: Promise<typeof import('@cio/ui/base/chart')> | null = null;
|
||||
function loadChart() {
|
||||
pending ??= import('@cio/ui/base/chart');
|
||||
return pending;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Chart from '@cio/ui/base/chart';
|
||||
import type * as Chart from '@cio/ui/base/chart';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import BarChartIcon from '@lucide/svelte/icons/bar-chart-3';
|
||||
@@ -135,15 +143,17 @@
|
||||
<Spinner class="ui:text-muted-foreground size-6" />
|
||||
</div>
|
||||
{:else if hasStudentData}
|
||||
<Chart.ChartContainer class="h-[280px] w-full" config={progressChartConfig}>
|
||||
<Chart.BarChart
|
||||
data={progressChartData}
|
||||
x="group"
|
||||
axis="x"
|
||||
props={progressChartProps}
|
||||
series={progressSeries}
|
||||
/>
|
||||
</Chart.ChartContainer>
|
||||
{#await loadChart() then C}
|
||||
<C.ChartContainer class="h-[280px] w-full" config={progressChartConfig}>
|
||||
<C.BarChart
|
||||
data={progressChartData}
|
||||
x="group"
|
||||
axis="x"
|
||||
props={progressChartProps}
|
||||
series={progressSeries}
|
||||
/>
|
||||
</C.ChartContainer>
|
||||
{/await}
|
||||
{:else}
|
||||
<Empty icon={BarChartIcon} title={$t('analytics.no_progress_data')} class="h-[280px]" />
|
||||
{/if}
|
||||
@@ -160,9 +170,11 @@
|
||||
<Spinner class="ui:text-muted-foreground size-6" />
|
||||
</div>
|
||||
{:else if hasStudentData}
|
||||
<Chart.ChartContainer class="h-[280px] w-full" config={gradeChartConfig}>
|
||||
<Chart.BarChart data={gradeChartData} x="group" axis="x" props={gradeChartProps} series={gradeSeries} />
|
||||
</Chart.ChartContainer>
|
||||
{#await loadChart() then C}
|
||||
<C.ChartContainer class="h-[280px] w-full" config={gradeChartConfig}>
|
||||
<C.BarChart data={gradeChartData} x="group" axis="x" props={gradeChartProps} series={gradeSeries} />
|
||||
</C.ChartContainer>
|
||||
{/await}
|
||||
{:else}
|
||||
<Empty icon={BarChartIcon} title={$t('analytics.no_grade_data')} class="h-[280px]" />
|
||||
{/if}
|
||||
|
||||
@@ -1,44 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import BellIcon from '@lucide/svelte/icons/bell';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import { HoverableItem, PreviewIcon } from '@cio/ui/custom/moving-icons';
|
||||
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||
import * as Empty from '@cio/ui/base/empty';
|
||||
import { page } from '$app/state';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { getActiveCourseNavKey } from '$features/course/utils/functions';
|
||||
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CoursePublicBadge from './course-public-badge.svelte';
|
||||
import ViewAsStudentModal from './view-as-student-modal.svelte';
|
||||
|
||||
const siteName = $derived($currentOrg.siteName);
|
||||
const showCoursePublishBadge = $derived(!$isStudentExperience);
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
const activeNavKey = $derived(getActiveCourseNavKey(page.url.pathname, courseApi.course?.id ?? ''));
|
||||
|
||||
let viewAsStudentOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!siteName) return;
|
||||
|
||||
setupProgressApi.fetchSetupProgress(siteName);
|
||||
});
|
||||
|
||||
function handlePreview() {
|
||||
openCoursePreview({
|
||||
courseId: courseApi.course?.id ?? '',
|
||||
courseSlug: courseApi.course?.slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
@@ -74,42 +63,23 @@
|
||||
{$t('course.navItems.nav_ai_assistant')}
|
||||
</Button>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="outline" size="icon">
|
||||
<BellIcon class="custom rounded-full" />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content>
|
||||
<Empty.Root class="ui:from-muted/50 ui:to-background ui:h-full ui:bg-gradient-to-b ui:from-30%">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<BellIcon />
|
||||
</Empty.Media>
|
||||
<Empty.Title>No Notifications</Empty.Title>
|
||||
<Empty.Description>You're all caught up. New notifications will appear here.</Empty.Description>
|
||||
</Empty.Header>
|
||||
<Empty.Content>
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCcwIcon />
|
||||
{$t('common.refresh')}
|
||||
</Button>
|
||||
</Empty.Content>
|
||||
</Empty.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<IconButton
|
||||
onclick={handlePreview}
|
||||
disabled={!courseApi.course?.id}
|
||||
tooltip={$t('course.header.preview')}
|
||||
aria-label={$t('course.header.preview')}
|
||||
>
|
||||
<PreviewIcon {isHovered} size={16} />
|
||||
</IconButton>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{#if !$isStudentExperience}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (viewAsStudentOpen = true)}
|
||||
disabled={!courseApi.course?.id}
|
||||
aria-label={$t('course.header.view_as_student')}
|
||||
>
|
||||
<EyeIcon size={14} />
|
||||
<span class="hidden sm:inline">{$t('course.header.view_as_student')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ViewAsStudentModal
|
||||
bind:open={viewAsStudentOpen}
|
||||
courseSlug={courseApi.course?.slug}
|
||||
currentOrgDomain={$currentOrgDomain}
|
||||
/>
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
onValueChange={(nextValue) => onQuestionTypeChange(question.id, nextValue)}
|
||||
triggerQuestionType={question?.questionType}
|
||||
types={availableQuestionTypes}
|
||||
{selfPacedCourse}
|
||||
/>
|
||||
{/snippet}
|
||||
</ExerciseQuestion.QuestionRenderer>
|
||||
|
||||
+72
-39
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
|
||||
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { getExerciseEditorQuestionTypeLabel } from './question-type-utils';
|
||||
@@ -16,51 +15,85 @@
|
||||
onValueChange: (value: string) => void;
|
||||
triggerQuestionType: Question['questionType'] | undefined;
|
||||
types: QuestionTypeEntry[];
|
||||
selfPacedCourse?: boolean;
|
||||
}
|
||||
|
||||
let { value, onValueChange, triggerQuestionType, types, selfPacedCourse = false }: Props = $props();
|
||||
let { value, onValueChange, triggerQuestionType, types }: Props = $props();
|
||||
|
||||
const autoGradableTypes = $derived(types.filter((typeEntry) => typeEntry.autoGradable));
|
||||
const manuallyGradedTypes = $derived(types.filter((typeEntry) => !typeEntry.autoGradable));
|
||||
</script>
|
||||
|
||||
{#snippet sectionTitle({ title, description })}
|
||||
<div>
|
||||
<Select.Label>
|
||||
<p class="font-semibold">
|
||||
{$t(title)}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground ui:mt-1 ui:text-xs">
|
||||
{$t(description)}
|
||||
</p>
|
||||
</Select.Label>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet upgradeButton({ typeEntry })}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
|
||||
onclick={(clickEvent) => {
|
||||
clickEvent.preventDefault();
|
||||
clickEvent.stopPropagation();
|
||||
openUpgradeModal();
|
||||
}}
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
|
||||
>
|
||||
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
|
||||
<span>{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<Select.Root type="single" {value} {onValueChange}>
|
||||
<Select.Trigger class="w-full min-w-0 sm:w-[180px]">
|
||||
{getExerciseEditorQuestionTypeLabel(triggerQuestionType)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each types as typeEntry (typeEntry.key)}
|
||||
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(typeEntry.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
|
||||
onclick={(clickEvent) => {
|
||||
clickEvent.preventDefault();
|
||||
clickEvent.stopPropagation();
|
||||
openUpgradeModal();
|
||||
}}
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
|
||||
>
|
||||
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
|
||||
<span>{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<Select.Item value={typeEntry.id.toString()} label={getExerciseEditorQuestionTypeLabel(typeEntry)}>
|
||||
{#snippet children({ selected: _sel })}
|
||||
<span class="flex w-full min-w-0 items-center justify-between gap-2">
|
||||
<span class="truncate">{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
|
||||
{#if selfPacedCourse}
|
||||
<Badge
|
||||
variant={typeEntry.autoGradable ? 'success' : 'warning'}
|
||||
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
|
||||
>
|
||||
{typeEntry.autoGradable
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Select.Content class="max-h-[300px]!">
|
||||
<Select.Group>
|
||||
{@render sectionTitle({
|
||||
title: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable',
|
||||
description: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable_description'
|
||||
})}
|
||||
|
||||
{#each autoGradableTypes as typeEntry (typeEntry.key)}
|
||||
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(typeEntry.key)}
|
||||
{@render upgradeButton({ typeEntry })}
|
||||
{:else}
|
||||
<Select.Item value={typeEntry.id.toString()} label={getExerciseEditorQuestionTypeLabel(typeEntry)}>
|
||||
{getExerciseEditorQuestionTypeLabel(typeEntry)}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Group>
|
||||
|
||||
{#if manuallyGradedTypes.length > 0}
|
||||
<Select.Separator />
|
||||
|
||||
<Select.Group>
|
||||
{@render sectionTitle({
|
||||
title: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading',
|
||||
description:
|
||||
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading_description'
|
||||
})}
|
||||
|
||||
{#each manuallyGradedTypes as typeEntry (typeEntry.key)}
|
||||
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(typeEntry.key)}
|
||||
{@render upgradeButton({ typeEntry })}
|
||||
{:else}
|
||||
<Select.Item value={typeEntry.id.toString()} label={getExerciseEditorQuestionTypeLabel(typeEntry)}>
|
||||
{getExerciseEditorQuestionTypeLabel(typeEntry)}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Group>
|
||||
{/if}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
/**
|
||||
* Browser-side HLS encoder driven by Mediabunny.
|
||||
*
|
||||
* Default upload generates a source-capped 360p/720p ABR ladder. A manual
|
||||
* follow-up flow can add a p1080 rendition later without re-encoding the
|
||||
* base ladder. All transcoding happens on the user's machine via WebCodecs —
|
||||
* we never spend a server CPU-second on encoding, and the source MP4 is
|
||||
* never uploaded. The data path is `File → WebCodecs → R2`.
|
||||
*/
|
||||
|
||||
import {
|
||||
BlobSource,
|
||||
BufferTarget,
|
||||
CanvasSink,
|
||||
Conversion,
|
||||
HlsOutputFormat,
|
||||
Input,
|
||||
MpegTsOutputFormat,
|
||||
Mp4OutputFormat,
|
||||
Output,
|
||||
PathedTarget,
|
||||
ALL_FORMATS,
|
||||
type ConversionVideoOptions,
|
||||
type InputVideoTrack
|
||||
} from 'mediabunny';
|
||||
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
export type HlsEncodeStage = 'probing' | 'encoding' | 'uploading' | 'thumbnail' | 'finalizing' | 'done' | 'failed';
|
||||
|
||||
export interface HlsEncodeProgress {
|
||||
stage: HlsEncodeStage;
|
||||
/** 0–100 overall completion estimate. */
|
||||
percent: number;
|
||||
/** Total bytes uploaded so far (segments + playlists + audio + thumbs). */
|
||||
uploadedBytes: number;
|
||||
/** Current rung label for the encoding stage, e.g. "p720". */
|
||||
rung?: string;
|
||||
/** Files uploaded so far / total files uploaded. */
|
||||
uploadedCount: number;
|
||||
/** Optional error message when stage === 'failed'. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type Hls1080Status = 'none' | 'generating' | 'ready' | 'failed';
|
||||
|
||||
export interface HlsEncodeResult {
|
||||
assetId: string;
|
||||
manifestKey: string;
|
||||
thumbnailUrl?: string;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
hlsRenditions: string[];
|
||||
hls1080Status: Hls1080Status;
|
||||
}
|
||||
|
||||
export interface HlsRungConfig {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
videoBitrate: number;
|
||||
}
|
||||
|
||||
export const ALL_HLS_RUNGS: HlsRungConfig[] = [
|
||||
{ name: 'p360', width: 640, height: 360, videoBitrate: 800_000 },
|
||||
{ name: 'p720', width: 1280, height: 720, videoBitrate: 2_500_000 },
|
||||
{ name: 'p1080', width: 1920, height: 1080, videoBitrate: 5_000_000 }
|
||||
];
|
||||
|
||||
export const P1080_RUNG = ALL_HLS_RUNGS.find((rung) => rung.name === 'p1080')!;
|
||||
|
||||
const DEFAULT_MAX_RUNG_HEIGHT = 720;
|
||||
|
||||
/** Source-capped rungs up to `maxHeight` — never upscales beyond the source. */
|
||||
export function selectRungsForSource(track: InputVideoTrack, maxHeight = DEFAULT_MAX_RUNG_HEIGHT): HlsRungConfig[] {
|
||||
const sourceHeight = track.displayHeight ?? 0;
|
||||
|
||||
return ALL_HLS_RUNGS.filter((rung) => rung.height <= Math.min(maxHeight, sourceHeight));
|
||||
}
|
||||
|
||||
const AUDIO_BITRATE = 128_000;
|
||||
const HLS_TARGET_SEGMENT_SECONDS = 4;
|
||||
const THUMBNAIL_FRACTIONS = [0.3, 0.5, 0.7];
|
||||
/** Hard ceiling for a generated thumbnail (matches the server job). */
|
||||
const THUMBNAIL_MAX_BYTES = 500 * 1024;
|
||||
/** JPEG quality steps tried (best first) until a frame fits under the size cap. */
|
||||
const THUMBNAIL_QUALITY_LADDER = [0.9, 0.8, 0.7, 0.6, 0.5];
|
||||
|
||||
export interface EncodeAndUploadOptions {
|
||||
file: File;
|
||||
title?: string;
|
||||
rungs?: HlsRungConfig[];
|
||||
onProgress?: (progress: HlsEncodeProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface EncodeAndUpload1080Options {
|
||||
file: File;
|
||||
assetId: string;
|
||||
onProgress?: (progress: HlsEncodeProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end: encode the file to HLS, push to R2, finalize the asset.
|
||||
* Resolves with the new `assetId` and HLS manifest key.
|
||||
*/
|
||||
export async function encodeAndUploadHls(opts: EncodeAndUploadOptions): Promise<HlsEncodeResult> {
|
||||
const progress: HlsEncodeProgress = {
|
||||
stage: 'probing',
|
||||
percent: 0,
|
||||
uploadedBytes: 0,
|
||||
uploadedCount: 0
|
||||
};
|
||||
|
||||
function emit(patch: Partial<HlsEncodeProgress>) {
|
||||
Object.assign(progress, patch);
|
||||
opts.onProgress?.({ ...progress });
|
||||
}
|
||||
|
||||
let reservedAssetId: string | null = null;
|
||||
|
||||
try {
|
||||
if (typeof VideoEncoder === 'undefined') {
|
||||
throw new HlsEncoderError('webcodecs_unsupported', 'Browser missing WebCodecs encoder support');
|
||||
}
|
||||
|
||||
// --- Probe input ---------------------------------------------------
|
||||
const input = new Input({ source: new BlobSource(opts.file), formats: ALL_FORMATS });
|
||||
const duration = await input.computeDuration();
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
if (!videoTrack) {
|
||||
throw new HlsEncoderError('encode_failed', 'No video track found in source');
|
||||
}
|
||||
const aspectRatio = computeAspectRatioLabel(videoTrack);
|
||||
const sourceWidth = videoTrack.displayWidth ?? 0;
|
||||
const sourceHeight = videoTrack.displayHeight ?? 0;
|
||||
const rungs = opts.rungs ?? selectRungsForSource(videoTrack, DEFAULT_MAX_RUNG_HEIGHT);
|
||||
if (rungs.length === 0) {
|
||||
throw new HlsEncoderError('encode_failed', 'Source resolution is too low for HLS encoding');
|
||||
}
|
||||
|
||||
// --- Reserve assetId ----------------------------------------------
|
||||
const init = await classroomio.organization.assets.hls.init.$post({
|
||||
json: {
|
||||
fileName: opts.file.name,
|
||||
byteSize: opts.file.size,
|
||||
mimeType: opts.file.type || 'application/octet-stream',
|
||||
title: opts.title
|
||||
}
|
||||
});
|
||||
const initBody = await init.json();
|
||||
if (!('success' in initBody) || !initBody.success) {
|
||||
throw new HlsEncoderError('encode_failed', 'Failed to init HLS asset');
|
||||
}
|
||||
const { assetId } = initBody.data;
|
||||
reservedAssetId = assetId;
|
||||
|
||||
// --- Pre-presign every file Mediabunny will write ------------------
|
||||
// Segments are deterministic from rung name + segment index, so we
|
||||
// can predict the full path list before encoding starts and ask the
|
||||
// server for all presigned URLs in one round-trip. The upload step
|
||||
// then becomes a synchronous map lookup — no more one-POST-per-segment.
|
||||
emit({ stage: 'encoding', percent: 1 });
|
||||
const expectedPaths = predictHlsPaths(rungs, duration, Boolean(audioTrack));
|
||||
const presignedUrls = await batchPresignPaths(assetId, expectedPaths);
|
||||
|
||||
const uploader = new HlsUploader({
|
||||
assetId,
|
||||
presignedUrls,
|
||||
onUploaded: ({ totalBytes, count }) => {
|
||||
emit({ uploadedBytes: totalBytes, uploadedCount: count });
|
||||
},
|
||||
signal: opts.signal
|
||||
});
|
||||
|
||||
// Map a video playlist → rung folder name. Mediabunny counts the
|
||||
// audio playlist as its own entry, so `n` from `HlsOutputPlaylistInfo`
|
||||
// doesn't line up 1:1 with our rung array. We assign rung names in
|
||||
// the order video playlists arrive (which mirrors the order we passed
|
||||
// them into `Conversion.init`'s `video` array). Audio playlists land
|
||||
// under `audio/`. The Map keys by track identity so a duplicate
|
||||
// callback invocation for the same playlist resolves to the same path.
|
||||
const playlistNames = new Map<unknown, string>();
|
||||
let videoPlaylistsAssigned = 0;
|
||||
function resolvePlaylistFolder(tracks: { isVideoTrack: () => boolean }[]): string {
|
||||
const videoTrack = tracks.find((track) => track.isVideoTrack());
|
||||
if (!videoTrack) return 'audio';
|
||||
|
||||
const existing = playlistNames.get(videoTrack);
|
||||
if (existing) return existing;
|
||||
|
||||
const rung = rungs[videoPlaylistsAssigned];
|
||||
videoPlaylistsAssigned += 1;
|
||||
const name = rung?.name ?? `v${videoPlaylistsAssigned}`;
|
||||
playlistNames.set(videoTrack, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
const hlsOutput = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
targetDuration: HLS_TARGET_SEGMENT_SECONDS,
|
||||
getPlaylistPath: ({ tracks }) => `${resolvePlaylistFolder(tracks)}/playlist.m3u8`,
|
||||
// Segment paths are relative to the containing playlist's path.
|
||||
// Just the file name puts segments alongside their playlist
|
||||
// (e.g. `p720/seg-00001.ts`) rather than nested under a duplicate
|
||||
// rung folder.
|
||||
getSegmentPath: ({ n }) => `seg-${pad(n, 5)}.ts`
|
||||
}),
|
||||
target: new PathedTarget('master.m3u8', (request) => uploader.targetFor(request.path, request.mimeType))
|
||||
});
|
||||
|
||||
const videoVariants: ConversionVideoOptions[] = rungs.map((rung) => ({
|
||||
width: rung.width,
|
||||
height: rung.height,
|
||||
fit: 'contain',
|
||||
bitrate: rung.videoBitrate,
|
||||
// Keyframe at least every targetDuration so HLS segment boundaries land on key frames.
|
||||
keyFrameInterval: HLS_TARGET_SEGMENT_SECONDS,
|
||||
// Prefer the platform's hardware H.264 encoder (VideoToolbox / Media
|
||||
// Foundation / VAAPI) — typically several times faster than the
|
||||
// browser's software encoder. Falls back to software automatically
|
||||
// when no hardware encoder can satisfy the config.
|
||||
hardwareAcceleration: 'prefer-hardware'
|
||||
}));
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output: hlsOutput,
|
||||
video: videoVariants,
|
||||
audio: audioTrack ? { codec: 'aac', bitrate: AUDIO_BITRATE, numberOfChannels: 2 } : { discard: true },
|
||||
showWarnings: false
|
||||
});
|
||||
|
||||
conversion.onProgress = (fraction) => {
|
||||
// Reserve 70% of the bar for the encode itself; uploads finish behind it.
|
||||
emit({ percent: Math.min(72, 2 + Math.round(fraction * 70)) });
|
||||
};
|
||||
|
||||
if (!conversion.isValid) {
|
||||
throw new HlsEncoderError(
|
||||
'encode_failed',
|
||||
`Conversion invalid (${conversion.discardedTracks.length} discarded tracks)`
|
||||
);
|
||||
}
|
||||
|
||||
await conversion.execute();
|
||||
await uploader.flush();
|
||||
|
||||
// --- Audio-only output for transcription --------------------------
|
||||
let audioKey: string | undefined;
|
||||
if (audioTrack) {
|
||||
const audioOutput = new Output({
|
||||
format: new Mp4OutputFormat(),
|
||||
target: new BufferTarget({
|
||||
onFinalize: async (buffer) => {
|
||||
audioKey = await uploader.uploadSingle('audio.m4a', 'audio/mp4', buffer);
|
||||
}
|
||||
})
|
||||
});
|
||||
const audioConversion = await Conversion.init({
|
||||
input,
|
||||
output: audioOutput,
|
||||
video: { discard: true },
|
||||
audio: { codec: 'aac', bitrate: AUDIO_BITRATE, numberOfChannels: 1, sampleRate: 16_000 },
|
||||
showWarnings: false
|
||||
});
|
||||
if (audioConversion.isValid) {
|
||||
await audioConversion.execute();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Thumbnails ----------------------------------------------------
|
||||
emit({ stage: 'thumbnail', percent: 86 });
|
||||
const thumbnailUrls = await extractAndUploadThumbnails({
|
||||
videoTrack,
|
||||
duration,
|
||||
file: opts.file
|
||||
});
|
||||
|
||||
// --- Finalize -----------------------------------------------------
|
||||
emit({ stage: 'finalizing', percent: 96 });
|
||||
const finalize = await classroomio.organization.assets[':assetId'].hls.finalize.$post({
|
||||
param: { assetId },
|
||||
json: {
|
||||
manifestKey: `${assetId}/master.m3u8`,
|
||||
audioKey,
|
||||
durationSeconds: duration,
|
||||
aspectRatio,
|
||||
byteSize: progress.uploadedBytes,
|
||||
videoCodec: 'avc',
|
||||
audioCodec: audioTrack ? 'aac' : undefined,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
hlsRenditions: rungs.map((rung) => rung.name),
|
||||
hls1080Status: 'none',
|
||||
thumbnailUrl: thumbnailUrls[0],
|
||||
thumbnailCandidateUrls: thumbnailUrls,
|
||||
title: opts.title
|
||||
}
|
||||
});
|
||||
const finalizeBody = await finalize.json();
|
||||
if (!('success' in finalizeBody) || !finalizeBody.success) {
|
||||
throw new HlsEncoderError('finalize_failed', 'Server rejected finalize payload');
|
||||
}
|
||||
|
||||
emit({ stage: 'done', percent: 100 });
|
||||
reservedAssetId = null;
|
||||
return {
|
||||
assetId,
|
||||
manifestKey: `${assetId}/master.m3u8`,
|
||||
thumbnailUrl: thumbnailUrls[0],
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
hlsRenditions: rungs.map((rung) => rung.name),
|
||||
hls1080Status: 'none'
|
||||
};
|
||||
} catch (error) {
|
||||
const code = error instanceof HlsEncoderError ? error.code : 'encode_failed';
|
||||
const message = error instanceof Error ? error.message : 'Unknown encoder error';
|
||||
emit({ stage: 'failed', error: `${code}: ${message}` });
|
||||
|
||||
// Best-effort cleanup: tell the server to drop the reserved row + any
|
||||
// partial R2 objects it managed to write. Swallow errors — the user
|
||||
// already sees the original failure, and a stuck `processing` row is
|
||||
// recoverable via an admin sweep anyway.
|
||||
if (reservedAssetId) {
|
||||
try {
|
||||
await classroomio.organization.assets[':assetId'].hls.$delete({ param: { assetId: reservedAssetId } });
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to abort HLS asset after encode error', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and upload only the p1080 rendition for an existing HLS asset,
|
||||
* patch the master manifest, and finalize rendition metadata server-side.
|
||||
*/
|
||||
export async function encodeAndUploadHls1080(opts: EncodeAndUpload1080Options): Promise<{
|
||||
hlsRenditions: string[];
|
||||
hls1080Status: Hls1080Status;
|
||||
}> {
|
||||
const progress: HlsEncodeProgress = {
|
||||
stage: 'probing',
|
||||
percent: 0,
|
||||
uploadedBytes: 0,
|
||||
uploadedCount: 0
|
||||
};
|
||||
|
||||
function emit(patch: Partial<HlsEncodeProgress>) {
|
||||
Object.assign(progress, patch);
|
||||
opts.onProgress?.({ ...progress });
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof VideoEncoder === 'undefined') {
|
||||
throw new HlsEncoderError('webcodecs_unsupported', 'Browser missing WebCodecs encoder support');
|
||||
}
|
||||
|
||||
const input = new Input({ source: new BlobSource(opts.file), formats: ALL_FORMATS });
|
||||
const duration = await input.computeDuration();
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
if (!videoTrack) {
|
||||
throw new HlsEncoderError('encode_failed', 'No video track found in source');
|
||||
}
|
||||
|
||||
const sourceHeight = videoTrack.displayHeight ?? 0;
|
||||
if (sourceHeight < 1080) {
|
||||
throw new HlsEncoderError('encode_failed', 'Source video does not support 1080p');
|
||||
}
|
||||
|
||||
const rungs = [P1080_RUNG];
|
||||
emit({ stage: 'encoding', percent: 1, rung: 'p1080' });
|
||||
|
||||
const expectedPaths = predictHlsPaths(rungs, duration, false);
|
||||
const presignedUrls = await batchPresign1080Paths(opts.assetId, expectedPaths);
|
||||
|
||||
const capturedFiles = new Map<string, Uint8Array>();
|
||||
const uploader = new HlsUploader({
|
||||
assetId: opts.assetId,
|
||||
presignedUrls,
|
||||
presignEndpoint: '1080',
|
||||
skipPaths: new Set(['master.m3u8']),
|
||||
capturedFiles,
|
||||
onUploaded: ({ totalBytes, count }) => {
|
||||
emit({ uploadedBytes: totalBytes, uploadedCount: count });
|
||||
},
|
||||
signal: opts.signal
|
||||
});
|
||||
|
||||
const hlsOutput = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
targetDuration: HLS_TARGET_SEGMENT_SECONDS,
|
||||
getPlaylistPath: () => 'p1080/playlist.m3u8',
|
||||
getSegmentPath: ({ n }) => `seg-${pad(n, 5)}.ts`
|
||||
}),
|
||||
target: new PathedTarget('master.m3u8', (request) => uploader.targetFor(request.path, request.mimeType))
|
||||
});
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output: hlsOutput,
|
||||
video: [
|
||||
{
|
||||
width: P1080_RUNG.width,
|
||||
height: P1080_RUNG.height,
|
||||
fit: 'contain',
|
||||
bitrate: P1080_RUNG.videoBitrate,
|
||||
keyFrameInterval: HLS_TARGET_SEGMENT_SECONDS,
|
||||
hardwareAcceleration: 'prefer-hardware'
|
||||
}
|
||||
],
|
||||
audio: { discard: true },
|
||||
showWarnings: false
|
||||
});
|
||||
|
||||
conversion.onProgress = (fraction) => {
|
||||
emit({ percent: Math.min(72, 2 + Math.round(fraction * 70)), rung: 'p1080' });
|
||||
};
|
||||
|
||||
if (!conversion.isValid) {
|
||||
throw new HlsEncoderError(
|
||||
'encode_failed',
|
||||
`Conversion invalid (${conversion.discardedTracks.length} discarded tracks)`
|
||||
);
|
||||
}
|
||||
|
||||
await conversion.execute();
|
||||
await uploader.flush();
|
||||
|
||||
emit({ stage: 'uploading', percent: 78 });
|
||||
const generatedMaster = capturedFiles.get('master.m3u8');
|
||||
if (!generatedMaster) {
|
||||
throw new HlsEncoderError('encode_failed', 'Encoder did not produce a master manifest');
|
||||
}
|
||||
|
||||
const existingMaster = await fetchExistingMasterManifest(opts.assetId, opts.signal);
|
||||
const mergedMaster = mergeP1080IntoMaster(existingMaster, new TextDecoder().decode(generatedMaster));
|
||||
const mergedBytes = new TextEncoder().encode(mergedMaster);
|
||||
const uploadedBytesBeforeMaster = progress.uploadedBytes;
|
||||
await uploader.uploadSingle('master.m3u8', 'application/vnd.apple.mpegurl', mergedBytes, { force: true });
|
||||
|
||||
emit({ stage: 'finalizing', percent: 96 });
|
||||
const finalize = await classroomio.organization.assets[':assetId'].hls['1080'].finalize.$post({
|
||||
param: { assetId: opts.assetId },
|
||||
json: {
|
||||
additionalByteSize: uploadedBytesBeforeMaster,
|
||||
sourceWidth: videoTrack.displayWidth ?? undefined,
|
||||
sourceHeight: videoTrack.displayHeight ?? undefined
|
||||
}
|
||||
});
|
||||
const finalizeBody = await finalize.json();
|
||||
if (!('success' in finalizeBody) || !finalizeBody.success) {
|
||||
throw new HlsEncoderError('finalize_failed', 'Server rejected 1080p finalize payload');
|
||||
}
|
||||
|
||||
const metadata = finalizeBody.data.metadata as { hlsRenditions?: string[]; hls1080Status?: Hls1080Status } | null;
|
||||
emit({ stage: 'done', percent: 100 });
|
||||
|
||||
return {
|
||||
hlsRenditions: metadata?.hlsRenditions ?? ['p360', 'p720', 'p1080'],
|
||||
hls1080Status: metadata?.hls1080Status ?? 'ready'
|
||||
};
|
||||
} catch (error) {
|
||||
const code = error instanceof HlsEncoderError ? error.code : 'encode_failed';
|
||||
const message = error instanceof Error ? error.message : 'Unknown encoder error';
|
||||
emit({ stage: 'failed', error: `${code}: ${message}` });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
class HlsEncoderError extends Error {
|
||||
constructor(
|
||||
public code: 'webcodecs_unsupported' | 'encode_failed' | 'upload_failed' | 'finalize_failed',
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HlsEncoderError';
|
||||
}
|
||||
}
|
||||
|
||||
interface PresignedUrl {
|
||||
url: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict every path Mediabunny will write so we can presign them all in
|
||||
* a single request before encoding starts. Segment counts come from
|
||||
* duration / target-duration with a small safety margin — over-presigning
|
||||
* is free (unused presigned URLs simply expire) while under-presigning
|
||||
* forces a fallback per-file presign at upload time.
|
||||
*
|
||||
* When the source has audio, Mediabunny's HLS output emits a separate
|
||||
* `audio/` playlist + its own segments alongside the video rungs (the
|
||||
* master playlist groups them via `#EXT-X-MEDIA`). We presign those too.
|
||||
* The trailing `audio.m4a` is the separate transcription-only output we
|
||||
* run after the HLS conversion.
|
||||
*/
|
||||
function predictHlsPaths(rungs: HlsRungConfig[], duration: number, hasAudio: boolean): string[] {
|
||||
const paths: string[] = ['master.m3u8'];
|
||||
const segmentCount = Math.max(1, Math.ceil(duration / HLS_TARGET_SEGMENT_SECONDS)) + 5;
|
||||
|
||||
for (const rung of rungs) {
|
||||
paths.push(`${rung.name}/playlist.m3u8`);
|
||||
for (let i = 1; i <= segmentCount; i++) {
|
||||
paths.push(`${rung.name}/seg-${pad(i, 5)}.ts`);
|
||||
}
|
||||
}
|
||||
if (hasAudio) {
|
||||
paths.push('audio/playlist.m3u8');
|
||||
for (let i = 1; i <= segmentCount; i++) {
|
||||
paths.push(`audio/seg-${pad(i, 5)}.ts`);
|
||||
}
|
||||
paths.push('audio.m4a');
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function batchPresignPaths(assetId: string, paths: string[]): Promise<Map<string, PresignedUrl>> {
|
||||
const response = await classroomio.organization.assets[':assetId'].hls.presign.$post({
|
||||
param: { assetId },
|
||||
json: { paths }
|
||||
});
|
||||
return parsePresignResponse(paths, response);
|
||||
}
|
||||
|
||||
async function batchPresign1080Paths(assetId: string, paths: string[]): Promise<Map<string, PresignedUrl>> {
|
||||
const response = await classroomio.organization.assets[':assetId'].hls['1080'].presign.$post({
|
||||
param: { assetId },
|
||||
json: { paths }
|
||||
});
|
||||
return parsePresignResponse(paths, response);
|
||||
}
|
||||
|
||||
async function parsePresignResponse(paths: string[], response: Response): Promise<Map<string, PresignedUrl>> {
|
||||
const map = new Map<string, PresignedUrl>();
|
||||
const body = await response.json();
|
||||
if (!('success' in body) || !body.success) {
|
||||
throw new HlsEncoderError('upload_failed', 'Failed to presign HLS uploads');
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
const url = body.data.urls[path];
|
||||
const key = body.data.keys[path];
|
||||
if (url && key) map.set(path, { url, key });
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads files Mediabunny emits while encoding. All expected paths are
|
||||
* pre-presigned at start; PathedTarget.onFinalize is a synchronous map
|
||||
* lookup. The rare path that wasn't predicted (e.g. encoder produced one
|
||||
* extra segment) falls back to a single per-file presign.
|
||||
*/
|
||||
class HlsUploader {
|
||||
private totalBytes = 0;
|
||||
private uploadedCount = 0;
|
||||
private inflight: Promise<void>[] = [];
|
||||
private uploadFailures: unknown[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
assetId: string;
|
||||
presignedUrls: Map<string, PresignedUrl>;
|
||||
onUploaded: (state: { totalBytes: number; count: number }) => void;
|
||||
signal?: AbortSignal;
|
||||
presignEndpoint?: 'default' | '1080';
|
||||
skipPaths?: Set<string>;
|
||||
capturedFiles?: Map<string, Uint8Array>;
|
||||
}
|
||||
) {}
|
||||
|
||||
targetFor(path: string, mimeType: string): BufferTarget {
|
||||
return new BufferTarget({
|
||||
onFinalize: async (buffer) => {
|
||||
await this.uploadSingle(path, mimeType, buffer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async uploadSingle(
|
||||
path: string,
|
||||
mimeType: string,
|
||||
buffer: ArrayBuffer | Uint8Array,
|
||||
options?: { force?: boolean }
|
||||
): Promise<string> {
|
||||
const upload = (async () => {
|
||||
const u8 = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||
if (!options?.force && this.options.skipPaths?.has(path)) {
|
||||
this.options.capturedFiles?.set(path, u8);
|
||||
return `${this.options.assetId}/${path}`;
|
||||
}
|
||||
|
||||
let presigned = this.options.presignedUrls.get(path);
|
||||
if (!presigned) {
|
||||
presigned = await this.fallbackPresign(path);
|
||||
this.options.presignedUrls.set(path, presigned);
|
||||
}
|
||||
|
||||
const owned = new Uint8Array(u8.byteLength);
|
||||
owned.set(u8);
|
||||
const body = new Blob([owned], { type: mimeType });
|
||||
const res = await fetch(presigned.url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': mimeType },
|
||||
body,
|
||||
signal: this.options.signal
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new HlsEncoderError('upload_failed', `PUT ${path} → ${res.status}`);
|
||||
}
|
||||
|
||||
this.totalBytes += owned.byteLength;
|
||||
this.uploadedCount += 1;
|
||||
this.options.onUploaded({ totalBytes: this.totalBytes, count: this.uploadedCount });
|
||||
return presigned.key;
|
||||
})();
|
||||
|
||||
const tracked = upload.then(
|
||||
() => undefined,
|
||||
(error) => {
|
||||
this.uploadFailures.push(error);
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
this.inflight.push(tracked);
|
||||
tracked.finally(() => {
|
||||
const index = this.inflight.indexOf(tracked);
|
||||
if (index >= 0) this.inflight.splice(index, 1);
|
||||
});
|
||||
|
||||
return upload;
|
||||
}
|
||||
|
||||
private async fallbackPresign(path: string): Promise<PresignedUrl> {
|
||||
const response =
|
||||
this.options.presignEndpoint === '1080'
|
||||
? await classroomio.organization.assets[':assetId'].hls['1080'].presign.$post({
|
||||
param: { assetId: this.options.assetId },
|
||||
json: { paths: [path] }
|
||||
})
|
||||
: await classroomio.organization.assets[':assetId'].hls.presign.$post({
|
||||
param: { assetId: this.options.assetId },
|
||||
json: { paths: [path] }
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!('success' in body) || !body.success) {
|
||||
throw new HlsEncoderError('upload_failed', `Failed to presign ${path}`);
|
||||
}
|
||||
const url = body.data.urls[path];
|
||||
const key = body.data.keys[path];
|
||||
if (!url || !key) {
|
||||
throw new HlsEncoderError('upload_failed', `Presign returned no url for ${path}`);
|
||||
}
|
||||
return { url, key };
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
while (this.inflight.length) {
|
||||
const pending = this.inflight.slice();
|
||||
const results = await Promise.allSettled(pending);
|
||||
const failure = results.find((result) => result.status === 'rejected');
|
||||
if (failure?.status === 'rejected') {
|
||||
throw failure.reason;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.uploadFailures.length > 0) {
|
||||
throw this.uploadFailures[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function extractAndUploadThumbnails(input: {
|
||||
videoTrack: InputVideoTrack;
|
||||
duration: number;
|
||||
file: File;
|
||||
}): Promise<string[]> {
|
||||
if (!input.duration || input.duration <= 0) return [];
|
||||
|
||||
const sink = new CanvasSink(input.videoTrack, { width: 1280, fit: 'contain', poolSize: 1 });
|
||||
const timestamps = THUMBNAIL_FRACTIONS.map((f) => input.duration * f);
|
||||
const candidates: { url: string; score: number }[] = [];
|
||||
|
||||
let index = 0;
|
||||
for await (const wrapped of sink.canvasesAtTimestamps(timestamps)) {
|
||||
if (!wrapped) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const canvas = wrapped.canvas as HTMLCanvasElement | OffscreenCanvas;
|
||||
const blob = await canvasToJpegBlobUnderCap(canvas, THUMBNAIL_MAX_BYTES);
|
||||
if (!blob) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const score = await averageLuma(canvas);
|
||||
const fileName = input.file.name.replace(/\.[^.]+$/, '') + `-thumb-${index}.jpg`;
|
||||
const file = new File([blob], fileName, { type: 'image/jpeg' });
|
||||
try {
|
||||
const response = await classroomio.media.image.$post({ form: { file } });
|
||||
const body = await response.json();
|
||||
if ('success' in body && body.success && body.url) {
|
||||
candidates.push({ url: body.url, score });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Thumbnail upload failed', error);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
return candidates.map((c) => c.url);
|
||||
}
|
||||
|
||||
async function canvasToJpegBlob(canvas: HTMLCanvasElement | OffscreenCanvas, quality: number): Promise<Blob | null> {
|
||||
if (canvas instanceof OffscreenCanvas) {
|
||||
return canvas.convertToBlob({ type: 'image/jpeg', quality });
|
||||
}
|
||||
return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/jpeg', quality));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the canvas as JPEG, stepping quality down until the blob fits under
|
||||
* `maxBytes`. Returns the first blob within the cap, or the smallest produced
|
||||
* if even the lowest quality overshoots.
|
||||
*/
|
||||
async function canvasToJpegBlobUnderCap(
|
||||
canvas: HTMLCanvasElement | OffscreenCanvas,
|
||||
maxBytes: number
|
||||
): Promise<Blob | null> {
|
||||
let smallest: Blob | null = null;
|
||||
for (const quality of THUMBNAIL_QUALITY_LADDER) {
|
||||
const blob = await canvasToJpegBlob(canvas, quality);
|
||||
if (!blob) continue;
|
||||
|
||||
if (!smallest || blob.size < smallest.size) smallest = blob;
|
||||
if (blob.size <= maxBytes) return blob;
|
||||
}
|
||||
|
||||
return smallest;
|
||||
}
|
||||
|
||||
async function averageLuma(canvas: HTMLCanvasElement | OffscreenCanvas): Promise<number> {
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
|
||||
if (!ctx) return 0;
|
||||
|
||||
const { width, height } = canvas;
|
||||
const sampleSize = Math.min(64, width, height);
|
||||
const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
sum += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
|
||||
}
|
||||
return sum / (data.length / 4);
|
||||
}
|
||||
|
||||
function computeAspectRatioLabel(track: InputVideoTrack): string {
|
||||
const w = track.displayWidth;
|
||||
const h = track.displayHeight;
|
||||
if (!w || !h) return '16:9';
|
||||
|
||||
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
|
||||
const g = gcd(w, h);
|
||||
return `${w / g}:${h / g}`;
|
||||
}
|
||||
|
||||
function pad(n: number, width: number): string {
|
||||
return n.toString().padStart(width, '0');
|
||||
}
|
||||
|
||||
async function fetchExistingMasterManifest(assetId: string, signal?: AbortSignal): Promise<string> {
|
||||
await classroomio.organization.assets[':assetId'].hls.cookie.$post({ param: { assetId } });
|
||||
const response = await fetch(`/hls/${assetId}/master.m3u8`, { credentials: 'include', signal });
|
||||
if (!response.ok) {
|
||||
throw new HlsEncoderError('upload_failed', `Failed to fetch master manifest (${response.status})`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function extractAudioGroupId(manifest: string): string | null {
|
||||
const match = manifest.match(/#EXT-X-MEDIA:TYPE=AUDIO[^\n]*GROUP-ID="([^"]+)"/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function mergeP1080IntoMaster(existingManifest: string, generatedManifest: string): string {
|
||||
if (existingManifest.includes('p1080/playlist.m3u8')) {
|
||||
return existingManifest;
|
||||
}
|
||||
|
||||
const generatedLines = generatedManifest.split('\n');
|
||||
let streamInf: string | null = null;
|
||||
for (let index = 0; index < generatedLines.length; index += 1) {
|
||||
const line = generatedLines[index]?.trim() ?? '';
|
||||
if (!line.startsWith('#EXT-X-STREAM-INF')) continue;
|
||||
|
||||
const playlistLine = generatedLines[index + 1]?.trim() ?? '';
|
||||
if (playlistLine === 'p1080/playlist.m3u8') {
|
||||
streamInf = line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const audioGroupId = extractAudioGroupId(existingManifest);
|
||||
if (!streamInf) {
|
||||
streamInf = `#EXT-X-STREAM-INF:BANDWIDTH=${P1080_RUNG.videoBitrate},RESOLUTION=${P1080_RUNG.width}x${P1080_RUNG.height},CODECS="avc1.640028,mp4a.40.2"`;
|
||||
}
|
||||
|
||||
if (audioGroupId && !streamInf.includes('AUDIO=')) {
|
||||
streamInf = `${streamInf},AUDIO="${audioGroupId}"`;
|
||||
}
|
||||
|
||||
const lines = existingManifest.trimEnd().split('\n');
|
||||
lines.push(streamInf);
|
||||
lines.push('p1080/playlist.m3u8');
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
+5
@@ -24,6 +24,11 @@ class LessonVideoBus {
|
||||
this.seekFn(seconds);
|
||||
}
|
||||
|
||||
/** Replace the active transcript (e.g. after an edit is saved). */
|
||||
setTranscript(transcript: AssetTranscriptPayload | null) {
|
||||
this.transcript = transcript;
|
||||
}
|
||||
|
||||
/** Reset all per-lesson state (called when the upload asset changes or the player unmounts). */
|
||||
reset(opts?: { keepAssetId?: boolean }) {
|
||||
if (!opts?.keepAssetId) {
|
||||
|
||||
+245
-54
@@ -1,29 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { MediaPlayer } from '@cio/ui/custom/media-player';
|
||||
import { presignApi } from '$features/course/api/presign.svelte';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { jobsApi, JobPoller, type MediaJobEnvelope } from '$features/jobs';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { sidePanel } from '$features/side-panel';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||
import type { LessonVideo } from './video-card-utils';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
|
||||
|
||||
/**
|
||||
* HLS playback flag — set by the upload flow when the asset was encoded
|
||||
* via Mediabunny and stored as a manifest. `video.metadata` is untyped
|
||||
* `Record<string, unknown>` at the call site, so the cast keeps the
|
||||
* boolean lookup honest.
|
||||
*/
|
||||
function isHlsVideo(v: LessonVideo): boolean {
|
||||
return Boolean((v.metadata as { hls?: boolean } | undefined)?.hls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fully-qualified URL for an HLS manifest or segment from the
|
||||
* relative `/hls/{assetId}/{rest}` form stored in `lesson.videos[].link`.
|
||||
* Uses the typed Hono client so the URL goes through the same base every
|
||||
* other API call uses (PUBLIC_SERVER_URL locally, `${origin}/proxy/...`
|
||||
* in Cloud production — the tenant-router Worker intercepts the latter
|
||||
* and streams directly from R2).
|
||||
*/
|
||||
function resolveHlsUrl(rawUrl: string): string {
|
||||
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
|
||||
const match = rawUrl.match(/^\/?hls\/([^/]+)\/(.+)$/);
|
||||
if (!match) return rawUrl;
|
||||
|
||||
const [, assetId, rest] = match;
|
||||
const built = classroomio.hls[':assetId']['*'].$url({ param: { assetId } });
|
||||
return built.toString().replace(/\/\*$/, '') + '/' + rest;
|
||||
}
|
||||
|
||||
async function mintHlsCookie(assetId: string): Promise<void> {
|
||||
await classroomio.organization.assets[':assetId'].hls.cookie.$post({
|
||||
param: { assetId }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the transcript VTT through the typed RPC client and turn it
|
||||
* into a `blob:` URL. We can't point the `<track>` element directly at
|
||||
* the api URL: `<video crossorigin>` would force CORS on every video
|
||||
* subresource (poster on r2.dev, Plyr's `blank.mp4`, …) and break
|
||||
* those. Without `crossorigin` the browser refuses cross-origin
|
||||
* tracks. `blob:` URLs side-step both: same-origin from the browser's
|
||||
* POV, and the fetch itself runs through the same auth'd path as
|
||||
* every other API call.
|
||||
*/
|
||||
let blobTrackUrl = $state<string | null>(null);
|
||||
let revokeBlobTrackUrl: (() => void) | null = null;
|
||||
|
||||
async function loadTranscriptTrack(rawUrl: string): Promise<void> {
|
||||
const match = rawUrl.match(/^\/?transcripts\/([^/]+)\/track\.vtt$/);
|
||||
if (!match) {
|
||||
blobTrackUrl = /^https?:\/\//i.test(rawUrl) ? rawUrl : null;
|
||||
return;
|
||||
}
|
||||
|
||||
const [, assetId] = match;
|
||||
try {
|
||||
const response = await classroomio.transcripts[':assetId']['track.vtt'].$get({ param: { assetId } });
|
||||
if (!response.ok) return;
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
revokeBlobTrackUrl?.();
|
||||
revokeBlobTrackUrl = () => URL.revokeObjectURL(url);
|
||||
blobTrackUrl = url;
|
||||
} catch (error) {
|
||||
console.warn('Failed to load transcript track', error);
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
video: LessonVideo;
|
||||
}
|
||||
|
||||
let { video }: Props = $props();
|
||||
|
||||
let localTranscript = $state<AssetTranscriptPayload | null>(null);
|
||||
let localTranscriptLoading = $state(false);
|
||||
|
||||
// The parent keys this component on the upload assetId / link, so each
|
||||
// mount maps to a single asset; lifecycle is plain onMount/onDestroy.
|
||||
const uploadAssetId =
|
||||
video.type === 'upload' ? ((video as LessonVideo & { assetId?: string }).assetId ?? null) : null;
|
||||
const uploadStorageKey =
|
||||
video.type === 'upload' && typeof video.key === 'string' && video.key.length > 0 ? video.key : null;
|
||||
|
||||
/** Refresh ~10 minutes before server presign expiry (1 hour). */
|
||||
const PLAYBACK_URL_REFRESH_MS = 50 * 60 * 1000;
|
||||
|
||||
let isMounted = true;
|
||||
const isHls = $derived(isHlsVideo(video));
|
||||
let playbackUrl = $state(isHlsVideo(video) ? resolveHlsUrl(video.link) : video.link);
|
||||
let playbackUrlIssuedAt = $state(Date.now());
|
||||
let playbackRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let vttRefetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let jobPoller: JobPoller<MediaJobEnvelope> | null = null;
|
||||
let hasAutoOpenedTranscript = false;
|
||||
|
||||
let localSeekFn: (seconds: number) => void = () => {};
|
||||
|
||||
function ownsVideoBus(): boolean {
|
||||
return uploadAssetId !== null && lessonVideoBus.assetId === uploadAssetId;
|
||||
}
|
||||
|
||||
function syncVideoBus(): void {
|
||||
if (!uploadAssetId) return;
|
||||
|
||||
lessonVideoBus.assetId = uploadAssetId;
|
||||
lessonVideoBus.transcript = localTranscript;
|
||||
lessonVideoBus.transcriptLoading = localTranscriptLoading;
|
||||
lessonVideoBus.setSeekFn(localSeekFn);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const rawUrl = localTranscript?.vttUrl;
|
||||
if (!rawUrl) {
|
||||
revokeBlobTrackUrl?.();
|
||||
revokeBlobTrackUrl = null;
|
||||
blobTrackUrl = null;
|
||||
return;
|
||||
}
|
||||
void loadTranscriptTrack(rawUrl);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
playbackUrl = isHlsVideo(video) ? resolveHlsUrl(video.link) : video.link;
|
||||
playbackUrlIssuedAt = Date.now();
|
||||
schedulePlaybackUrlRefresh();
|
||||
});
|
||||
|
||||
function clearPlaybackRefreshTimer() {
|
||||
if (playbackRefreshTimer) {
|
||||
clearTimeout(playbackRefreshTimer);
|
||||
playbackRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePlaybackUrlRefresh() {
|
||||
clearPlaybackRefreshTimer();
|
||||
if (!uploadStorageKey || isHls) return;
|
||||
|
||||
const delayMs = PLAYBACK_URL_REFRESH_MS - (Date.now() - playbackUrlIssuedAt);
|
||||
if (delayMs <= 0) {
|
||||
void refreshPlaybackUrlIfIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
playbackRefreshTimer = setTimeout(() => {
|
||||
void refreshPlaybackUrlIfIdle();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async function refreshPlaybackUrl(): Promise<string | null> {
|
||||
if (!uploadStorageKey || !isMounted) return null;
|
||||
|
||||
const urls = await presignApi.getVideoDownloadUrls([uploadStorageKey]);
|
||||
const freshUrl = urls[uploadStorageKey];
|
||||
|
||||
if (!freshUrl || !isMounted) return null;
|
||||
|
||||
playbackUrl = freshUrl;
|
||||
playbackUrlIssuedAt = Date.now();
|
||||
schedulePlaybackUrlRefresh();
|
||||
|
||||
return freshUrl;
|
||||
}
|
||||
|
||||
async function refreshPlaybackUrlIfIdle(): Promise<string | null> {
|
||||
if (lessonVideoBus.hasPlayed) return null;
|
||||
|
||||
return refreshPlaybackUrl();
|
||||
}
|
||||
|
||||
async function handlePlaybackReload(): Promise<boolean> {
|
||||
pendingResumeSeconds = lessonVideoBus.currentTimeSeconds;
|
||||
|
||||
if (uploadStorageKey) {
|
||||
return Boolean(await refreshPlaybackUrl());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
let pendingResumeSeconds: number | null = null;
|
||||
|
||||
function handleSourceLoaded(element: HTMLVideoElement) {
|
||||
if (pendingResumeSeconds == null || pendingResumeSeconds <= 0) return;
|
||||
|
||||
element.currentTime = pendingResumeSeconds;
|
||||
pendingResumeSeconds = null;
|
||||
}
|
||||
|
||||
function clearVttRefetchTimer() {
|
||||
if (vttRefetchTimer) {
|
||||
@@ -58,7 +235,10 @@
|
||||
async function loadTranscript(): Promise<void> {
|
||||
if (!uploadAssetId) {
|
||||
if (isMounted) {
|
||||
lessonVideoBus.transcript = null;
|
||||
localTranscript = null;
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcript = null;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -66,24 +246,33 @@
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
lessonVideoBus.transcriptLoading = true;
|
||||
localTranscriptLoading = true;
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcriptLoading = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await mediaApi.getAssetTranscript(uploadAssetId);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
lessonVideoBus.transcript = data;
|
||||
localTranscript = data;
|
||||
scheduleVttRefetch(data?.vttUrlExpiresAt);
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcript = data;
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
lessonVideoBus.transcriptLoading = false;
|
||||
localTranscriptLoading = false;
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcriptLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function watchJobsUntilTerminalThenReload(): Promise<void> {
|
||||
if (!uploadAssetId || lessonVideoBus.transcript || !isMounted) return;
|
||||
if (!uploadAssetId || localTranscript || !isMounted) return;
|
||||
|
||||
stopJobPoller();
|
||||
|
||||
@@ -122,8 +311,9 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
lessonVideoBus.reset();
|
||||
lessonVideoBus.assetId = uploadAssetId;
|
||||
if (lessonVideoBus.assetId === null || lessonVideoBus.assetId === uploadAssetId) {
|
||||
syncVideoBus();
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
await loadTranscript();
|
||||
@@ -135,19 +325,24 @@
|
||||
|
||||
onDestroy(() => {
|
||||
isMounted = false;
|
||||
clearPlaybackRefreshTimer();
|
||||
clearVttRefetchTimer();
|
||||
stopJobPoller();
|
||||
lessonVideoBus.reset();
|
||||
sidePanel.closeIfScope('lesson');
|
||||
revokeBlobTrackUrl?.();
|
||||
revokeBlobTrackUrl = null;
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.reset();
|
||||
sidePanel.closeIfScope('lesson');
|
||||
}
|
||||
});
|
||||
|
||||
const tracks = $derived(
|
||||
lessonVideoBus.transcript?.vttUrl
|
||||
blobTrackUrl && localTranscript
|
||||
? [
|
||||
{
|
||||
kind: 'captions' as const,
|
||||
src: lessonVideoBus.transcript.vttUrl,
|
||||
srclang: lessonVideoBus.transcript.language,
|
||||
src: blobTrackUrl,
|
||||
srclang: localTranscript.language,
|
||||
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.captions_label'),
|
||||
default: false
|
||||
}
|
||||
@@ -155,30 +350,17 @@
|
||||
: []
|
||||
);
|
||||
|
||||
// Plyr can't add a <track> after construction, so when the captions URL
|
||||
// appears asynchronously we remount the player once. Include a stable key when
|
||||
// a transcript exists but VTT is not ready yet so optional controls (e.g. transcript panel) attach.
|
||||
const playerRemountKey = $derived.by(() => {
|
||||
if (!lessonVideoBus.transcript) return 'no-transcript';
|
||||
|
||||
return lessonVideoBus.transcript.vttUrl ?? 'transcript-awaiting-vtt';
|
||||
});
|
||||
|
||||
function handleFirstPlay() {
|
||||
syncVideoBus();
|
||||
lessonVideoBus.hasPlayed = true;
|
||||
|
||||
if (hasAutoOpenedTranscript) return;
|
||||
if (!lessonVideoBus.transcript?.segments?.length) return;
|
||||
|
||||
hasAutoOpenedTranscript = true;
|
||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||
}
|
||||
|
||||
const transcriptPanelControl = $derived(
|
||||
lessonVideoBus.transcript
|
||||
localTranscript
|
||||
? {
|
||||
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.open_side_panel'),
|
||||
onClick: () => {
|
||||
syncVideoBus();
|
||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||
}
|
||||
}
|
||||
@@ -187,30 +369,39 @@
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
{#key playerRemountKey}
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: video.type,
|
||||
url: video.link,
|
||||
metadata: video.metadata as { thumbnailUrl?: string; title?: string } | undefined,
|
||||
tracks
|
||||
}}
|
||||
options={{
|
||||
maxHeight: '569px',
|
||||
width: '100%',
|
||||
controls: true,
|
||||
playsinline: true,
|
||||
onTimeUpdate: (seconds) => {
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: video.type,
|
||||
url: playbackUrl,
|
||||
hls: isHls,
|
||||
metadata: video.metadata as { thumbnailUrl?: string; title?: string } | undefined,
|
||||
tracks
|
||||
}}
|
||||
options={{
|
||||
maxHeight: '569px',
|
||||
width: '100%',
|
||||
controls: true,
|
||||
playsinline: true,
|
||||
onTimeUpdate: (seconds) => {
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.currentTimeSeconds = seconds;
|
||||
},
|
||||
onPlayerReady: (player) => {
|
||||
lessonVideoBus.setSeekFn((seconds) => {
|
||||
player.currentTime = seconds;
|
||||
});
|
||||
},
|
||||
onFirstPlay: handleFirstPlay,
|
||||
transcriptPanelControl
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
}
|
||||
},
|
||||
onPlayerReady: (player) => {
|
||||
localSeekFn = (seconds) => {
|
||||
player.currentTime = seconds;
|
||||
};
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.setSeekFn(localSeekFn);
|
||||
}
|
||||
},
|
||||
onFirstPlay: handleFirstPlay,
|
||||
onSourceLoaded: handleSourceLoaded,
|
||||
onBeforeHlsLoad: isHls && uploadAssetId ? () => mintHlsCookie(uploadAssetId) : undefined,
|
||||
transcriptPanelControl,
|
||||
playbackErrorLabel: $t('course.navItem.lessons.materials.tabs.video.playback_error'),
|
||||
playbackReloadLabel: $t('course.navItem.lessons.materials.tabs.video.playback_reload'),
|
||||
onPlaybackReload: handlePlaybackReload
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -78,7 +78,7 @@
|
||||
|
||||
<!-- YouTube-like: thumbnail on top; avatar + stacked text + overflow menu -->
|
||||
<div class="group w-full max-w-full min-w-0 {isEditMode ? 'rounded-lg border' : ''}">
|
||||
<div class="ui:bg-muted relative aspect-video w-full min-w-0 overflow-hidden rounded-xl">
|
||||
<div class="ui:bg-muted ui:rounded-md relative aspect-video w-full min-w-0 overflow-hidden">
|
||||
{#if thumbnailUrl}
|
||||
<Image src={thumbnailUrl} alt={title} className="absolute inset-0 block h-full w-full object-cover" />
|
||||
{:else}
|
||||
@@ -142,6 +142,7 @@
|
||||
{video}
|
||||
{onRemove}
|
||||
onThumbnailSaved={(url) => lessonApi.updateLessonVideoThumbnail(index, url)}
|
||||
onHlsMetadataUpdated={(metadata) => lessonApi.updateLessonVideoMetadata(index, metadata)}
|
||||
menuPlacement="inline"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+85
-14
@@ -1,21 +1,31 @@
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import CaptionsIcon from '@lucide/svelte/icons/captions';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { sidePanel } from '$features/side-panel';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||
|
||||
type Segment = AssetTranscriptPayload['segments'][number];
|
||||
import type { TranscriptSegment } from '$features/media/utils/types';
|
||||
|
||||
const transcript = $derived(lessonVideoBus.transcript);
|
||||
const transcriptLoading = $derived(lessonVideoBus.transcriptLoading);
|
||||
const segments = $derived<Segment[]>(transcript?.segments ?? []);
|
||||
const segments = $derived<TranscriptSegment[]>(transcript?.segments ?? []);
|
||||
const language = $derived(transcript?.language ?? '');
|
||||
const assetId = $derived(lessonVideoBus.assetId);
|
||||
const currentTimeSeconds = $derived(lessonVideoBus.currentTimeSeconds);
|
||||
|
||||
function isActiveSegment(segment: Segment, index: number): boolean {
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
let draft = $state<TranscriptSegment[]>([]);
|
||||
|
||||
const canEdit = $derived(Boolean($isOrgAdmin) && Boolean(assetId) && segments.length > 0);
|
||||
|
||||
function isActiveSegment(segment: TranscriptSegment, index: number): boolean {
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
@@ -40,6 +50,30 @@
|
||||
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
draft = segments.map((segment) => ({ ...segment }));
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
editing = false;
|
||||
draft = [];
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!assetId) return;
|
||||
|
||||
saving = true;
|
||||
const updated = await mediaApi.updateAssetTranscript(assetId, draft);
|
||||
saving = false;
|
||||
|
||||
if (!updated) return;
|
||||
|
||||
lessonVideoBus.setTranscript(updated);
|
||||
editing = false;
|
||||
draft = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
@@ -57,18 +91,55 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
onclick={() => sidePanel.close()}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
tooltip={$t('side_panel.close_aria')}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</IconButton>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if editing}
|
||||
<Button variant="ghost" size="sm" onclick={cancelEditing} disabled={saving}>
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onclick={save} disabled={saving}>
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.save')}
|
||||
</Button>
|
||||
{:else}
|
||||
{#if canEdit}
|
||||
<IconButton
|
||||
onclick={startEditing}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
tooltip={$t('course.navItem.lessons.materials.tabs.video.transcript.edit')}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
<IconButton
|
||||
onclick={() => sidePanel.close()}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
tooltip={$t('side_panel.close_aria')}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{#if segments.length}
|
||||
{#if editing}
|
||||
{#each draft as segment, index (index)}
|
||||
<div class="mb-3 last:mb-0">
|
||||
<span class="ui:text-muted-foreground mb-1 block text-xs tabular-nums">
|
||||
{formatTimestamp(segment.start)}
|
||||
</span>
|
||||
<Textarea
|
||||
bind:value={draft[index].text}
|
||||
rows={2}
|
||||
disabled={saving}
|
||||
aria-label={$t('course.navItem.lessons.materials.tabs.video.transcript.segment_label', {
|
||||
timestamp: formatTimestamp(segment.start)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{:else if segments.length}
|
||||
{#each segments as segment, index (segment.start + segment.end + index)}
|
||||
{@const active = isActiveSegment(segment, index)}
|
||||
<button
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { VideoUploader } from '$lib/utils/services/courses/presign';
|
||||
import { MediaUploader } from './media-uploader';
|
||||
import { encodeAndUploadHls, type HlsEncodeProgress } from './hls-encoder';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
@@ -23,6 +25,14 @@
|
||||
let { lessonId: _lessonId = '' }: Props = $props();
|
||||
|
||||
let fileSize: number | undefined = $state();
|
||||
let hlsProgress: HlsEncodeProgress | null = $state(null);
|
||||
let hlsAbortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* HLS is the default upload path on Cloud. Self-hosted installs fall
|
||||
* back to raw-MP4 upload (no R2 + tenant-router edge dependency).
|
||||
*/
|
||||
const hlsEnabled = $derived(publicEnv.PUBLIC_IS_SELFHOSTED !== 'true');
|
||||
|
||||
let formRes: {
|
||||
url?: string;
|
||||
@@ -110,6 +120,82 @@
|
||||
fileSize = videoFile.size / (1024 * 1024);
|
||||
fileName = videoFile.name;
|
||||
|
||||
if (hlsEnabled) {
|
||||
hlsAbortController = new AbortController();
|
||||
try {
|
||||
const result = await encodeAndUploadHls({
|
||||
file: videoFile,
|
||||
title: videoFile.name,
|
||||
signal: hlsAbortController.signal,
|
||||
onProgress: (p) => {
|
||||
hlsProgress = p;
|
||||
$lessonVideoUpload.uploadProgress = p.percent;
|
||||
}
|
||||
});
|
||||
|
||||
const lessonVideoPosition = Array.isArray(lessonApi.lesson?.videos) ? lessonApi.lesson?.videos.length : 0;
|
||||
if (_lessonId) {
|
||||
await mediaApi.attachAsset(result.assetId, {
|
||||
targetType: 'lesson',
|
||||
targetId: _lessonId,
|
||||
slotType: 'lesson_video',
|
||||
position: lessonVideoPosition
|
||||
});
|
||||
}
|
||||
|
||||
const hlsManifestUrl = `/hls/${result.assetId}/master.m3u8`;
|
||||
lessonApi.updateLessonState(
|
||||
'videos',
|
||||
[
|
||||
{
|
||||
type: 'upload',
|
||||
link: hlsManifestUrl,
|
||||
assetId: result.assetId,
|
||||
fileName: videoFile.name,
|
||||
metadata: {
|
||||
fileName: videoFile.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
hls: true,
|
||||
sourceWidth: result.sourceWidth,
|
||||
sourceHeight: result.sourceHeight,
|
||||
hlsRenditions: result.hlsRenditions,
|
||||
hls1080Status: result.hls1080Status,
|
||||
...(result.thumbnailUrl ? { thumbnailUrl: result.thumbnailUrl } : {})
|
||||
}
|
||||
} as {
|
||||
type: 'upload';
|
||||
link: string;
|
||||
assetId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
fileName?: string;
|
||||
}
|
||||
],
|
||||
{ append: true }
|
||||
);
|
||||
|
||||
formRes = {
|
||||
url: hlsManifestUrl,
|
||||
fileKey: result.manifestKey,
|
||||
status: 200,
|
||||
thumbnailUrl: result.thumbnailUrl
|
||||
};
|
||||
startProcessingPoll(result.assetId);
|
||||
isLoaded = true;
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('HLS encode failed', err);
|
||||
formRes = {
|
||||
type: 'INTERNAL_ERROR',
|
||||
status: 500,
|
||||
message: err instanceof Error ? err.message : t.get('hls_upload.error.encode_failed')
|
||||
};
|
||||
isLoaded = true;
|
||||
return;
|
||||
} finally {
|
||||
$lessonVideoUpload.isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { url: presignedUrl, fileKey } = await videoUploader.getPresignedUrl(videoFile);
|
||||
|
||||
@@ -256,6 +342,7 @@
|
||||
|
||||
function cancelUpload() {
|
||||
videoUploader.abortController?.abort();
|
||||
hlsAbortController?.abort();
|
||||
|
||||
cancelVideoUpload();
|
||||
|
||||
@@ -264,7 +351,13 @@
|
||||
$lessonVideoUpload.uploadProgress = 0;
|
||||
}
|
||||
|
||||
let helperText = $derived($lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize || 0) + 'MB');
|
||||
let helperText = $derived.by(() => {
|
||||
const hp = hlsProgress;
|
||||
if (!hp) return $lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize || 0) + 'MB';
|
||||
|
||||
const stageLabel = t.get(`hls_upload.stage.${hp.stage}`);
|
||||
return `${stageLabel} ${hp.percent}%`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<UpgradeBanner className="mb-3" onClick={() => ($lessonVideoUpload.isModalOpen = false)}>
|
||||
|
||||
+149
-16
@@ -4,6 +4,7 @@
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
@@ -14,7 +15,15 @@
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { LessonVideo } from './video-card-utils';
|
||||
import {
|
||||
canGenerateHls1080,
|
||||
getHls1080Status,
|
||||
getVideoMetadata,
|
||||
isHlsUploadVideo,
|
||||
hasHls1080Rendition,
|
||||
type LessonVideo
|
||||
} from './video-card-utils';
|
||||
import { encodeAndUploadHls1080 } from './hls-encoder';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
|
||||
|
||||
@@ -22,25 +31,35 @@
|
||||
video: LessonVideo;
|
||||
onRemove: () => void;
|
||||
onThumbnailSaved?: (thumbnailUrl: string) => void;
|
||||
onHlsMetadataUpdated?: (metadata: Record<string, unknown>) => void;
|
||||
/** `inline` aligns with the title row (YouTube-style). `corner` is top-right overlay on the nearest `relative` ancestor. */
|
||||
menuPlacement?: 'inline' | 'corner';
|
||||
}
|
||||
|
||||
let { video, onRemove, onThumbnailSaved, menuPlacement = 'inline' }: Props = $props();
|
||||
let { video, onRemove, onThumbnailSaved, onHlsMetadataUpdated, menuPlacement = 'inline' }: Props = $props();
|
||||
|
||||
let manageThumbsOpen = $state(false);
|
||||
let manageThumbsAsset = $state<OrganizationAsset | null>(null);
|
||||
let isLoadingAsset = $state(false);
|
||||
let dropdownOpen = $state(false);
|
||||
|
||||
let isTranscribing = $state(false);
|
||||
let hasTranscript = $state(false);
|
||||
let hasCheckedTranscript = $state(false);
|
||||
let activePoller: JobPoller<MediaJobEnvelope> | null = null;
|
||||
let isGenerating1080 = $state(false);
|
||||
let sourceFileInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
const assetId = $derived((video as LessonVideo & { assetId?: string }).assetId ?? null);
|
||||
|
||||
const canGenerateTranscript = $derived(video.type === 'upload' && !!assetId);
|
||||
const canManageThumbnails = $derived(video.type === 'upload' && !!assetId);
|
||||
const hls1080Status = $derived(getHls1080Status(video));
|
||||
const showGenerate1080 = $derived(canGenerateHls1080(video) && !isGenerating1080);
|
||||
const show1080Ready = $derived(hls1080Status === 'ready');
|
||||
const show1080Unavailable = $derived(
|
||||
isHlsUploadVideo(video) && !hasHls1080Rendition(video) && !isGenerating1080 && hls1080Status === 'unavailable'
|
||||
);
|
||||
|
||||
onDestroy(() => {
|
||||
activePoller?.stop();
|
||||
@@ -54,6 +73,24 @@
|
||||
hasTranscript = !!data?.segments?.length;
|
||||
}
|
||||
|
||||
async function ensureHlsMetadataChecked() {
|
||||
if (!assetId || !isHlsUploadVideo(video)) return;
|
||||
|
||||
const metadata = getVideoMetadata(video);
|
||||
if (metadata.sourceHeight != null) return;
|
||||
|
||||
const asset = await mediaApi.refreshAsset(assetId);
|
||||
if (!asset?.metadata || typeof asset.metadata !== 'object' || Array.isArray(asset.metadata)) return;
|
||||
|
||||
const assetMetadata = asset.metadata as Record<string, unknown>;
|
||||
onHlsMetadataUpdated?.({
|
||||
...(typeof assetMetadata.sourceWidth === 'number' ? { sourceWidth: assetMetadata.sourceWidth } : {}),
|
||||
...(typeof assetMetadata.sourceHeight === 'number' ? { sourceHeight: assetMetadata.sourceHeight } : {}),
|
||||
...(Array.isArray(assetMetadata.hlsRenditions) ? { hlsRenditions: assetMetadata.hlsRenditions } : {}),
|
||||
...(typeof assetMetadata.hls1080Status === 'string' ? { hls1080Status: assetMetadata.hls1080Status } : {})
|
||||
});
|
||||
}
|
||||
|
||||
async function startTranscriptionPoll(afterAssetId: string) {
|
||||
activePoller?.stop();
|
||||
activePoller = null;
|
||||
@@ -131,6 +168,10 @@
|
||||
async function handleManageThumbnails() {
|
||||
if (!assetId || isLoadingAsset) return;
|
||||
|
||||
// Close the dropdown immediately so the user doesn't see it floating
|
||||
// behind the dialog during the asset refresh round-trip.
|
||||
dropdownOpen = false;
|
||||
|
||||
isLoadingAsset = true;
|
||||
try {
|
||||
const asset = await mediaApi.refreshAsset(assetId);
|
||||
@@ -153,18 +194,78 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleGenerate1080Click() {
|
||||
if (!assetId || isGenerating1080) return;
|
||||
|
||||
dropdownOpen = false;
|
||||
sourceFileInput?.click();
|
||||
}
|
||||
|
||||
async function handleSourceFileSelected(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const selectedFile = input.files?.[0];
|
||||
input.value = '';
|
||||
|
||||
if (!selectedFile || !assetId) return;
|
||||
|
||||
isGenerating1080 = true;
|
||||
onHlsMetadataUpdated?.({ hls1080Status: 'generating' });
|
||||
|
||||
try {
|
||||
const result = await encodeAndUploadHls1080({
|
||||
file: selectedFile,
|
||||
assetId
|
||||
});
|
||||
|
||||
onHlsMetadataUpdated?.({
|
||||
hlsRenditions: result.hlsRenditions,
|
||||
hls1080Status: result.hls1080Status
|
||||
});
|
||||
snackbar.success('snackbar.lesson_video.generate_1080_completed');
|
||||
} catch (error) {
|
||||
console.error('Failed to generate 1080p rendition', error);
|
||||
onHlsMetadataUpdated?.({ hls1080Status: 'failed' });
|
||||
snackbar.error('snackbar.lesson_video.generate_1080_failed');
|
||||
} finally {
|
||||
isGenerating1080 = false;
|
||||
}
|
||||
}
|
||||
|
||||
const generateLabel = $derived(
|
||||
isTranscribing
|
||||
? t.get('course.navItem.lessons.materials.tabs.video.generate_transcript_in_progress')
|
||||
: t.get('course.navItem.lessons.materials.tabs.video.generate_transcript')
|
||||
);
|
||||
|
||||
const generate1080Label = $derived(
|
||||
isGenerating1080
|
||||
? t.get('course.navItem.lessons.materials.tabs.video.generate_1080_in_progress')
|
||||
: t.get('course.navItem.lessons.materials.tabs.video.generate_1080')
|
||||
);
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={sourceFileInput}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
class="hidden"
|
||||
onchange={(event) => {
|
||||
void handleSourceFileSelected(event);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root
|
||||
bind:open={dropdownOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open && canGenerateTranscript) {
|
||||
if (!open) return;
|
||||
|
||||
if (canGenerateTranscript) {
|
||||
void ensureTranscriptChecked();
|
||||
}
|
||||
|
||||
if (isHlsUploadVideo(video)) {
|
||||
void ensureHlsMetadataChecked();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
@@ -192,7 +293,39 @@
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canGenerateTranscript}
|
||||
{#if showGenerate1080}
|
||||
<DropdownMenu.Item disabled={isGenerating1080} onclick={handleGenerate1080Click}>
|
||||
<span class="flex items-center gap-2">
|
||||
<SparklesIcon size={14} />
|
||||
{generate1080Label}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if isGenerating1080}
|
||||
<DropdownMenu.Item disabled>
|
||||
<span class="flex items-center gap-2">
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.video.generate_1080_in_progress')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if show1080Ready}
|
||||
<DropdownMenu.Item disabled>
|
||||
<span class="flex items-center gap-2">
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.video.generate_1080_ready')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if show1080Unavailable}
|
||||
<DropdownMenu.Item disabled>
|
||||
<span class="flex items-center gap-2">
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.video.generate_1080_unavailable')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canGenerateTranscript && !hasTranscript}
|
||||
<DropdownMenu.Item
|
||||
disabled={isTranscribing}
|
||||
onclick={() => {
|
||||
@@ -204,18 +337,18 @@
|
||||
{generateLabel}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{#if hasTranscript}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
void handleViewTranscript();
|
||||
}}
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<EyeIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.video.view_transcript')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if canGenerateTranscript && hasTranscript}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
void handleViewTranscript();
|
||||
}}
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<EyeIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.video.view_transcript')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
|
||||
<span class="flex items-center gap-2">
|
||||
|
||||
@@ -2,7 +2,9 @@ import { getYoutubeVideoId } from '@cio/ui/custom/media-player';
|
||||
|
||||
export type LessonVideo = NonNullable<import('$features/course/utils/types').Lesson['videos']>[number];
|
||||
|
||||
type VideoMetadata = {
|
||||
export type Hls1080Status = 'none' | 'generating' | 'ready' | 'failed';
|
||||
|
||||
export type VideoMetadata = {
|
||||
title?: string;
|
||||
fileName?: string;
|
||||
aspectRatio?: string;
|
||||
@@ -11,8 +13,66 @@ type VideoMetadata = {
|
||||
thumbnailUrl?: string;
|
||||
googleDriveFileId?: string;
|
||||
mimeType?: string;
|
||||
hls?: boolean;
|
||||
sourceWidth?: number;
|
||||
sourceHeight?: number;
|
||||
hlsRenditions?: string[];
|
||||
hls1080Status?: Hls1080Status;
|
||||
};
|
||||
|
||||
export function getVideoMetadata(video: LessonVideo): VideoMetadata {
|
||||
if (video.metadata && typeof video.metadata === 'object' && !Array.isArray(video.metadata)) {
|
||||
return video.metadata as VideoMetadata;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function isHlsUploadVideo(video: LessonVideo): boolean {
|
||||
if (video.type !== 'upload') return false;
|
||||
|
||||
const metadata = getVideoMetadata(video);
|
||||
if (metadata.hls === true) return true;
|
||||
|
||||
return typeof video.link === 'string' && video.link.includes('/hls/') && video.link.endsWith('.m3u8');
|
||||
}
|
||||
|
||||
export function hasHls1080Rendition(video: LessonVideo): boolean {
|
||||
const metadata = getVideoMetadata(video);
|
||||
return metadata.hlsRenditions?.includes('p1080') ?? false;
|
||||
}
|
||||
|
||||
export function canGenerateHls1080(video: LessonVideo): boolean {
|
||||
if (!isHlsUploadVideo(video)) return false;
|
||||
|
||||
const assetId = (video as LessonVideo & { assetId?: string }).assetId;
|
||||
if (!assetId) return false;
|
||||
|
||||
const metadata = getVideoMetadata(video);
|
||||
const sourceHeight = metadata.sourceHeight;
|
||||
if (sourceHeight != null && sourceHeight < 1080) return false;
|
||||
|
||||
if (hasHls1080Rendition(video)) return false;
|
||||
if (metadata.hls1080Status === 'generating') return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getHls1080Status(video: LessonVideo): Hls1080Status | 'unavailable' | 'unknown' {
|
||||
const metadata = getVideoMetadata(video);
|
||||
if (!isHlsUploadVideo(video)) return 'unavailable';
|
||||
|
||||
if (hasHls1080Rendition(video) || metadata.hls1080Status === 'ready') {
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
const sourceHeight = metadata.sourceHeight;
|
||||
if (sourceHeight == null) return 'unknown';
|
||||
if (sourceHeight < 1080) return 'unavailable';
|
||||
|
||||
return metadata.hls1080Status ?? 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnail URL for a lesson video. YouTube uses img.youtube.com; uploads use metadata.thumbnailUrl when present.
|
||||
*/
|
||||
|
||||
+57
-16
@@ -1,9 +1,52 @@
|
||||
const JPEG_QUALITY = 0.85;
|
||||
const DEFAULT_SEEK_SECONDS = 1;
|
||||
const THUMBNAIL_TIMEOUT_MS = 15_000;
|
||||
/** Cap the captured frame to this width so the preview matches the stored thumbnail. */
|
||||
const MAX_THUMBNAIL_WIDTH = 1280;
|
||||
/** Hard ceiling for the generated thumbnail (matches the server job). */
|
||||
const THUMBNAIL_MAX_BYTES = 500 * 1024;
|
||||
/** JPEG quality steps tried (best first) until the frame fits under the size cap. */
|
||||
const THUMBNAIL_QUALITY_LADDER = [0.9, 0.8, 0.7, 0.6, 0.5];
|
||||
|
||||
export type ThumbnailResult = { blob: Blob; durationSeconds: number };
|
||||
|
||||
/**
|
||||
* Encode the canvas as JPEG, stepping quality down until the blob fits under
|
||||
* `maxBytes`. Resolves with the first blob within the cap, the smallest one
|
||||
* produced if none fit, or null on failure.
|
||||
*/
|
||||
function canvasToJpegBlobUnderCap(canvas: HTMLCanvasElement, maxBytes: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
let smallest: Blob | null = null;
|
||||
let index = 0;
|
||||
|
||||
const tryNext = () => {
|
||||
if (index >= THUMBNAIL_QUALITY_LADDER.length) {
|
||||
resolve(smallest);
|
||||
return;
|
||||
}
|
||||
|
||||
const quality = THUMBNAIL_QUALITY_LADDER[index];
|
||||
index += 1;
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
if (!smallest || blob.size < smallest.size) smallest = blob;
|
||||
if (blob.size <= maxBytes) {
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
}
|
||||
tryNext();
|
||||
},
|
||||
'image/jpeg',
|
||||
quality
|
||||
);
|
||||
};
|
||||
|
||||
tryNext();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a thumbnail from a video file by capturing a frame to canvas.
|
||||
* Seeks to 1s or duration/4 for short clips. Returns null on failure.
|
||||
@@ -44,13 +87,15 @@ export function generateVideoThumbnail(videoFile: File): Promise<ThumbnailResult
|
||||
|
||||
video.addEventListener('seeked', () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
if (canvas.width <= 0 || canvas.height <= 0) {
|
||||
if (video.videoWidth <= 0 || video.videoHeight <= 0) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = video.videoWidth > MAX_THUMBNAIL_WIDTH ? MAX_THUMBNAIL_WIDTH / video.videoWidth : 1;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(video.videoWidth * scale);
|
||||
canvas.height = Math.round(video.videoHeight * scale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
finish(null);
|
||||
@@ -58,17 +103,13 @@ export function generateVideoThumbnail(videoFile: File): Promise<ThumbnailResult
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const durationSeconds = video.duration;
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
finish({ blob, durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0 });
|
||||
} else {
|
||||
finish(null);
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
JPEG_QUALITY
|
||||
);
|
||||
canvasToJpegBlobUnderCap(canvas, THUMBNAIL_MAX_BYTES).then((blob) => {
|
||||
if (blob) {
|
||||
finish({ blob, durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0 });
|
||||
} else {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,15 @@
|
||||
</Button>
|
||||
|
||||
{#if videos.length}
|
||||
<Item.Group class="grid! w-full grid-cols-1 gap-x-5 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!--
|
||||
`auto-fill` + `minmax(200px, 1fr)` packs as many columns as fit while
|
||||
each card stays at least 200px wide. Unlike `auto-fit`, empty tracks
|
||||
are kept — so a single card on a wide container stays slim instead
|
||||
of stretching across the full row. As the container narrows
|
||||
(e.g. the transcript side panel opens) the grid drops columns
|
||||
naturally instead of squeezing the cards.
|
||||
-->
|
||||
<Item.Group class="grid! w-full grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-x-5 gap-y-8">
|
||||
{#each videos as video, index}
|
||||
<LessonVideoSimpleCard {video} {index} isEditMode={true} onRemove={() => requestRemoveVideo(index)} />
|
||||
{/each}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { orgs } from '$lib/utils/store/org';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import CourseSidebarLogo from './course-sidebar-logo.svelte';
|
||||
import Navigation from './course-sidebar-navigation.svelte';
|
||||
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
|
||||
@@ -112,14 +112,14 @@
|
||||
{#if !isOrgLoaded}
|
||||
<SidebarSkeleton />
|
||||
{:else}
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Root collapsible="icon" class="ui:z-150!">
|
||||
<Sidebar.Header>
|
||||
<CourseSidebarLogo />
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
{#if isCourseReady}
|
||||
<Navigation path={currentPath} {id} isStudent={$isOrgStudent ?? undefined} />
|
||||
<Navigation path={currentPath} {id} isStudent={$isStudentExperience} />
|
||||
{:else}
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<div class="flex h-8 items-center gap-2 rounded-md px-2">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { copyPublicCoursePageUrl, viewAsStudent } from '$features/course/utils/course-preview';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
courseSlug?: string | null;
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), courseSlug = null, currentOrgDomain = '' }: Props = $props();
|
||||
|
||||
let isNavigating = $state(false);
|
||||
|
||||
async function handleCopyLink() {
|
||||
if (!courseSlug) return;
|
||||
|
||||
await copyPublicCoursePageUrl(courseSlug, currentOrgDomain);
|
||||
}
|
||||
|
||||
async function handleGoToLms() {
|
||||
isNavigating = true;
|
||||
|
||||
// Opens the student view in a new tab; close the modal once the handoff starts.
|
||||
const ok = await viewAsStudent({ courseSlug, currentOrgDomain });
|
||||
isNavigating = false;
|
||||
|
||||
if (ok) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen && !isNavigating) open = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-[calc(100%-2rem)] max-w-xl! p-4">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.view_as_student.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('course.view_as_student.description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<img
|
||||
src="https://assets.cdn.clsrio.com/cio-lms-plain.png"
|
||||
alt=""
|
||||
class="ui:border mx-auto my-4 w-full rounded-lg"
|
||||
width="2095"
|
||||
height="1744"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="secondary" onclick={handleCopyLink} disabled={!courseSlug || isNavigating}>
|
||||
{$t('course.view_as_student.copy_link')}
|
||||
</Button>
|
||||
<Button variant="default" onclick={handleGoToLms} disabled={!courseSlug} loading={isNavigating}>
|
||||
{$t('course.view_as_student.go_to_lms')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -3,13 +3,15 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as ButtonGroup from '@cio/ui/base/button-group';
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import { onDestroy, onMount, tick, untrack } from 'svelte';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
|
||||
@@ -37,7 +39,6 @@
|
||||
import ExerciseSettingsTab from '$features/course/components/exercise/exercise-settings-tab.svelte';
|
||||
import ViewMode from '$features/course/components/exercise/view-mode.svelte';
|
||||
import Submissions from '$features/course/components/exercise/submissions/submissions.svelte';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
|
||||
import { ContentNavigationActions } from '$features/course/components/lesson';
|
||||
import {
|
||||
@@ -373,6 +374,11 @@
|
||||
const sectionId = handleAddSection();
|
||||
await scrollToExerciseElement(`exercise-section-${sectionId}`);
|
||||
}
|
||||
|
||||
function togglePreviewMode() {
|
||||
preview = !preview;
|
||||
reorderQuestions = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Header isSticky={true} class="top-12! z-100! min-h-[36px]">
|
||||
@@ -401,71 +407,87 @@
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if selectedTab === 'questions'}
|
||||
<div class="right-0 flex w-full items-center justify-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button class="mr-2" onclick={handleSave} loading={isSaving}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.save')}
|
||||
</Button>
|
||||
<IconButton
|
||||
onclick={() => {
|
||||
preview = !preview;
|
||||
reorderQuestions = false;
|
||||
}}
|
||||
tooltip={preview
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.preview')}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
{#if preview}
|
||||
<PencilIcon size={20} />
|
||||
{:else}
|
||||
<EyeIcon size={20} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button type="button" variant="ghost" size="icon" class="h-8 w-8">
|
||||
<CirclePlusIcon size={20} />
|
||||
<span class="sr-only">{$t('course.navItem.lessons.exercises.all_exercises.add')}</span>
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="center" class="z-201!">
|
||||
<DropdownMenu.Item onclick={addQuestionFromHeader}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={addSectionFromHeader}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8">
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
<span class="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if hasSections($questionnaire.sections)}
|
||||
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
|
||||
{reorderQuestions
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<ButtonGroup.Root>
|
||||
<Button variant="outline" size="sm" onclick={addQuestionFromHeader}>
|
||||
<CirclePlusIcon size={16} />
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
|
||||
</Button>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={$t('course.navItem.lessons.exercises.all_exercises.add')}
|
||||
>
|
||||
<ChevronDownIcon size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="z-201!">
|
||||
<DropdownMenu.Item onclick={addSectionFromHeader}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</ButtonGroup.Root>
|
||||
|
||||
<ButtonGroup.Root>
|
||||
<Button variant="outline" size="sm" onclick={togglePreviewMode}>
|
||||
{#if preview}
|
||||
<PencilIcon size={16} />
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')}
|
||||
{:else}
|
||||
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<EyeIcon size={16} />
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.preview')}
|
||||
{/if}
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600 focus:text-red-600 dark:text-red-400"
|
||||
onclick={() => (shouldDeleteExercise = true)}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_exercise')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
|
||||
<ButtonGroup.Root>
|
||||
<Button size="sm" onclick={handleSave} loading={isSaving}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.save')}
|
||||
</Button>
|
||||
<ButtonGroup.Separator />
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon-sm"
|
||||
aria-label={$t('course.navItem.lessons.exercises.all_exercises.more')}
|
||||
>
|
||||
<MoreHorizontalIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="z-201!">
|
||||
{#if hasSections($questionnaire.sections)}
|
||||
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
|
||||
{reorderQuestions
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600 focus:text-red-600 dark:text-red-400"
|
||||
onclick={() => (shouldDeleteExercise = true)}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_exercise')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</ButtonGroup.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -478,7 +500,7 @@
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="overflow-x-hidden">
|
||||
<div class="overflow-x-hidden pb-20">
|
||||
{#if $isOrgStudent}
|
||||
{#if isExerciseUnlocked}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import HistoryIcon from '@lucide/svelte/icons/history';
|
||||
// import HistoryIcon from '@lucide/svelte/icons/history';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
@@ -381,13 +381,15 @@
|
||||
{/if}
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if mode === MODES.edit && window.innerWidth >= 1024}
|
||||
<IconButton onclick={() => (isVersionDrawerOpen = true)}>
|
||||
<HistoryIcon size={20} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-row items-center gap-2 lg:flex">
|
||||
<!--
|
||||
{#if mode === MODES.edit && window.innerWidth >= 1024}
|
||||
<IconButton onclick={() => (isVersionDrawerOpen = true)}>
|
||||
<HistoryIcon size={20} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
-->
|
||||
|
||||
{#if mode === MODES.edit}
|
||||
<span class="ui:text-muted-foreground text-sm" aria-live="polite">
|
||||
{#if lessonApi.isSaving}
|
||||
@@ -416,7 +418,9 @@
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class={`overflow-x-hidden py-6 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}>
|
||||
<div
|
||||
class={`overflow-x-hidden py-6 pb-20 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}
|
||||
>
|
||||
{#if $isOrgStudent && lessonApi.lesson && !isLessonUnlocked}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
|
||||
@@ -367,10 +367,10 @@
|
||||
<Field.Field>
|
||||
<div class="relative w-fit">
|
||||
<img
|
||||
style="min-width:280px; min-height:200px"
|
||||
style="min-width:320px; min-height:200px"
|
||||
alt="Course cover"
|
||||
src={$settings.logo ? $settings.logo : '/images/classroomio-course-img-template.jpg'}
|
||||
class="relative mt-2 h-[200px] w-[280px] rounded-md md:mt-0"
|
||||
class="relative mt-2 h-[200px] w-[320px] rounded-md md:mt-0"
|
||||
/>
|
||||
</div>
|
||||
</Field.Field>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { accountApi } from '$features/account/api/account.svelte';
|
||||
|
||||
interface OpenCoursePreviewOptions {
|
||||
courseId: string;
|
||||
@@ -8,6 +9,11 @@ interface OpenCoursePreviewOptions {
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
interface ViewAsStudentOptions {
|
||||
courseSlug?: string | null;
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
/** Absolute URL for the org-site public course page (`/course/{slug}`). Client-only. */
|
||||
export function getPublicCoursePageUrl(courseSlug: string, currentOrgDomain = ''): string {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -41,6 +47,45 @@ export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = ''
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* "View as student" handoff. Mints a short-lived login-link token for the current
|
||||
* user and navigates to the org domain's `/api/auth/login-link`, which signs them in
|
||||
* there (host-only session) and redirects to the course's student view.
|
||||
*
|
||||
* One flow for cloud and self-hosted: on cloud the tenant-router worker fronts
|
||||
* `/api/auth/*`→API and `/course/*`→dashboard under the tenant host; on self-hosted the
|
||||
* dashboard's own `hooks.server.ts` proxies `/api/auth/*` to the API. Either way the
|
||||
* cookie lands on `currentOrgDomain` and the relative `redirect` resolves there.
|
||||
*/
|
||||
export async function viewAsStudent({ courseSlug, currentOrgDomain = '' }: ViewAsStudentOptions) {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!courseSlug) {
|
||||
snackbar.info('course.header.preview_missing_slug');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = await accountApi.createViewAsStudentToken();
|
||||
if (!token) {
|
||||
snackbar.error('snackbar.view_as_student.failed');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const origin = currentOrgDomain?.trim() || window.location.origin;
|
||||
const loginLinkUrl = new URL('/api/auth/login-link', origin);
|
||||
loginLinkUrl.searchParams.set('token', token);
|
||||
loginLinkUrl.searchParams.set('redirect', `/course/${courseSlug}`);
|
||||
|
||||
// Cross-origin handoff — open in a new tab so the teacher keeps their dashboard.
|
||||
window.open(loginLinkUrl.toString(), '_blank', 'noopener,noreferrer');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function copyPublicCoursePageUrl(courseSlug: string, currentOrgDomain = '') {
|
||||
const url = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { CourseContentItem, CourseMembers } from '$features/course/utils/ty
|
||||
import type { Marks } from './types';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import Papa from 'papaparse';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import jsPDF from 'jspdf';
|
||||
|
||||
/** Exercise information for display in marks table (linear, no lesson grouping) */
|
||||
export interface ExerciseInfo {
|
||||
@@ -188,13 +186,18 @@ export function generateMarksCSV(
|
||||
* @param studentMarksByExerciseId Student marks by exercise
|
||||
* @param courseTitle Course title for filename and header
|
||||
*/
|
||||
export function generateMarksPDF(
|
||||
export async function generateMarksPDF(
|
||||
students: CourseMembers,
|
||||
exercises: ExerciseInfo[],
|
||||
studentMarksByExerciseId: StudentMarksByExercise,
|
||||
courseTitle: string
|
||||
): void {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||
import('jspdf'),
|
||||
import('jspdf-autotable')
|
||||
]);
|
||||
|
||||
const doc = new jsPDF({ orientation: 'landscape' });
|
||||
const head = [['Student', ...exercises.map((ex) => ex.title), 'Avg grade']];
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@ export function toPublicLessonView(lesson: Extract<PublicItem, { kind: 'lesson'
|
||||
title: lesson.title,
|
||||
sectionTitle: lesson.sectionTitle,
|
||||
body: lesson.body ?? '',
|
||||
video: lesson.video,
|
||||
// `lesson.video.hls` may be present on the wire from the public-course
|
||||
// API but isn't in the older inferred type; spread through verbatim.
|
||||
video: lesson.video as PublicLessonViewData['video'],
|
||||
isUnlocked: lesson.isUnlocked
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,6 +301,8 @@ export type CourseMetadata = (NonNullable<ApiCourse['metadata']> extends object
|
||||
? NonNullable<ApiCourse['metadata']>
|
||||
: Record<string, unknown>) & {
|
||||
isContentGroupingEnabled?: boolean;
|
||||
skills?: string[];
|
||||
tools?: string[];
|
||||
};
|
||||
export type CourseCertificate =
|
||||
NonNullable<ApiCourse['certificate']> extends object
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { LICENSE_FEATURE_IDS, type LicenseFeatureId } from '@cio/utils/license';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import {
|
||||
getActivePlanName,
|
||||
getLicenseFeaturesForPlan,
|
||||
LICENSE_FEATURE_IDS,
|
||||
type LicenseFeatureId
|
||||
} from '@cio/utils/license';
|
||||
|
||||
class LicenseApi {
|
||||
features = $state<LicenseFeatureId[]>([]);
|
||||
@@ -9,6 +16,15 @@ class LicenseApi {
|
||||
this.isResolved = true;
|
||||
}
|
||||
|
||||
syncFromAccount(instanceFeatures: string[] = [], org?: Pick<AccountOrg, 'plans'>) {
|
||||
if (PUBLIC_IS_SELFHOSTED === 'true') {
|
||||
this.setFeatures(instanceFeatures);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setFeatures(getLicenseFeaturesForPlan(getActivePlanName(org?.plans)));
|
||||
}
|
||||
|
||||
hasAccess(feature: LicenseFeatureId): boolean {
|
||||
return this.features.includes(feature);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
|
||||
import { ActivityCard } from '$features/ui';
|
||||
@@ -259,7 +260,11 @@
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-1 flex-col space-y-3">
|
||||
{#if highlightedCourses.length > 0}
|
||||
{#if coursesApi.isLoading}
|
||||
<div class="ui:text-muted-foreground flex flex-1 items-center justify-center py-16">
|
||||
<Spinner class="size-6" />
|
||||
</div>
|
||||
{:else if highlightedCourses.length > 0}
|
||||
{#each highlightedCourses as course}
|
||||
{@const courseProgress = getStudentCourseProgressPercent(course)}
|
||||
{@const totalItems = getCourseTotalItems(course)}
|
||||
@@ -352,36 +357,42 @@
|
||||
{$t('dashboard.compliance')}
|
||||
</h2>
|
||||
|
||||
<article class="ui:bg-card ui:text-card-foreground flex flex-1 rounded border p-4">
|
||||
<div class="flex items-center justify-between gap-4 lg:flex-col lg:items-start xl:flex-row xl:items-center">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<div class="ui:bg-primary/10 flex size-12 shrink-0 items-center justify-center rounded">
|
||||
<ClipboardCheckIcon class="ui:text-primary size-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-semibold">{$t('dashboard.compliance_score')}</h3>
|
||||
<p class="ui:text-muted-foreground mt-1 text-sm">
|
||||
{#if complianceCourses.length > 0}
|
||||
{$t('dashboard.required_courses_completed', {
|
||||
completed: completedComplianceCourses.length,
|
||||
total: complianceCourses.length
|
||||
})}
|
||||
{:else}
|
||||
{$t('dashboard.compliance_score_empty')}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="ui:text-primary shrink-0 text-2xl font-semibold">
|
||||
{#if complianceScore === null}
|
||||
--
|
||||
{:else}
|
||||
{complianceScore}%
|
||||
{/if}
|
||||
</p>
|
||||
{#if coursesApi.isLoading}
|
||||
<div class="ui:text-muted-foreground flex flex-1 items-center justify-center rounded border py-16">
|
||||
<Spinner class="size-6" />
|
||||
</div>
|
||||
</article>
|
||||
{:else}
|
||||
<article class="ui:bg-card ui:text-card-foreground flex flex-1 rounded border p-4">
|
||||
<div class="flex items-center justify-between gap-4 lg:flex-col lg:items-start xl:flex-row xl:items-center">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<div class="ui:bg-primary/10 flex size-12 shrink-0 items-center justify-center rounded">
|
||||
<ClipboardCheckIcon class="ui:text-primary size-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-semibold">{$t('dashboard.compliance_score')}</h3>
|
||||
<p class="ui:text-muted-foreground mt-1 text-sm">
|
||||
{#if complianceCourses.length > 0}
|
||||
{$t('dashboard.required_courses_completed', {
|
||||
completed: completedComplianceCourses.length,
|
||||
total: complianceCourses.length
|
||||
})}
|
||||
{:else}
|
||||
{$t('dashboard.compliance_score_empty')}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="ui:text-primary shrink-0 text-2xl font-semibold">
|
||||
{#if complianceScore === null}
|
||||
--
|
||||
{:else}
|
||||
{complianceScore}%
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,10 @@ import type {
|
||||
OrganizationAsset,
|
||||
RegenerateAssetThumbnailRequest,
|
||||
SelectAssetThumbnailRequest,
|
||||
TranscriptSegment,
|
||||
UpdateAssetData,
|
||||
UpdateAssetRequest,
|
||||
UpdateAssetTranscriptRequest,
|
||||
YouTubeMetadata
|
||||
} from '../utils/types';
|
||||
import type {
|
||||
@@ -68,8 +70,18 @@ export class MediaApi extends BaseApiWithErrors {
|
||||
return;
|
||||
}
|
||||
|
||||
const { page, limit, kind, status, search, includeExternal } = parsed.data;
|
||||
const rpcQuery = {
|
||||
page: String(page),
|
||||
limit: String(limit),
|
||||
includeExternal: String(includeExternal),
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(status !== undefined ? { status } : {}),
|
||||
...(search !== undefined ? { search } : {})
|
||||
};
|
||||
|
||||
await this.execute<ListAssetsRequest>({
|
||||
requestFn: () => classroomio.organization.assets.$get({ query: parsed.data }),
|
||||
requestFn: () => classroomio.organization.assets.$get({ query: rpcQuery }),
|
||||
logContext: 'listing organization assets',
|
||||
onSuccess: (response) => {
|
||||
this.assets = response.data;
|
||||
@@ -88,8 +100,13 @@ export class MediaApi extends BaseApiWithErrors {
|
||||
return;
|
||||
}
|
||||
|
||||
const rpcQuery = {
|
||||
includeArchived: String(parsed.data.includeArchived),
|
||||
includeExternal: String(parsed.data.includeExternal)
|
||||
};
|
||||
|
||||
await this.execute<typeof classroomio.organization.assets.storage.$get>({
|
||||
requestFn: () => classroomio.organization.assets.storage.$get({ query: parsed.data }),
|
||||
requestFn: () => classroomio.organization.assets.storage.$get({ query: rpcQuery }),
|
||||
logContext: 'fetching media manager storage summary',
|
||||
onSuccess: (response) => {
|
||||
this.storageSummary = response.data;
|
||||
@@ -406,6 +423,29 @@ export class MediaApi extends BaseApiWithErrors {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Save edited transcript segments (org admins only). Returns the updated payload or null on failure. */
|
||||
async updateAssetTranscript(assetId: string, segments: TranscriptSegment[]): Promise<AssetTranscriptPayload | null> {
|
||||
let data: AssetTranscriptPayload | null = null;
|
||||
|
||||
await this.execute<UpdateAssetTranscriptRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.assets[':assetId'].transcript.$put({
|
||||
param: { assetId },
|
||||
json: { segments }
|
||||
}),
|
||||
logContext: 'updating asset transcript',
|
||||
onSuccess: (response) => {
|
||||
data = response.data;
|
||||
snackbar.success('snackbar.media_manager.transcript_saved');
|
||||
},
|
||||
onError: () => {
|
||||
snackbar.error('snackbar.media_manager.transcript_save_failed');
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Persist the chosen thumbnail URL (must be a generated candidate or a fresh upload). */
|
||||
async selectThumbnail(assetId: string, thumbnailUrl: string): Promise<OrganizationAsset | null> {
|
||||
let updated: OrganizationAsset | null = null;
|
||||
|
||||
+150
-106
@@ -4,6 +4,7 @@
|
||||
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
|
||||
import type { FileRejectedReason } from '@cio/ui/custom/file-drop-zone';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import MediaPlayer from '@cio/ui/custom/media-player/media-player.svelte';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||
|
||||
@@ -12,6 +13,18 @@
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
function buildHlsUrl(asset: OrganizationAsset): string | null {
|
||||
if (!asset.hlsManifestKey) return null;
|
||||
|
||||
const built = classroomio.hls[':assetId']['*'].$url({ param: { assetId: asset.id } });
|
||||
return built.toString().replace(/\/\*$/, '') + '/master.m3u8';
|
||||
}
|
||||
|
||||
async function mintHlsCookie(assetId: string): Promise<void> {
|
||||
await classroomio.organization.assets[':assetId'].hls.cookie.$post({ param: { assetId } });
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
@@ -19,7 +32,7 @@
|
||||
onThumbnailSaved?: (asset: OrganizationAsset) => void;
|
||||
}
|
||||
|
||||
const THUMBNAIL_MAX_SIZE = 5 * FileDropZone.MEGABYTE;
|
||||
const THUMBNAIL_MAX_SIZE = 1 * FileDropZone.MEGABYTE;
|
||||
const REGEN_POLL_INTERVAL_MS = 2000;
|
||||
const REGEN_POLL_TIMEOUT_MS = 60_000;
|
||||
const SLOT_KEYS = ['start', 'middle', 'end'] as const;
|
||||
@@ -30,6 +43,7 @@
|
||||
let selectedUrl = $state<string | null>(null);
|
||||
let customUrl = $state<string | null>(null);
|
||||
let videoUrl = $state<string | null>(null);
|
||||
let playerRef = $state<ReturnType<typeof MediaPlayer> | null>(null);
|
||||
let isVideoLoading = $state(false);
|
||||
let isUploading = $state(false);
|
||||
let isSaving = $state(false);
|
||||
@@ -77,6 +91,7 @@
|
||||
|
||||
function selectCandidate(url: string) {
|
||||
selectedUrl = url;
|
||||
playerRef?.setPoster(url);
|
||||
}
|
||||
|
||||
async function handleCustomUpload(files: File[]) {
|
||||
@@ -88,6 +103,7 @@
|
||||
const url = await uploadImage(file);
|
||||
customUrl = url;
|
||||
selectedUrl = url;
|
||||
playerRef?.setPoster(url);
|
||||
snackbar.success('snackbar.media_manager.thumbnail_uploaded');
|
||||
} catch {
|
||||
snackbar.error('snackbar.media_manager.thumbnail_upload_failed');
|
||||
@@ -157,128 +173,156 @@
|
||||
if (!isOpen) resetState();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-h-[85vh] w-[95%] max-w-3xl overflow-y-auto">
|
||||
<Dialog.Content
|
||||
class="ui:max-h-[85vh] ui:w-[95%] ui:max-w-3xl ui:overflow-hidden ui:grid-rows-[auto_minmax(0,1fr)_auto] ui:pb-0"
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('media_manager.thumbnails.manage_title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('media_manager.thumbnails.manage_description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if asset}
|
||||
<div class="space-y-5 p-1">
|
||||
<div class="ui:bg-muted/40 aspect-video w-full overflow-hidden rounded-md border">
|
||||
{#if isVideoLoading}
|
||||
<Skeleton class="h-full w-full" />
|
||||
{:else if videoUrl}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video controls preload="metadata" src={videoUrl} class="h-full w-full bg-black"></video>
|
||||
{:else}
|
||||
<div class="ui:text-muted-foreground flex h-full w-full items-center justify-center text-sm">
|
||||
{$t('media_manager.thumbnails.preview_unavailable')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium">{$t('media_manager.thumbnails.generated')}</p>
|
||||
{#if allCandidatesMissing}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t('media_manager.thumbnails.none_generated_warning')}
|
||||
</p>
|
||||
{@const activeAsset = asset}
|
||||
<div class="ui:min-h-0 ui:overflow-y-auto">
|
||||
<div class="space-y-5 p-1">
|
||||
<div class="ui:bg-muted/40 aspect-video w-full overflow-hidden rounded-md border">
|
||||
{#if isVideoLoading}
|
||||
<Skeleton class="h-full w-full" />
|
||||
{:else if activeAsset.hlsManifestKey}
|
||||
{@const hlsUrl = buildHlsUrl(activeAsset)}
|
||||
{#if hlsUrl}
|
||||
<MediaPlayer
|
||||
bind:this={playerRef}
|
||||
source={{
|
||||
type: 'upload',
|
||||
url: hlsUrl,
|
||||
hls: true,
|
||||
metadata: { thumbnailUrl: selectedUrl ?? customUrl ?? activeAsset.thumbnailUrl ?? undefined }
|
||||
}}
|
||||
options={{
|
||||
controls: true,
|
||||
playsinline: true,
|
||||
onBeforeHlsLoad: () => mintHlsCookie(activeAsset.id)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{:else if videoUrl}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
poster={selectedUrl ?? customUrl ?? activeAsset.thumbnailUrl ?? undefined}
|
||||
src={videoUrl}
|
||||
class="h-full w-full bg-black"
|
||||
></video>
|
||||
{:else}
|
||||
<div class="ui:text-muted-foreground flex h-full w-full items-center justify-center text-sm">
|
||||
{$t('media_manager.thumbnails.preview_unavailable')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{#each SLOT_KEYS as slotKey, index (slotKey)}
|
||||
{@const url = candidates[index] ?? null}
|
||||
{@const isSelected = url !== null && url === selectedUrl}
|
||||
{#if isRegenerating}
|
||||
<Skeleton class="aspect-video w-full rounded-md" />
|
||||
{:else if url}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectCandidate(url)}
|
||||
class={`ui:focus-visible:ring-ring relative aspect-video w-full overflow-hidden rounded-md border text-left transition focus-visible:ring-2 focus-visible:outline-none ${
|
||||
isSelected ? 'ring-primary border-primary ring-2' : 'hover:border-primary/50'
|
||||
}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<span
|
||||
class="ui:bg-background/85 absolute bottom-1 left-1 rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-medium">{$t('media_manager.thumbnails.generated')}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if allCandidatesMissing}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t('media_manager.thumbnails.none_generated_warning')}
|
||||
</p>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={regenerate} loading={isRegenerating} disabled={isSaving}>
|
||||
<RefreshCcwIcon class="size-4" />
|
||||
{$t('media_manager.thumbnails.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{#each SLOT_KEYS as slotKey, index (slotKey)}
|
||||
{@const url = candidates[index] ?? null}
|
||||
{@const isSelected = url !== null && url === selectedUrl}
|
||||
{#if isRegenerating}
|
||||
<Skeleton class="aspect-video w-full rounded-md" />
|
||||
{:else if url}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectCandidate(url)}
|
||||
class={`ui:focus-visible:ring-ring relative aspect-video w-full overflow-hidden rounded-md border text-left transition focus-visible:ring-2 focus-visible:outline-none ${
|
||||
isSelected ? 'ring-primary border-primary ring-2' : 'hover:border-primary/50'
|
||||
}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<span
|
||||
class="ui:bg-background/85 absolute bottom-1 left-1 rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
|
||||
>
|
||||
{$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
|
||||
</span>
|
||||
{#if isSelected}
|
||||
<span
|
||||
class="ui:bg-primary ui:text-primary-foreground absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full"
|
||||
>
|
||||
<CheckIcon class="custom size-3" />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="ui:bg-muted/30 ui:text-muted-foreground flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-xs"
|
||||
>
|
||||
{$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
|
||||
</span>
|
||||
{#if isSelected}
|
||||
<span
|
||||
class="ui:bg-primary ui:text-primary-foreground absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full"
|
||||
>
|
||||
<CheckIcon class="size-3" />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="ui:bg-muted/30 ui:text-muted-foreground flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-xs"
|
||||
>
|
||||
{$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">{$t('media_manager.thumbnails.upload_custom')}</p>
|
||||
<div class={isUploading ? 'ui:pointer-events-none ui:opacity-50' : ''}>
|
||||
<FileDropZone.Root
|
||||
accept={FileDropZone.ACCEPT_IMAGE}
|
||||
maxFiles={1}
|
||||
fileCount={0}
|
||||
maxFileSize={THUMBNAIL_MAX_SIZE}
|
||||
onUpload={handleCustomUpload}
|
||||
onFileRejected={handleUploadRejected}
|
||||
>
|
||||
<FileDropZone.Trigger
|
||||
label={$t('media_manager.form.thumbnail_drop_label')}
|
||||
formatMaxSize={() => $t('media_manager.form.thumbnail_drop_size')}
|
||||
/>
|
||||
</FileDropZone.Root>
|
||||
</div>
|
||||
{#if customUrl}
|
||||
<div class="flex items-center gap-3 rounded-md border p-2">
|
||||
<img
|
||||
src={customUrl}
|
||||
alt={$t('media_manager.thumbnails.custom_preview_alt')}
|
||||
class="h-16 w-24 rounded object-cover"
|
||||
/>
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t('media_manager.thumbnails.custom_selected_hint')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">{$t('media_manager.thumbnails.upload_custom')}</p>
|
||||
<div class={isUploading ? 'ui:pointer-events-none ui:opacity-50' : ''}>
|
||||
<FileDropZone.Root
|
||||
accept={FileDropZone.ACCEPT_IMAGE}
|
||||
maxFiles={1}
|
||||
fileCount={0}
|
||||
maxFileSize={THUMBNAIL_MAX_SIZE}
|
||||
onUpload={handleCustomUpload}
|
||||
onFileRejected={handleUploadRejected}
|
||||
>
|
||||
<FileDropZone.Trigger
|
||||
label={$t('media_manager.form.thumbnail_drop_label')}
|
||||
formatMaxSize={() => $t('media_manager.form.thumbnail_drop_size')}
|
||||
/>
|
||||
</FileDropZone.Root>
|
||||
</div>
|
||||
{#if customUrl}
|
||||
<div class="flex items-center gap-3 rounded-md border p-2">
|
||||
<img
|
||||
src={customUrl}
|
||||
alt={$t('media_manager.thumbnails.custom_preview_alt')}
|
||||
class="h-16 w-24 rounded object-cover"
|
||||
/>
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t('media_manager.thumbnails.custom_selected_hint')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer class="ui:gap-2 flex-col sm:flex-row sm:justify-between">
|
||||
<Button variant="outline" onclick={regenerate} loading={isRegenerating} disabled={isSaving}>
|
||||
<RefreshCcwIcon class="size-4" />
|
||||
{$t('media_manager.thumbnails.regenerate')}
|
||||
<Dialog.Footer class="ui:border-border ui:-mx-6 ui:border-t ui:px-6 ui:py-4">
|
||||
<Button variant="outline" onclick={() => (open = false)} disabled={isSaving || isRegenerating}>
|
||||
{$t('media_manager.edit.cancel')}
|
||||
</Button>
|
||||
<Button onclick={save} loading={isSaving} disabled={!hasSelection || isRegenerating || isUploading}>
|
||||
{$t('media_manager.thumbnails.save_selection')}
|
||||
</Button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={() => (open = false)} disabled={isSaving || isRegenerating}>
|
||||
{$t('media_manager.edit.cancel')}
|
||||
</Button>
|
||||
<Button onclick={save} loading={isSaving} disabled={!hasSelection || isRegenerating || isUploading}>
|
||||
{$t('media_manager.thumbnails.save_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -57,6 +57,12 @@ export type GetAssetTranscriptResponse = InferResponseType<GetAssetTranscriptReq
|
||||
export type GetAssetTranscriptSuccess = Extract<GetAssetTranscriptResponse, { success: true }>;
|
||||
export type AssetTranscriptPayload = GetAssetTranscriptSuccess['data'];
|
||||
|
||||
export type UpdateAssetTranscriptRequest =
|
||||
(typeof classroomio.organization)['assets'][':assetId']['transcript']['$put'];
|
||||
export type UpdateAssetTranscriptResponse = InferResponseType<UpdateAssetTranscriptRequest>;
|
||||
export type UpdateAssetTranscriptSuccess = Extract<UpdateAssetTranscriptResponse, { success: true }>;
|
||||
export type TranscriptSegment = NonNullable<AssetTranscriptPayload>['segments'][number];
|
||||
|
||||
export type SelectAssetThumbnailRequest = (typeof classroomio.organization)['assets'][':assetId']['thumbnail']['$put'];
|
||||
export type SelectAssetThumbnailResponse = InferResponseType<SelectAssetThumbnailRequest>;
|
||||
export type SelectAssetThumbnailSuccess = Extract<SelectAssetThumbnailResponse, { success: true }>;
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import type {
|
||||
AssignAudienceCoursesRequest,
|
||||
CreateLinkInviteRequest,
|
||||
DeleteAudienceMemberRequest,
|
||||
DeleteTeamRequest,
|
||||
DomainRequestRequest,
|
||||
GetAudienceRequest,
|
||||
GetLinkInviteRequest,
|
||||
GetOrgPublicCoursesRequest,
|
||||
ImportAudienceRequest,
|
||||
InviteTeamRequest,
|
||||
OrgLinkInvite,
|
||||
OrgPublicCourses,
|
||||
OrganizationAudience,
|
||||
OrganizationAudiencePagination,
|
||||
OrganizationAudienceQuery,
|
||||
OrganizationTeamMembers,
|
||||
ResendAudienceInviteRequest,
|
||||
RevokeAudienceInviteRequest
|
||||
RevokeAudienceInviteRequest,
|
||||
ToggleLinkInviteRequest
|
||||
} from '../utils/types';
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
@@ -553,6 +557,44 @@ class OrgApi extends BaseApiWithErrors {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
linkInvite = $state<OrgLinkInvite>(null);
|
||||
|
||||
async getLinkInvite() {
|
||||
return this.execute<GetLinkInviteRequest>({
|
||||
requestFn: () => classroomio.organization['link-invite'].$get(),
|
||||
logContext: 'fetching link invite',
|
||||
onSuccess: (response) => {
|
||||
this.linkInvite = response.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async generateLinkInvite(roleId: number) {
|
||||
return this.execute<CreateLinkInviteRequest>({
|
||||
requestFn: () => classroomio.organization['link-invite'].$post({ json: { roleId } }),
|
||||
logContext: 'generating link invite',
|
||||
onSuccess: (response) => {
|
||||
const wasNew = !this.linkInvite;
|
||||
this.linkInvite = response.data;
|
||||
|
||||
if (wasNew) {
|
||||
snackbar.success('snackbar.link_invite.generated');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async toggleLinkInvite(isRevoked: boolean) {
|
||||
return this.execute<ToggleLinkInviteRequest>({
|
||||
requestFn: () => classroomio.organization['link-invite'].$patch({ json: { isRevoked } }),
|
||||
logContext: 'toggling link invite',
|
||||
onSuccess: (response) => {
|
||||
this.linkInvite = response.data;
|
||||
snackbar.success(isRevoked ? 'snackbar.link_invite.disabled' : 'snackbar.link_invite.enabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const orgApi = new OrgApi();
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script module lang="ts">
|
||||
let pending: Promise<typeof import('@cio/ui/base/chart')> | null = null;
|
||||
function loadChart() {
|
||||
pending ??= import('@cio/ui/base/chart');
|
||||
return pending;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Chart from '@cio/ui/base/chart';
|
||||
import type * as Chart from '@cio/ui/base/chart';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { LoginActivityData } from '$features/org/utils/types';
|
||||
|
||||
@@ -40,9 +48,11 @@
|
||||
|
||||
<div class="flex h-full flex-col justify-center">
|
||||
{#if hasData}
|
||||
<Chart.ChartContainer class="h-[260px] w-full" config={chartConfig}>
|
||||
<Chart.BarChart {data} x="day" axis="x" {series} />
|
||||
</Chart.ChartContainer>
|
||||
{#await loadChart() then C}
|
||||
<C.ChartContainer class="h-[260px] w-full" config={chartConfig}>
|
||||
<C.BarChart {data} x="day" axis="x" {series} />
|
||||
</C.ChartContainer>
|
||||
{/await}
|
||||
{:else}
|
||||
<div class="ui:text-muted-foreground flex h-[260px] items-center justify-center text-sm">
|
||||
{$t('dashboard.login_activity_no_data')}
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
import {
|
||||
BoldCourseLanding,
|
||||
BoldLandingHero,
|
||||
BoldLandingNav,
|
||||
BoldLandingPage,
|
||||
ClassicCourseLanding,
|
||||
ClassicLandingHero,
|
||||
ClassicLandingNav,
|
||||
ClassicLandingPage,
|
||||
CorporateCourseLanding,
|
||||
CorporateLandingHero,
|
||||
CorporateLandingNav,
|
||||
CorporateLandingPage,
|
||||
EditorialCourseLanding,
|
||||
EditorialLandingHero,
|
||||
EditorialLandingNav,
|
||||
EditorialLandingPage,
|
||||
MinimalCourseLanding,
|
||||
MinimalLandingHero,
|
||||
MinimalLandingNav,
|
||||
MinimalLandingPage,
|
||||
SaasCourseLanding,
|
||||
SaasLandingHero,
|
||||
SaasLandingNav,
|
||||
SaasLandingPage,
|
||||
StudioCourseLanding,
|
||||
StudioLandingHero,
|
||||
StudioLandingNav,
|
||||
StudioLandingPage,
|
||||
TechCourseLanding,
|
||||
TechLandingHero,
|
||||
TechLandingNav,
|
||||
TechLandingPage,
|
||||
TerminalCourseLanding,
|
||||
TerminalLandingHero,
|
||||
TerminalLandingNav,
|
||||
TerminalLandingPage,
|
||||
VibrantCourseLanding,
|
||||
VibrantLandingHero,
|
||||
VibrantLandingNav,
|
||||
VibrantLandingPage
|
||||
@@ -44,6 +54,19 @@ export const landingPageThemeComponents = {
|
||||
vibrant: VibrantLandingPage
|
||||
} as const;
|
||||
|
||||
export const courseLandingPageThemeComponents = {
|
||||
minimal: MinimalCourseLanding,
|
||||
bold: BoldCourseLanding,
|
||||
classic: ClassicCourseLanding,
|
||||
saas: SaasCourseLanding,
|
||||
tech: TechCourseLanding,
|
||||
studio: StudioCourseLanding,
|
||||
corporate: CorporateCourseLanding,
|
||||
terminal: TerminalCourseLanding,
|
||||
editorial: EditorialCourseLanding,
|
||||
vibrant: VibrantCourseLanding
|
||||
} as const;
|
||||
|
||||
export const landingPageNavComponents = {
|
||||
minimal: MinimalLandingNav,
|
||||
bold: BoldLandingNav,
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
OrgLandingPageTheme
|
||||
} from '$lib/utils/types/org';
|
||||
import type { OrgPublicCourses } from './types';
|
||||
import type { OrgLandingPageProps } from '@cio/ui/custom/org-landing-page/types';
|
||||
import type { OrgLandingPageProps, LandingPageThemeBundle } from '@cio/ui/custom/org-landing-page/types';
|
||||
import { resolveLandingPageLinkIcon } from '@cio/ui/custom/org-landing-page/landing-page-link-icons';
|
||||
import {
|
||||
labelMatchesSocialPlatform,
|
||||
@@ -36,11 +36,11 @@ export const defaultLandingPageHero: OrgLandingPageHero = {
|
||||
heading: 'Become a Certified AI Engineer',
|
||||
subheading: 'Master the skills, earn your certification, and prove your expertise with hands-on training programs.',
|
||||
primaryAction: {
|
||||
label: 'Get Started',
|
||||
href: '#courses'
|
||||
label: 'Start Learning',
|
||||
href: '/login'
|
||||
},
|
||||
secondaryAction: {
|
||||
label: 'View Programs',
|
||||
label: 'Browse',
|
||||
href: '/courses'
|
||||
},
|
||||
image: '/images/learn-on-cio.jpg'
|
||||
@@ -83,8 +83,8 @@ export const defaultLandingPageSettings: OrgLandingPageJson = {
|
||||
theme: 'minimal',
|
||||
hero: defaultLandingPageHero,
|
||||
navItems: [
|
||||
{ label: 'Programs', href: '#courses' },
|
||||
{ label: 'Contact', href: '#contact' }
|
||||
{ label: 'Courses', href: '/courses' }
|
||||
// { label: 'Contact', href: '#contact' }
|
||||
],
|
||||
footer: createDefaultFooterConfig()
|
||||
};
|
||||
@@ -710,29 +710,34 @@ export function buildOrgLandingPageProps(
|
||||
|
||||
export type LandingPageThemeKey = (typeof landingPageThemes)[number];
|
||||
|
||||
export function importThemeComponent(theme: LandingPageThemeKey) {
|
||||
switch (theme) {
|
||||
case 'bold':
|
||||
return import('@cio/ui/custom/org-landing-page/bold.svelte');
|
||||
case 'classic':
|
||||
return import('@cio/ui/custom/org-landing-page/classic.svelte');
|
||||
case 'saas':
|
||||
return import('@cio/ui/custom/org-landing-page/saas.svelte');
|
||||
case 'tech':
|
||||
return import('@cio/ui/custom/org-landing-page/tech.svelte');
|
||||
case 'studio':
|
||||
return import('@cio/ui/custom/org-landing-page/studio.svelte');
|
||||
case 'corporate':
|
||||
return import('@cio/ui/custom/org-landing-page/corporate.svelte');
|
||||
case 'terminal':
|
||||
return import('@cio/ui/custom/org-landing-page/terminal.svelte');
|
||||
case 'editorial':
|
||||
return import('@cio/ui/custom/org-landing-page/editorial.svelte');
|
||||
case 'vibrant':
|
||||
return import('@cio/ui/custom/org-landing-page/vibrant.svelte');
|
||||
default:
|
||||
return import('@cio/ui/custom/org-landing-page/minimal.svelte');
|
||||
}
|
||||
export const DEFAULT_LANDING_PAGE_THEME: LandingPageThemeKey = 'minimal';
|
||||
|
||||
const THEME_BUNDLE_LOADERS: Record<LandingPageThemeKey, () => Promise<LandingPageThemeBundle>> = {
|
||||
minimal: () => import('@cio/ui/custom/org-landing-page/minimal'),
|
||||
bold: () => import('@cio/ui/custom/org-landing-page/bold'),
|
||||
classic: () => import('@cio/ui/custom/org-landing-page/classic'),
|
||||
saas: () => import('@cio/ui/custom/org-landing-page/saas'),
|
||||
tech: () => import('@cio/ui/custom/org-landing-page/tech'),
|
||||
studio: () => import('@cio/ui/custom/org-landing-page/studio'),
|
||||
corporate: () => import('@cio/ui/custom/org-landing-page/corporate'),
|
||||
terminal: () => import('@cio/ui/custom/org-landing-page/terminal'),
|
||||
editorial: () => import('@cio/ui/custom/org-landing-page/editorial'),
|
||||
vibrant: () => import('@cio/ui/custom/org-landing-page/vibrant')
|
||||
};
|
||||
|
||||
export function importThemeBundle(theme: LandingPageThemeKey): Promise<LandingPageThemeBundle> {
|
||||
const load = THEME_BUNDLE_LOADERS[theme] ?? THEME_BUNDLE_LOADERS[DEFAULT_LANDING_PAGE_THEME];
|
||||
return load();
|
||||
}
|
||||
|
||||
export async function importThemeComponent(theme: LandingPageThemeKey) {
|
||||
const mod = await importThemeBundle(theme);
|
||||
return { default: mod.org };
|
||||
}
|
||||
|
||||
export async function importCourseLandingPageTheme(theme: LandingPageThemeKey) {
|
||||
const mod = await importThemeBundle(theme);
|
||||
return { default: mod.course };
|
||||
}
|
||||
|
||||
const NAV_SNIPPET_THEMES: ReadonlySet<LandingPageThemeKey> = new Set([
|
||||
@@ -757,123 +762,10 @@ export function themeHeaderShellClass(_theme: LandingPageThemeKey): string {
|
||||
export { themeStyle } from '@cio/ui/custom/org-landing-page/theme-style';
|
||||
|
||||
export async function importThemeCourseCard(theme: LandingPageThemeKey) {
|
||||
switch (theme) {
|
||||
case 'bold':
|
||||
return (await import('@cio/ui/custom/org-landing-page/bold/course-card.svelte')).default;
|
||||
case 'classic':
|
||||
return (await import('@cio/ui/custom/org-landing-page/classic/course-card.svelte')).default;
|
||||
case 'saas':
|
||||
return (await import('@cio/ui/custom/org-landing-page/saas/course-card.svelte')).default;
|
||||
case 'tech':
|
||||
return (await import('@cio/ui/custom/org-landing-page/tech/course-card.svelte')).default;
|
||||
case 'studio':
|
||||
return (await import('@cio/ui/custom/org-landing-page/studio/course-card.svelte')).default;
|
||||
case 'corporate':
|
||||
return (await import('@cio/ui/custom/org-landing-page/corporate/course-card.svelte')).default;
|
||||
case 'terminal':
|
||||
return (await import('@cio/ui/custom/org-landing-page/terminal/course-card.svelte')).default;
|
||||
case 'editorial':
|
||||
return (await import('@cio/ui/custom/org-landing-page/editorial/course-card.svelte')).default;
|
||||
case 'vibrant':
|
||||
return (await import('@cio/ui/custom/org-landing-page/vibrant/course-card.svelte')).default;
|
||||
default:
|
||||
return (await import('@cio/ui/custom/org-landing-page/minimal/course-card.svelte')).default;
|
||||
}
|
||||
}
|
||||
|
||||
export function themeCourseGridClass(theme: LandingPageThemeKey): string {
|
||||
switch (theme) {
|
||||
case 'corporate':
|
||||
case 'tech':
|
||||
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]';
|
||||
case 'saas':
|
||||
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-px ui:bg-[var(--landing-border)] ui:border ui:border-[var(--landing-border)]';
|
||||
case 'minimal':
|
||||
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-6';
|
||||
case 'studio':
|
||||
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-3';
|
||||
case 'terminal':
|
||||
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[14px]';
|
||||
case 'editorial':
|
||||
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-4';
|
||||
case 'vibrant':
|
||||
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-5';
|
||||
default:
|
||||
return 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-6';
|
||||
}
|
||||
return (await importThemeBundle(theme)).courseCard;
|
||||
}
|
||||
|
||||
export async function importThemeNavHero(theme: LandingPageThemeKey) {
|
||||
switch (theme) {
|
||||
case 'bold': {
|
||||
const [nav, hero] = await Promise.all([
|
||||
import('@cio/ui/custom/org-landing-page/bold/nav.svelte'),
|
||||
import('@cio/ui/custom/org-landing-page/bold/hero.svelte')
|
||||
]);
|
||||
return { NavComponent: nav.default, HeroComponent: hero.default };
|
||||
}
|
||||
case 'classic': {
|
||||
const [nav, hero] = await Promise.all([
|
||||
import('@cio/ui/custom/org-landing-page/classic/nav.svelte'),
|
||||
import('@cio/ui/custom/org-landing-page/classic/hero.svelte')
|
||||
]);
|
||||
return { NavComponent: nav.default, HeroComponent: hero.default };
|
||||
}
|
||||
case 'saas': {
|
||||
const [nav, hero] = await Promise.all([
|
||||
import('@cio/ui/custom/org-landing-page/saas/nav.svelte'),
|
||||
import('@cio/ui/custom/org-landing-page/saas/hero.svelte')
|
||||
]);
|
||||
return { NavComponent: nav.default, HeroComponent: hero.default };
|
||||
}
|
||||
case 'tech': {
|
||||
const [nav, hero] = await Promise.all([
|
||||
import('@cio/ui/custom/org-landing-page/tech/nav.svelte'),
|
||||
import('@cio/ui/custom/org-landing-page/tech/hero.svelte')
|
||||
]);
|
||||
return { NavComponent: nav.default, HeroComponent: hero.default };
|
||||
}
|
||||
case 'studio': {
|
||||
const [nav, hero] = await Promise.all([
|
||||
import('@cio/ui/custom/org-landing-page/studio/nav.svelte'),
|
||||
import('@cio/ui/custom/org-landing-page/studio/hero.svelte')
|
||||
]);
|
||||
return { NavComponent: nav.default, HeroComponent: hero.default };
|
||||
}
|
||||
case 'corporate': {
|
||||
const [nav, hero] = await Promise.all([
|
||||
import('@cio/ui/custom/org-landing-page/corporate/nav.svelte'),
|
||||
import('@cio/ui/custom/org-landing-page/corporate/hero.svelte')
|
||||
]);
|
||||
return { NavComponent: nav.default, HeroComponent: hero.default };
|
||||
}
|
||||
case 'terminal': {
|
||||
const [nav, hero] = await Promise.all([
|
||||
import('@cio/ui/custom/org-landing-page/terminal/nav.svelte'),
|
||||
import('@cio/ui/custom/org-landing-page/terminal/hero.svelte')
|
||||
]);
|
||||
return { NavComponent: nav.default, HeroComponent: hero.default };
|
||||
}
|
||||
case 'editorial': {
|
||||
const [nav, hero] = await Promise.all([
|
||||
import('@cio/ui/custom/org-landing-page/editorial/nav.svelte'),
|
||||
import('@cio/ui/custom/org-landing-page/editorial/hero.svelte')
|
||||
]);
|
||||
return { NavComponent: nav.default, HeroComponent: hero.default };
|
||||
}
|
||||
case 'vibrant': {
|
||||
const [nav, hero] = await Promise.all([
|
||||
import('@cio/ui/custom/org-landing-page/vibrant/nav.svelte'),
|
||||
import('@cio/ui/custom/org-landing-page/vibrant/hero.svelte')
|
||||
]);
|
||||
return { NavComponent: nav.default, HeroComponent: hero.default };
|
||||
}
|
||||
default: {
|
||||
const [nav, hero] = await Promise.all([
|
||||
import('@cio/ui/custom/org-landing-page/minimal/nav.svelte'),
|
||||
import('@cio/ui/custom/org-landing-page/minimal/hero.svelte')
|
||||
]);
|
||||
return { NavComponent: nav.default, HeroComponent: hero.default };
|
||||
}
|
||||
}
|
||||
const mod = await importThemeBundle(theme);
|
||||
return { NavComponent: mod.nav, HeroComponent: mod.hero };
|
||||
}
|
||||
|
||||
@@ -128,6 +128,14 @@ export type AssignAudienceCoursesData = AssignAudienceCoursesSuccess['data'];
|
||||
export type ResendAudienceInviteRequest = (typeof classroomio.organization)['audience']['resend-invite']['$post'];
|
||||
export type RevokeAudienceInviteRequest = (typeof classroomio.organization)['audience']['revoke-invite']['$post'];
|
||||
|
||||
// Link invite types
|
||||
export type GetLinkInviteRequest = (typeof classroomio.organization)['link-invite']['$get'];
|
||||
export type CreateLinkInviteRequest = (typeof classroomio.organization)['link-invite']['$post'];
|
||||
export type ToggleLinkInviteRequest = (typeof classroomio.organization)['link-invite']['$patch'];
|
||||
|
||||
export type GetLinkInviteSuccess = Extract<InferResponseType<GetLinkInviteRequest>, { success: true }>;
|
||||
export type OrgLinkInvite = GetLinkInviteSuccess['data'];
|
||||
|
||||
// Token auth types
|
||||
export type CreateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$post'];
|
||||
export type GetTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$get'];
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script module lang="ts">
|
||||
let pending: Promise<typeof import('@cio/ui/base/chart')> | null = null;
|
||||
function loadChart() {
|
||||
pending ??= import('@cio/ui/base/chart');
|
||||
return pending;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Chart from '@cio/ui/base/chart';
|
||||
import type * as Chart from '@cio/ui/base/chart';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AiUsageData } from '$features/settings/utils/types';
|
||||
|
||||
@@ -48,9 +56,11 @@
|
||||
</div>
|
||||
|
||||
{#if hasData}
|
||||
<Chart.ChartContainer class="h-[260px] w-full" config={chartConfig}>
|
||||
<Chart.BarChart {data} x="date" axis="x" {series} />
|
||||
</Chart.ChartContainer>
|
||||
{#await loadChart() then C}
|
||||
<C.ChartContainer class="h-[260px] w-full" config={chartConfig}>
|
||||
<C.BarChart {data} x="date" axis="x" {series} />
|
||||
</C.ChartContainer>
|
||||
{/await}
|
||||
{:else}
|
||||
<div class="ui:text-muted-foreground flex h-[260px] items-center justify-center text-sm">
|
||||
{$t('settings.ai_credits.chart.empty')}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { ssoApi } from '$features/org/api/sso.svelte';
|
||||
import { getRequestBaseUrl } from '$lib/utils/services/api';
|
||||
import { page } from '$app/state';
|
||||
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
@@ -121,7 +121,7 @@
|
||||
}
|
||||
|
||||
function getCallbackUrl(providerId: string) {
|
||||
return `${getRequestBaseUrl()}/api/auth/sso/callback/${providerId}`;
|
||||
return new URL(`/api/auth/sso/callback/${providerId}`, page.url.origin).href;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as Tabs from '@cio/ui/base/tabs';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { mockOrgLandingPageProps } from '@cio/ui/custom/org-landing-page';
|
||||
import { landingPageThemeComponents } from '$features/org/utils/landing-page-components';
|
||||
import { mockOrgLandingPageProps, mockCourseLandingPageProps } from '@cio/ui/custom/org-landing-page';
|
||||
import {
|
||||
landingPageThemeComponents,
|
||||
courseLandingPageThemeComponents
|
||||
} from '$features/org/utils/landing-page-components';
|
||||
import { landingPageThemes } from '$features/org/utils/landing-page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
type LandingPageTheme = (typeof landingPageThemes)[number];
|
||||
type PreviewView = 'home' | 'course';
|
||||
|
||||
const validThemes = new Set<LandingPageTheme>(landingPageThemes);
|
||||
|
||||
@@ -19,15 +25,33 @@
|
||||
|
||||
const open = $derived(previewTheme !== null);
|
||||
|
||||
let previewView = $state<PreviewView>('home');
|
||||
|
||||
// Reset to the home view whenever a different theme is opened for preview.
|
||||
$effect(() => {
|
||||
if (previewTheme) {
|
||||
previewView = 'home';
|
||||
}
|
||||
});
|
||||
|
||||
const ThemeComponent = $derived(
|
||||
previewTheme ? (landingPageThemeComponents[previewTheme] ?? landingPageThemeComponents.minimal) : null
|
||||
);
|
||||
|
||||
const previewProps = $derived({
|
||||
const CourseComponent = $derived(
|
||||
previewTheme ? (courseLandingPageThemeComponents[previewTheme] ?? courseLandingPageThemeComponents.minimal) : null
|
||||
);
|
||||
|
||||
const orgPreviewProps = $derived({
|
||||
...mockOrgLandingPageProps,
|
||||
embed: undefined
|
||||
});
|
||||
|
||||
const coursePreviewProps = $derived({
|
||||
...mockCourseLandingPageProps,
|
||||
theme: previewTheme ?? mockCourseLandingPageProps.theme
|
||||
});
|
||||
|
||||
function closePreview() {
|
||||
const nextUrl = new URL(page.url);
|
||||
nextUrl.searchParams.delete('preview');
|
||||
@@ -62,9 +86,28 @@
|
||||
</div>
|
||||
|
||||
<div class="h-full w-full overflow-y-auto">
|
||||
{#if ThemeComponent}
|
||||
<ThemeComponent {...previewProps} disableCourseLinks={true} />
|
||||
{#if previewView === 'home' && ThemeComponent}
|
||||
<ThemeComponent {...orgPreviewProps} disableCourseLinks={true} />
|
||||
{:else if previewView === 'course' && CourseComponent}
|
||||
<CourseComponent {...coursePreviewProps} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Tabs.Root
|
||||
bind:value={previewView}
|
||||
class="ui:pointer-events-none ui:absolute ui:bottom-6 ui:left-1/2 ui:z-[9999] ui:-translate-x-1/2"
|
||||
>
|
||||
<Tabs.List
|
||||
aria-label={$t('settings.landing_page.preview_tabs.label')}
|
||||
class="ui:pointer-events-auto ui:bg-background/90 ui:supports-[backdrop-filter]:bg-background/70 ui:h-10 ui:gap-1 ui:rounded-full ui:border ui:p-1 ui:shadow-lg ui:backdrop-blur"
|
||||
>
|
||||
<Tabs.Trigger value="home" class="ui:rounded-full ui:px-4">
|
||||
{$t('settings.landing_page.preview_tabs.home')}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="course" class="ui:rounded-full ui:px-4">
|
||||
{$t('settings.landing_page.preview_tabs.course')}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -48,64 +48,66 @@
|
||||
|
||||
const currentTheme = $derived(normalized.theme as LandingPageTheme);
|
||||
|
||||
const CDN_BASE = 'https://assets.cdn.clsrio.com/templates';
|
||||
|
||||
const themeCards = [
|
||||
{
|
||||
value: 'minimal',
|
||||
preview: '/templates/minimal.png',
|
||||
preview: `${CDN_BASE}/minimal.png`,
|
||||
titleKey: 'settings.landing_page.theme.cards.minimal.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.minimal.description'
|
||||
},
|
||||
{
|
||||
value: 'bold',
|
||||
preview: '/templates/bold.png',
|
||||
preview: `${CDN_BASE}/bold.png`,
|
||||
titleKey: 'settings.landing_page.theme.cards.bold.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.bold.description'
|
||||
},
|
||||
{
|
||||
value: 'classic',
|
||||
preview: '/templates/classic.png',
|
||||
preview: `${CDN_BASE}/classic.png`,
|
||||
titleKey: 'settings.landing_page.theme.cards.classic.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.classic.description'
|
||||
},
|
||||
{
|
||||
value: 'saas',
|
||||
preview: '/templates/saas.png',
|
||||
preview: `${CDN_BASE}/saas.png`,
|
||||
titleKey: 'settings.landing_page.theme.cards.saas.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.saas.description'
|
||||
},
|
||||
{
|
||||
value: 'tech',
|
||||
preview: '/templates/tech.png',
|
||||
preview: `${CDN_BASE}/tech.png`,
|
||||
titleKey: 'settings.landing_page.theme.cards.tech.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.tech.description'
|
||||
},
|
||||
{
|
||||
value: 'studio',
|
||||
preview: '/templates/studio.png',
|
||||
preview: `${CDN_BASE}/studio.png`,
|
||||
titleKey: 'settings.landing_page.theme.cards.studio.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.studio.description'
|
||||
},
|
||||
{
|
||||
value: 'corporate',
|
||||
preview: '/templates/corporate.png',
|
||||
preview: `${CDN_BASE}/corporate.png`,
|
||||
titleKey: 'settings.landing_page.theme.cards.corporate.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.corporate.description'
|
||||
},
|
||||
{
|
||||
value: 'terminal',
|
||||
preview: '/templates/terminal.png',
|
||||
preview: `${CDN_BASE}/terminal.png`,
|
||||
titleKey: 'settings.landing_page.theme.cards.terminal.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.terminal.description'
|
||||
},
|
||||
{
|
||||
value: 'editorial',
|
||||
preview: '/templates/editorial.png',
|
||||
preview: `${CDN_BASE}/editorial.png`,
|
||||
titleKey: 'settings.landing_page.theme.cards.editorial.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.editorial.description'
|
||||
},
|
||||
{
|
||||
value: 'vibrant',
|
||||
preview: '/templates/vibrant.png',
|
||||
preview: `${CDN_BASE}/vibrant.png`,
|
||||
titleKey: 'settings.landing_page.theme.cards.vibrant.title',
|
||||
descriptionKey: 'settings.landing_page.theme.cards.vibrant.description'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isFreePlan, currentOrg } from '$lib/utils/store/org';
|
||||
@@ -22,6 +23,33 @@
|
||||
let errorMessage = $state('');
|
||||
let role = $state(ROLE.TUTOR.toString());
|
||||
let isRemoving: number | null = $state(null);
|
||||
let linkRole = $state(ROLE.TUTOR.toString());
|
||||
let copiedLink = $state(false);
|
||||
|
||||
function buildLinkInviteUrl(token: string): string {
|
||||
return `${window.location.origin}/invite/link/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
async function onGenerateLink() {
|
||||
if ($isFreePlan) {
|
||||
snackbar.error('upgrade.required');
|
||||
return;
|
||||
}
|
||||
|
||||
await orgApi.generateLinkInvite(parseInt(linkRole));
|
||||
}
|
||||
|
||||
async function onCopyLink() {
|
||||
if (!orgApi.linkInvite?.token) return;
|
||||
|
||||
await navigator.clipboard.writeText(buildLinkInviteUrl(orgApi.linkInvite.token));
|
||||
copiedLink = true;
|
||||
setTimeout(() => (copiedLink = false), 2000);
|
||||
}
|
||||
|
||||
async function onToggleLinkInvite(active: boolean) {
|
||||
await orgApi.toggleLinkInvite(!active);
|
||||
}
|
||||
|
||||
async function onSendInvite() {
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
@@ -80,6 +108,7 @@
|
||||
if (!$currentOrg) return;
|
||||
|
||||
orgApi.getOrgTeam();
|
||||
orgApi.getLinkInvite();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -130,6 +159,69 @@
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.people.teams.link_invite.heading')}</Field.Legend>
|
||||
<Field.Description class="mb-4">{$t('course.navItem.people.teams.link_invite.description')}</Field.Description>
|
||||
|
||||
{#if !orgApi.linkInvite}
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.people.teams.role')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={linkRole} disabled={$isFreePlan}>
|
||||
<Select.Trigger class="w-40">
|
||||
<p>{linkRole ? $t(ROLE_LABEL[linkRole]) : $t('course.navItem.people.teams.link_invite.select_role')}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value={ROLE.ADMIN.toString()}>{$t(ROLE_LABEL[ROLE.ADMIN])}</Select.Item>
|
||||
<Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onclick={onGenerateLink}
|
||||
loading={orgApi.isLoading}
|
||||
disabled={orgApi.isLoading || $isFreePlan}
|
||||
>
|
||||
{$t('course.navItem.people.teams.link_invite.generate')}
|
||||
</Button>
|
||||
</Field.Group>
|
||||
{:else}
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
value={buildLinkInviteUrl(orgApi.linkInvite.token)}
|
||||
readonly
|
||||
class="w-full font-mono text-xs {orgApi.linkInvite.isRevoked ? 'opacity-50' : ''}"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onclick={onCopyLink} disabled={orgApi.linkInvite.isRevoked}>
|
||||
{copiedLink
|
||||
? $t('course.navItem.people.teams.link_invite.copied')
|
||||
: $t('course.navItem.people.teams.link_invite.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</Field.Field>
|
||||
|
||||
{#if orgApi.linkInvite.isRevoked}
|
||||
<p class="text-sm text-amber-600">{$t('course.navItem.people.teams.link_invite.disabled_notice')}</p>
|
||||
{/if}
|
||||
|
||||
<Field.Field orientation="horizontal" class="mt-2">
|
||||
<Switch
|
||||
checked={!orgApi.linkInvite.isRevoked}
|
||||
onCheckedChange={onToggleLinkInvite}
|
||||
disabled={orgApi.isLoading || $isFreePlan}
|
||||
/>
|
||||
<Field.Label>{$t('course.navItem.people.teams.link_invite.toggle_label')}</Field.Label>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.people.teams.members')}</Field.Legend>
|
||||
<Field.Group>
|
||||
|
||||
+74
-61
@@ -15,10 +15,12 @@
|
||||
MoneyIcon,
|
||||
ReviewIcon,
|
||||
PersonIcon,
|
||||
ContentIcon,
|
||||
HoverableItem,
|
||||
PreviewIcon
|
||||
} from '@cio/ui/custom/moving-icons';
|
||||
import type { TCourseUpdate } from '@cio/utils/validation/course';
|
||||
import type { LandingSectionKey } from '@cio/ui/custom/org-landing-page';
|
||||
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { CloseButton } from '$features/ui';
|
||||
@@ -30,6 +32,7 @@
|
||||
import ReviewsForm from './reviews-form.svelte';
|
||||
import CertificateForm from './certificate-form.svelte';
|
||||
import InstructorForm from './instructor-form.svelte';
|
||||
import SkillsToolsForm from './skills-tools-form.svelte';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
@@ -37,7 +40,6 @@
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
|
||||
// import CustomPromptBtn from '$lib/components/AI/AIButton/CustomPromptBtn.svelte';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
@@ -46,112 +48,122 @@
|
||||
courseId: string;
|
||||
syncCourseStore: (course: Course) => void;
|
||||
onClose?: () => void;
|
||||
selectedSectionKey?: LandingSectionKey | null;
|
||||
}
|
||||
|
||||
let { course = $bindable(), courseId, syncCourseStore, onClose }: Props = $props();
|
||||
let {
|
||||
course = $bindable(),
|
||||
courseId,
|
||||
syncCourseStore,
|
||||
onClose,
|
||||
selectedSectionKey = $bindable(null)
|
||||
}: Props = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
let loading = $state(false);
|
||||
|
||||
interface Section {
|
||||
key: number;
|
||||
path: string;
|
||||
key: LandingSectionKey;
|
||||
title: string;
|
||||
icon: Component;
|
||||
enableAIWriter?: boolean;
|
||||
initPrompt?: string;
|
||||
domId: string;
|
||||
}
|
||||
|
||||
const sections: Section[] = [
|
||||
const sections: Section[] = $derived([
|
||||
{
|
||||
key: 1,
|
||||
path: '',
|
||||
key: 'header',
|
||||
title: $t('course.navItem.landing_page.editor.title.header'),
|
||||
icon: HeaderIcon
|
||||
icon: HeaderIcon,
|
||||
domId: 'header'
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
path: 'metadata.requirements',
|
||||
key: 'requirement',
|
||||
title: $t('course.navItem.landing_page.editor.title.requirement'),
|
||||
icon: ExerciseIcon,
|
||||
enableAIWriter: true,
|
||||
initPrompt: $t('course.navItem.landing_page.editor.title.requirement')
|
||||
domId: 'requirement'
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
path: 'metadata.description',
|
||||
key: 'description',
|
||||
title: $t('course.navItem.landing_page.editor.title.description'),
|
||||
icon: LessonIcon,
|
||||
enableAIWriter: true,
|
||||
initPrompt: 'Please write a detailed course description for this course:'
|
||||
domId: 'description'
|
||||
},
|
||||
{
|
||||
key: 4,
|
||||
path: 'metadata.goals',
|
||||
key: 'goals',
|
||||
title: $t('course.navItem.landing_page.editor.title.goals'),
|
||||
icon: GoalIcon,
|
||||
enableAIWriter: true,
|
||||
initPrompt: 'What should a student expect to learn from this course:'
|
||||
domId: 'goals'
|
||||
},
|
||||
{
|
||||
key: 5,
|
||||
path: '',
|
||||
key: 'chips',
|
||||
title: $t('course.navItem.landing_page.editor.title.chips'),
|
||||
icon: ContentIcon,
|
||||
domId: 'course-chips'
|
||||
},
|
||||
{
|
||||
key: 'certificate',
|
||||
title: $t('course.navItem.landing_page.editor.title.certificate'),
|
||||
icon: CertificateIcon
|
||||
icon: CertificateIcon,
|
||||
domId: 'certificate'
|
||||
},
|
||||
{
|
||||
key: 6,
|
||||
path: '',
|
||||
key: 'reviews',
|
||||
title: $t('course.navItem.landing_page.editor.title.reviews'),
|
||||
icon: ReviewIcon
|
||||
icon: ReviewIcon,
|
||||
domId: 'reviews'
|
||||
},
|
||||
{
|
||||
key: 7,
|
||||
path: '',
|
||||
key: 'instructor',
|
||||
title: $t('course.navItem.landing_page.editor.title.instructor'),
|
||||
icon: PersonIcon
|
||||
icon: PersonIcon,
|
||||
domId: 'instructor'
|
||||
},
|
||||
{
|
||||
key: 8,
|
||||
path: '',
|
||||
key: 'pricing',
|
||||
title: $t('course.navItem.landing_page.editor.title.pricing'),
|
||||
icon: MoneyIcon
|
||||
icon: MoneyIcon,
|
||||
domId: 'pricing'
|
||||
}
|
||||
];
|
||||
let selectedSection: Section | null = $state(null);
|
||||
]);
|
||||
|
||||
const selectedSection = $derived(
|
||||
selectedSectionKey ? (sections.find((s) => s.key === selectedSectionKey) ?? null) : null
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
if (!selectedSection) {
|
||||
if (!selectedSectionKey) {
|
||||
onClose?.();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedSection = null;
|
||||
selectedSectionKey = null;
|
||||
}
|
||||
|
||||
function handleSectionSelect(sectionKey: number) {
|
||||
function handleSectionSelect(key: LandingSectionKey) {
|
||||
return () => {
|
||||
selectedSection = sections.find((section) => section.key === sectionKey) || null;
|
||||
|
||||
if (selectedSection) {
|
||||
const sectionId = selectedSection.title.toLowerCase();
|
||||
|
||||
const sectionEl = document.getElementById(sectionId);
|
||||
if (sectionEl) {
|
||||
sectionEl.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
selectedSectionKey = key;
|
||||
const section = sections.find((s) => s.key === key);
|
||||
if (section) {
|
||||
const el = document.getElementById(section.domId);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!selectedSection) return;
|
||||
const el = document.getElementById(selectedSection.domId);
|
||||
if (el) {
|
||||
untrack(() => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
loading = true;
|
||||
course.slug = course.slug || generateSlug(course.title, { appendTimestamp: true });
|
||||
|
||||
console.log('course', course);
|
||||
const updatePayload = {
|
||||
...course,
|
||||
type: course.type!,
|
||||
@@ -185,7 +197,6 @@
|
||||
function setter(value: unknown, setterKey: string) {
|
||||
if (typeof value === 'undefined') return;
|
||||
|
||||
// Untrack the course change to avoid unnecessary re-renders
|
||||
const _course = untrack(() => cloneDeep(course));
|
||||
set(_course, setterKey, value);
|
||||
|
||||
@@ -254,21 +265,23 @@
|
||||
</Sidebar.Group>
|
||||
{:else}
|
||||
<div class="p-4">
|
||||
{#if selectedSection.key === 1}
|
||||
{#if selectedSection.key === 'header'}
|
||||
<HeaderForm bind:course />
|
||||
{:else if selectedSection.key === 2}
|
||||
{:else if selectedSection.key === 'requirement'}
|
||||
<RequirementForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 3}
|
||||
{:else if selectedSection.key === 'description'}
|
||||
<DescriptionForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 4}
|
||||
{:else if selectedSection.key === 'goals'}
|
||||
<GoalsForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 5}
|
||||
{:else if selectedSection.key === 'chips'}
|
||||
<SkillsToolsForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 'certificate'}
|
||||
<CertificateForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 6}
|
||||
{:else if selectedSection.key === 'reviews'}
|
||||
<ReviewsForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 7}
|
||||
{:else if selectedSection.key === 'instructor'}
|
||||
<InstructorForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 8}
|
||||
{:else if selectedSection.key === 'pricing'}
|
||||
<PricingForm bind:course {setter} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+24
-4
@@ -54,8 +54,28 @@
|
||||
bind:value={course.metadata.videoUrl}
|
||||
/>
|
||||
<div class="mt-7">
|
||||
<p class="mb-3">{$t('course.navItem.landing_page.editor.header_form.replace_cover')}</p>
|
||||
<Button variant="outline" onclick={widgetControl}>
|
||||
{$t('course.navItem.landing_page.editor.header_form.replace')}
|
||||
</Button>
|
||||
<p class="mb-3 font-bold">{$t('course.navItem.landing_page.editor.header_form.replace_cover')}</p>
|
||||
|
||||
{#if course.logo}
|
||||
<div class="border-border mb-3 overflow-hidden rounded-md border">
|
||||
<img src={course.logo} alt={course.title ?? ''} class="block aspect-video w-full object-cover" />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="border-border bg-muted text-muted-foreground mb-3 flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-xs"
|
||||
>
|
||||
{$t('course.navItem.landing_page.editor.header_form.no_cover')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={widgetControl}>
|
||||
{$t('course.navItem.landing_page.editor.header_form.replace')}
|
||||
</Button>
|
||||
{#if course.logo}
|
||||
<Button variant="outline" onclick={() => (course.logo = '')}>
|
||||
{$t('course.navItem.landing_page.editor.header_form.remove')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@
|
||||
let role = $state(get(course, 'metadata.instructor.role'));
|
||||
let imgUrl = $state(get(course, 'metadata.instructor.imgUrl'));
|
||||
let description = $state(get(course, 'metadata.instructor.description'));
|
||||
let courseNo = $state(get(course, 'metadata.instructor.courseNo'));
|
||||
let courseNo = $state(get(course, 'metadata.instructor.coursesNo'));
|
||||
let avatar: File | undefined = $state();
|
||||
let isUploading = $state(false);
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
setter(description, 'metadata.instructor.description');
|
||||
});
|
||||
$effect(() => {
|
||||
setter(courseNo, 'metadata.instructor.courseNo');
|
||||
setter(courseNo, 'metadata.instructor.coursesNo');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
course: Course;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setter: (value: any, key: string) => void;
|
||||
}
|
||||
|
||||
let { course = $bindable(), setter }: Props = $props();
|
||||
|
||||
let skillsDraft = $state('');
|
||||
let toolsDraft = $state('');
|
||||
|
||||
const skills = $derived((course.metadata?.skills ?? []) as string[]);
|
||||
const tools = $derived((course.metadata?.tools ?? []) as string[]);
|
||||
|
||||
function addSkill() {
|
||||
const value = skillsDraft.trim();
|
||||
if (!value) return;
|
||||
if (skills.includes(value)) {
|
||||
skillsDraft = '';
|
||||
return;
|
||||
}
|
||||
setter([...skills, value], 'metadata.skills');
|
||||
skillsDraft = '';
|
||||
}
|
||||
|
||||
function removeSkill(skill: string) {
|
||||
setter(
|
||||
skills.filter((s) => s !== skill),
|
||||
'metadata.skills'
|
||||
);
|
||||
}
|
||||
|
||||
function addTool() {
|
||||
const value = toolsDraft.trim();
|
||||
if (!value) return;
|
||||
if (tools.includes(value)) {
|
||||
toolsDraft = '';
|
||||
return;
|
||||
}
|
||||
setter([...tools, value], 'metadata.tools');
|
||||
toolsDraft = '';
|
||||
}
|
||||
|
||||
function removeTool(tool: string) {
|
||||
setter(
|
||||
tools.filter((t) => t !== tool),
|
||||
'metadata.tools'
|
||||
);
|
||||
}
|
||||
|
||||
function handleSkillKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addSkill();
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addTool();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-semibold">
|
||||
{$t('course.navItem.landing_page.editor.title.skills')}
|
||||
</p>
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
{$t('course.navItem.landing_page.editor.skills_help')}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={skillsDraft}
|
||||
onkeydown={handleSkillKey}
|
||||
placeholder={$t('course.navItem.landing_page.editor.skills_placeholder')}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addSkill} disabled={!skillsDraft.trim()}>
|
||||
<PlusIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if skills.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{#each skills as skill (skill)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-gray-300 px-3 py-1 text-xs text-gray-700"
|
||||
>
|
||||
{skill}
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-700"
|
||||
onclick={() => removeSkill(skill)}
|
||||
aria-label={$t('course.navItem.landing_page.editor.remove')}
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-semibold">
|
||||
{$t('course.navItem.landing_page.editor.title.tools')}
|
||||
</p>
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
{$t('course.navItem.landing_page.editor.tools_help')}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={toolsDraft}
|
||||
onkeydown={handleToolKey}
|
||||
placeholder={$t('course.navItem.landing_page.editor.tools_placeholder')}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addTool} disabled={!toolsDraft.trim()}>
|
||||
<PlusIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if tools.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{#each tools as tool (tool)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-gray-300 px-3 py-1 text-xs text-gray-700"
|
||||
>
|
||||
{tool}
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-700"
|
||||
onclick={() => removeTool(tool)}
|
||||
aria-label={$t('course.navItem.landing_page.editor.remove')}
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
+36
-551
@@ -1,74 +1,31 @@
|
||||
<script lang="ts">
|
||||
import get from 'lodash/get';
|
||||
import pluralize from 'pluralize';
|
||||
import { page } from '$app/state';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { Component } from 'svelte';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import PlayIcon from '@lucide/svelte/icons/play';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import StarIcon from '@lucide/svelte/icons/star';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import { resolve } from '$app/paths';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
|
||||
import { Sparkle } from '@cio/ui/custom/animation/sparkle';
|
||||
import { MagicCard } from '@cio/ui/custom/animation/magic-card';
|
||||
|
||||
import { getLectureNo } from '$features/course/utils/functions';
|
||||
import {
|
||||
importThemeNavHero,
|
||||
normalizeLandingPageSettings,
|
||||
themeHeaderShellClass,
|
||||
themeStyle,
|
||||
themeRendersNavInsideHero
|
||||
} from '$features/org/utils/landing-page';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { basePath } from '$lib/utils/store/app';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { NAV_ITEM_KEY, NAV_ITEMS } from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { handleOpenWidget, reviewsModalStore } from './store';
|
||||
import type { Course, CourseMetadata } from '$features/course/utils/types';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { filterNavItems, getCourseLessons, getCourseSections, getTotalLessons } from './utils';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import { Chip } from '@cio/ui/custom/chip';
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import Image from '$features/ui/image.svelte';
|
||||
import PoweredBy from '$features/ui/powered-by.svelte';
|
||||
import { importCourseLandingPageTheme, normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import UploadWidget from '$features/ui/upload-widget/upload-widget.svelte';
|
||||
import HTMLRender from '$features/ui/html-render.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import PricingSection from './components/pricing-section.svelte';
|
||||
import NavSection from './components/nav-section.svelte';
|
||||
import SectionsDisplay from './components/sections-display.svelte';
|
||||
import { observeIntersection } from './components/intersection-observer';
|
||||
import { buildCourseLandingPageProps } from './utils';
|
||||
import { handleOpenWidget } from './store';
|
||||
|
||||
interface Props {
|
||||
editMode?: boolean;
|
||||
courseData: Course;
|
||||
showStandaloneShell?: boolean;
|
||||
org?: AccountOrg | null;
|
||||
/** Pre-resolved theme component from the route's load function (eliminates the flash on SSR pages). */
|
||||
themeComponent?: Component | null;
|
||||
}
|
||||
|
||||
let { editMode = false, courseData = $bindable(), showStandaloneShell = false }: Props = $props();
|
||||
let { editMode = false, courseData = $bindable(), org = null, themeComponent = null }: Props = $props();
|
||||
|
||||
/** Editor / standalone shell: show org-theme hero + nav from `currentOrg.landingpage`. Org-site embed omits (parent provides hero). */
|
||||
const showMarketingHero = $derived(showStandaloneShell || editMode);
|
||||
|
||||
const landingSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage));
|
||||
|
||||
const heroShellClass = $derived(`ui:w-full ${themeHeaderShellClass(landingSettings.theme)}`);
|
||||
const heroShellStyle = $derived(themeStyle(landingSettings.theme));
|
||||
const navInsideHero = $derived(themeRendersNavInsideHero(landingSettings.theme));
|
||||
|
||||
const orgNameForNav = $derived($currentOrg.name || get(courseData, 'org.name', '') || 'ClassroomIO');
|
||||
const logoUrlForNav = $derived($currentOrg.avatarUrl || undefined);
|
||||
const activeOrg = $derived(org ?? $currentOrg);
|
||||
const landingSettings = $derived(normalizeLandingPageSettings(activeOrg.landingpage));
|
||||
|
||||
const authAction = $derived(
|
||||
$user.isLoggedIn
|
||||
@@ -82,47 +39,35 @@
|
||||
}
|
||||
);
|
||||
|
||||
const courseHero = $derived.by(() => {
|
||||
const enrollHref = $derived.by(() => {
|
||||
const slug = typeof courseData.slug === 'string' && courseData.slug.length > 0 ? courseData.slug : '';
|
||||
const enrollHref = slug ? resolve(`/course/${slug}/enroll`, {}) : '#';
|
||||
const enrollmentsOpen = get(courseData, 'metadata.allowNewStudent') === true;
|
||||
|
||||
return {
|
||||
...landingSettings.hero,
|
||||
heading: get(courseData, 'title', '') || landingSettings.hero.heading,
|
||||
subheading: get(courseData, 'description', '') || landingSettings.hero.subheading,
|
||||
primaryAction: {
|
||||
label: t.get('course.navItem.landing_page.start_course'),
|
||||
href: enrollHref,
|
||||
disabled: editMode || !enrollmentsOpen
|
||||
},
|
||||
image: get(courseData, 'logo', '') || landingSettings.hero.image
|
||||
};
|
||||
return slug ? resolve(`/course/${slug}/enroll`, {}) : '#';
|
||||
});
|
||||
|
||||
/** Matches API `buildCourseContent` — do not use `metadata.isContentGroupingEnabled` (dashboard normalizes undefined → true). */
|
||||
const lessonsLayoutIsGrouped = $derived(courseData.content?.grouped === true);
|
||||
const enrollmentsOpen = $derived(get(courseData, 'metadata.allowNewStudent') === true);
|
||||
const enrollDisabled = $derived(editMode || !enrollmentsOpen);
|
||||
|
||||
const landingProps = $derived(
|
||||
buildCourseLandingPageProps(courseData, activeOrg, {
|
||||
enrollHref,
|
||||
enrollDisabled,
|
||||
authAction
|
||||
})
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let NavComponent = $state<Component<any> | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let HeroComponent = $state<Component<any> | null>(null);
|
||||
let ThemeComponent = $state<Component<any> | null>(themeComponent);
|
||||
|
||||
$effect(() => {
|
||||
if (!showMarketingHero) {
|
||||
NavComponent = null;
|
||||
HeroComponent = null;
|
||||
|
||||
return;
|
||||
}
|
||||
// Skip client-side loading if the theme was already resolved server-side.
|
||||
if (ThemeComponent) return;
|
||||
|
||||
const theme = landingSettings.theme;
|
||||
let cancelled = false;
|
||||
|
||||
void importThemeNavHero(theme).then(({ NavComponent: nav, HeroComponent: hero }) => {
|
||||
void importCourseLandingPageTheme(theme).then((mod) => {
|
||||
if (!cancelled) {
|
||||
NavComponent = nav;
|
||||
HeroComponent = hero;
|
||||
ThemeComponent = mod.default;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -130,473 +75,13 @@
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const reviews = $derived(get(courseData, 'metadata.reviews') || []);
|
||||
|
||||
const lessons = $derived(getCourseLessons(courseData));
|
||||
const totalRatings = $derived(reviews?.reduce((acc = 0, review) => acc + (review?.rating || 0), 0));
|
||||
const averageRating = $derived(reviews?.length ? (totalRatings / reviews.length).toFixed(1) : null);
|
||||
const expandDescription = $derived(Array(reviews?.length ?? 0).fill(false));
|
||||
|
||||
const instructor = $derived((get(courseData, 'metadata.instructor') || {}) as Record<string, unknown>);
|
||||
const organizationName = $derived(get(courseData, 'org.name') || $currentOrg.name || '');
|
||||
const instructorName = $derived.by(() => {
|
||||
const name = get(instructor, 'name');
|
||||
return typeof name === 'string' && name.trim().length > 0 ? name : organizationName;
|
||||
});
|
||||
const instructorImageUrl = $derived.by(() => {
|
||||
const imageUrl = get(instructor, 'imgUrl');
|
||||
return typeof imageUrl === 'string' && imageUrl.trim().length > 0 ? imageUrl : '/images/avatar.svg';
|
||||
});
|
||||
const certificate = $derived(
|
||||
get(courseData, 'metadata.certificate', {
|
||||
templateUrl: '/images/certificate-template.svg'
|
||||
}) as CourseMetadata['certificate']
|
||||
);
|
||||
|
||||
const navItems = $derived(filterNavItems(courseData, reviews));
|
||||
const navItemKeys = $derived(navItems.map((item) => item.key));
|
||||
|
||||
let startCoursePayment = $state(false);
|
||||
let isVisible = $state(false);
|
||||
let observer: { destroy: () => void };
|
||||
|
||||
let activeNav = $state(NAV_ITEMS[0].key);
|
||||
|
||||
const courseSections = $derived(getCourseSections(courseData));
|
||||
const totalLessons = $derived(getTotalLessons(courseSections));
|
||||
const studentCount = $derived(courseData.group?.members?.length ?? 0);
|
||||
|
||||
function locationHashChanged() {
|
||||
activeNav = window.location.hash;
|
||||
}
|
||||
|
||||
function toggleDescription(id: number) {
|
||||
expandDescription[id] = !expandDescription[id];
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.onhashchange = locationHashChanged;
|
||||
|
||||
const targetNode = document.querySelector('.target-component');
|
||||
observer = observeIntersection(targetNode, (inView: boolean) => {
|
||||
isVisible = inView;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
observer?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !editMode && showStandaloneShell}
|
||||
<PoweredBy />
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full flex-col items-center bg-white dark:bg-black">
|
||||
{#if showMarketingHero}
|
||||
<div class={heroShellClass} style={heroShellStyle}>
|
||||
{#if NavComponent && HeroComponent}
|
||||
{#if navInsideHero}
|
||||
<HeroComponent hero={courseHero} orgName={orgNameForNav}>
|
||||
{#snippet navigation()}
|
||||
<NavComponent
|
||||
orgName={orgNameForNav}
|
||||
logoUrl={logoUrlForNav}
|
||||
navItems={landingSettings.navItems}
|
||||
{authAction}
|
||||
/>
|
||||
{/snippet}
|
||||
</HeroComponent>
|
||||
{:else}
|
||||
<NavComponent
|
||||
orgName={orgNameForNav}
|
||||
logoUrl={logoUrlForNav}
|
||||
navItems={landingSettings.navItems}
|
||||
{authAction}
|
||||
/>
|
||||
<HeroComponent hero={courseHero} orgName={orgNameForNav} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if editMode && $handleOpenWidget.open}
|
||||
<div class="mx-auto w-full max-w-7xl px-6 py-2">
|
||||
<UploadWidget imageURL={courseApi.course?.logo} onchange={(newLogo) => (courseApi.course!.logo = newLogo)} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if ThemeComponent}
|
||||
{#if editMode && $handleOpenWidget.open}
|
||||
<div class="mx-auto w-full max-w-7xl px-6 py-3">
|
||||
<UploadWidget imageURL={courseData.logo} onchange={(newLogo) => (courseData.logo = newLogo)} />
|
||||
</div>
|
||||
|
||||
<!-- Social Proof Strip -->
|
||||
{#if reviews.length > 0 || studentCount > 0 || lessons.length > 0}
|
||||
<div class="w-full border-b border-gray-200 bg-gray-50 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-center gap-6 px-6 py-4 text-sm md:justify-start"
|
||||
>
|
||||
{#if averageRating}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<StarIcon size={16} class="fill-yellow-400 text-yellow-400" />
|
||||
<span class="font-semibold">{averageRating}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400"
|
||||
>({reviews.length} {reviews.length === 1 ? 'review' : 'reviews'})</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if studentCount > 0}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<UsersIcon size={16} />
|
||||
<span>{studentCount} {studentCount === 1 ? 'student' : 'students'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if lessons.length > 0}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<BookOpenIcon size={16} />
|
||||
<span>{pluralize('lesson', lessons.length, true)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if courseData.type}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<ClockIcon size={16} />
|
||||
<span>{courseData.type === 'LIVE_CLASS' ? 'Live Class' : 'Self-Paced'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Body -->
|
||||
<div class="w-full bg-white dark:bg-black">
|
||||
<div
|
||||
class="mx-auto my-2 flex w-full max-w-6xl flex-col-reverse items-center justify-between px-4 lg:flex-row lg:items-start lg:py-8"
|
||||
>
|
||||
<!-- Course Details -->
|
||||
<div class="w-full max-w-[680px] min-w-[60%] p-3 lg:mr-10">
|
||||
<!-- Navigation -->
|
||||
<UnderlineTabs.Root bind:value={activeNav} class="py-2">
|
||||
<UnderlineTabs.List>
|
||||
{#each navItems as navItem (navItem.key)}
|
||||
<UnderlineTabs.Trigger value={navItem.key}>
|
||||
<a href={resolve(page.url.pathname + navItem.key, {})}>
|
||||
{$t(navItem.label)}
|
||||
</a>
|
||||
</UnderlineTabs.Trigger>
|
||||
{/each}
|
||||
</UnderlineTabs.List>
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
<!-- Sections - Requirement -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.REQUIREMENT)}
|
||||
<NavSection id="requirement">
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.requirement')}
|
||||
</h3>
|
||||
<HTMLRender className="text-sm font-light leading-relaxed">
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} />
|
||||
</HTMLRender>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Course Description -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.DESCRIPTION)}
|
||||
<NavSection id="description">
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.description')}
|
||||
</h3>
|
||||
<HTMLRender className="dark:text-white text-sm font-light leading-relaxed">
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.description', '')} />
|
||||
</HTMLRender>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Goal -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.GOALS)}
|
||||
<NavSection id="goals">
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">{$t('course.navItem.landing_page.learn')}</h3>
|
||||
<HTMLRender className="text-sm font-light leading-relaxed">
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.goals', '')} />
|
||||
</HTMLRender>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Certificate -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.CERTIFICATE)}
|
||||
<NavSection id="certificate">
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 text-2xl font-semibold">{$t('course.navItem.landing_page.certificate')}</h3>
|
||||
<p class="mb-4 text-sm font-light dark:text-white">
|
||||
{$t('course.navItem.landing_page.certificate_text')}
|
||||
</p>
|
||||
<MagicCard
|
||||
gradientColor="#3b82f6"
|
||||
gradientOpacity={0.15}
|
||||
class="inline-block w-auto border-0 bg-transparent py-0 dark:bg-transparent"
|
||||
>
|
||||
<Image src={certificate?.templateUrl} alt="certificate template" className="max-h-[215px] w-auto" />
|
||||
</MagicCard>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Lessons (layout follows `content.grouped` from API, not client-normalized metadata) -->
|
||||
{#if !lessonsLayoutIsGrouped}
|
||||
<NavSection id="lessons">
|
||||
<BlurFade delay={0.1}>
|
||||
<div class="mb-3 flex w-full items-center justify-between">
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.content')}
|
||||
</h3>
|
||||
<p class="text-sm font-light dark:text-white">
|
||||
{pluralize('lesson', lessons.length, true)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each lessons as lesson, index (lesson.id)}
|
||||
<BlurFade delay={0.05 * Math.min(index, 10)}>
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 transition-colors hover:border-gray-400 dark:border-neutral-700 dark:hover:border-neutral-500"
|
||||
>
|
||||
<Chip value={getLectureNo(index + 1, '0')} />
|
||||
<p class="ml-2 inline text-xs font-light dark:text-white">
|
||||
{lesson.title}
|
||||
</p>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{:else}
|
||||
<NavSection id="lessons">
|
||||
<BlurFade delay={0.1}>
|
||||
<!-- header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">{$t('course.navItem.landing_page.course_content')}</h1>
|
||||
<span class="text-xs font-normal">
|
||||
{pluralize($t('course.navItem.landing_page.modules'), courseSections.length, true)},
|
||||
{pluralize($t('course.navItem.landing_page.lessons'), totalLessons, true)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#each courseSections as section, index (section.id)}
|
||||
<BlurFade delay={0.05 * Math.min(index, 8)}>
|
||||
<SectionsDisplay
|
||||
exerciseCount={section.exerciseCount}
|
||||
lessonCount={section.lessons?.length}
|
||||
lessons={section.lessons}
|
||||
title={section.title!}
|
||||
/>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Reviews -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.REVIEWS)}
|
||||
<NavSection id="reviews">
|
||||
<BlurFade delay={0.1}>
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.reviews')}
|
||||
</h2>
|
||||
{#if averageRating}
|
||||
<Sparkle
|
||||
text={averageRating}
|
||||
colors={{ first: '#facc15', second: '#f97316' }}
|
||||
sparklesCount={3}
|
||||
class="text-xl font-bold"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each reviews.slice(0, 4) as review, id (review.id)}
|
||||
{#if !review.hide}
|
||||
<BlurFade delay={0.1 + id * 0.08}>
|
||||
<div
|
||||
class="flex flex-row items-start gap-3 rounded-lg border border-gray-100 p-4 dark:border-neutral-800"
|
||||
>
|
||||
<!-- image container -->
|
||||
<Avatar.Root class="mt-1 size-10 shrink-0">
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<!-- profile content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="mb-0.5 font-medium">{review.name}</p>
|
||||
<!-- ratings -->
|
||||
<div class="mb-2 flex items-center gap-0.5">
|
||||
{#each Array(5) as _, starIndex (starIndex)}
|
||||
<StarIcon
|
||||
size={14}
|
||||
class={starIndex < (review.rating || 0)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300 dark:text-neutral-600'}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mb-2 overflow-hidden" style:max-height={expandDescription[id] ? 'none' : '60px'}>
|
||||
<p class="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
{review.description}
|
||||
</p>
|
||||
</div>
|
||||
{#if !expandDescription[id] && review.description.split(' ').length > 12}
|
||||
<button
|
||||
class="ui:text-primary text-sm font-normal underline"
|
||||
onclick={() => toggleDescription(id)}>See More</button
|
||||
>
|
||||
{/if}
|
||||
{#if expandDescription[id]}
|
||||
<button
|
||||
class="ui:text-primary text-sm font-normal underline"
|
||||
onclick={() => toggleDescription(id)}>See Less</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if reviews.length > 4}
|
||||
<Button class="mt-4" variant="outline" onclick={() => ($reviewsModalStore.open = true)}>
|
||||
{$t('course.navItem.landing_page.see_all')}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Reviews Modal -->
|
||||
<Dialog.Root bind:open={$reviewsModalStore.open}>
|
||||
<Dialog.Content class="w-9/12">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.landing_page.reviews_modal.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="flex">
|
||||
<!-- ratings -->
|
||||
<div class="w-1/3">
|
||||
<h2 class="text-xl">
|
||||
{averageRating}
|
||||
{$t('course.navItem.landing_page.reviews_modal.rating')}
|
||||
</h2>
|
||||
<h2 class="mt-2 text-lg font-semibold">
|
||||
{reviews.length}
|
||||
{$t('course.navItem.landing_page.reviews_modal.ratings')}
|
||||
</h2>
|
||||
</div>
|
||||
<!-- reviews -->
|
||||
<div class="flex w-4/6 flex-wrap">
|
||||
{#each reviews as review, id (review.id)}
|
||||
<!-- review -->
|
||||
<div class="my-2 flex w-full flex-row items-start">
|
||||
<!-- image container -->
|
||||
<Avatar.Root class="mt-1 h-10 w-10">
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<!-- profile content -->
|
||||
<div class="w-11/12 pl-2.5">
|
||||
<p class="mb-0.5 font-medium">{review.name}</p>
|
||||
<!-- ratings -->
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each Array(5) as _, starIndex (starIndex)}
|
||||
<StarIcon
|
||||
size={14}
|
||||
class={starIndex < (review.rating || 0)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300 dark:text-neutral-600'}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">
|
||||
{calDateDiff(review.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="mb-2 overflow-hidden transition-all"
|
||||
style:max-height={expandDescription[id] ? 'none' : '60px'}
|
||||
>
|
||||
<p class="my-2 text-sm leading-relaxed text-gray-600">
|
||||
{review.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Instructor -->
|
||||
<NavSection id="instructor">
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-4 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.instructor')}
|
||||
</h3>
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<img
|
||||
alt="Author Avatar"
|
||||
class="block h-20 w-20 rounded-full object-cover ring-2 ring-gray-200 dark:ring-neutral-700"
|
||||
src={instructorImageUrl}
|
||||
/>
|
||||
<div>
|
||||
<p class="text-base font-medium dark:text-white">
|
||||
{instructorName}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{get(instructor, 'role', '')}
|
||||
</p>
|
||||
{#if get(instructor, 'courseNo', '')}
|
||||
<p class="mt-1 flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-300">
|
||||
<PlayIcon size={14} class="filled" />
|
||||
<span>
|
||||
{get(instructor, 'courseNo', '')}
|
||||
{$t('course.navItem.landing_page.courses')}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm leading-relaxed font-light dark:text-white">
|
||||
{get(instructor, 'description', '')}
|
||||
</p>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Details -->
|
||||
<PricingSection {courseData} {editMode} bind:startCoursePayment className="target-component" />
|
||||
</div>
|
||||
{#if !isVisible}
|
||||
<PricingSection {courseData} {editMode} bind:startCoursePayment mobile={true} className="w-full" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.list ul li) {
|
||||
margin-left: 1rem;
|
||||
list-style-type: disc;
|
||||
}
|
||||
</style>
|
||||
<svelte:component this={ThemeComponent} {...landingProps} />
|
||||
{/if}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { NAV_ITEMS, NAV_ITEM_KEY } from './constants';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { Review } from '$features/course/utils/types';
|
||||
import get from 'lodash/get';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
import type { CourseLandingPageProps, OrgLandingPageTheme } from '@cio/ui/custom/org-landing-page';
|
||||
|
||||
export type LandingPageLesson = {
|
||||
id: string;
|
||||
@@ -105,3 +108,138 @@ export function filterNavItems(course: Course, reviews: Review[]) {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCourseLandingPageProps(
|
||||
course: Course,
|
||||
org: AccountOrg,
|
||||
options: {
|
||||
enrollHref: string;
|
||||
enrollDisabled: boolean;
|
||||
authAction?: { label: string; href: string };
|
||||
}
|
||||
): CourseLandingPageProps {
|
||||
const landing = normalizeLandingPageSettings(org.landingpage);
|
||||
const metadata = course.metadata ?? null;
|
||||
const reviewsRaw = (metadata?.reviews ?? []) as NonNullable<NonNullable<Course['metadata']>['reviews']>;
|
||||
const reviewsVisible = reviewsRaw.filter((r) => !r?.hide);
|
||||
|
||||
const sectionDisplay = (metadata?.sectionDisplay ?? {}) as Record<string, boolean>;
|
||||
const isVisible = (key: string, fallback = true) =>
|
||||
typeof sectionDisplay[key] === 'boolean' ? sectionDisplay[key] : fallback;
|
||||
|
||||
const sections = getCourseSections(course);
|
||||
const totalLessons = getTotalLessons(sections);
|
||||
|
||||
const averageRating =
|
||||
reviewsVisible.length > 0
|
||||
? reviewsVisible.reduce((sum, r) => sum + (r.rating || 0), 0) / reviewsVisible.length
|
||||
: undefined;
|
||||
|
||||
const hasCertificate = Boolean(metadata?.certificate?.templateUrl || course.certificate?.design?.templateId);
|
||||
|
||||
const courseTypeLabel =
|
||||
course.type === 'LIVE_CLASS' ? 'Live class' : course.type === 'COMPLIANCE' ? 'Compliance' : 'Self-paced';
|
||||
|
||||
const includeRequirement = isVisible(NAV_ITEM_KEY.REQUIREMENT) && Boolean(metadata?.requirements);
|
||||
const includeDescription = isVisible(NAV_ITEM_KEY.DESCRIPTION) && Boolean(metadata?.description);
|
||||
const includeGoals = isVisible(NAV_ITEM_KEY.GOALS) && Boolean(metadata?.goals);
|
||||
const includeCertificate = isVisible(NAV_ITEM_KEY.CERTIFICATE) && Boolean(metadata?.certificate?.templateUrl);
|
||||
const includeReviews = isVisible(NAV_ITEM_KEY.REVIEWS, reviewsVisible.length > 0);
|
||||
const includeInstructor = isVisible(NAV_ITEM_KEY.INSTRUCTOR);
|
||||
|
||||
const instructor = (metadata?.instructor ?? {}) as NonNullable<Course['metadata']>['instructor'] & {
|
||||
name?: string;
|
||||
role?: string;
|
||||
description?: string;
|
||||
imgUrl?: string;
|
||||
coursesNo?: number;
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{ label: 'Lessons', value: totalLessons.toString() },
|
||||
averageRating !== undefined ? { label: 'Rating', value: averageRating.toFixed(1) } : null,
|
||||
hasCertificate ? { label: 'Certificate', value: 'Included' } : null
|
||||
].filter(Boolean) as Array<{ label: string; value: string }>;
|
||||
|
||||
const pricingCtaLabel = course.cost && course.cost > 0 ? 'Enrol now' : 'Start free';
|
||||
|
||||
return {
|
||||
theme: landing.theme as OrgLandingPageTheme,
|
||||
orgName: org.name ?? '',
|
||||
logoUrl: org.avatarUrl ?? undefined,
|
||||
navItems: landing.navItems,
|
||||
authAction: options.authAction,
|
||||
hero: {
|
||||
heading: course.title ?? landing.hero.heading,
|
||||
subheading: course.description ?? landing.hero.subheading,
|
||||
primaryAction: {
|
||||
label: pricingCtaLabel,
|
||||
href: options.enrollHref,
|
||||
disabled: options.enrollDisabled
|
||||
},
|
||||
secondaryAction: { label: 'View curriculum', href: '#curriculum' },
|
||||
image: course.logo || undefined,
|
||||
stats
|
||||
},
|
||||
socialProof: {
|
||||
rating: averageRating,
|
||||
lessons: totalLessons > 0 ? totalLessons : undefined,
|
||||
type: courseTypeLabel,
|
||||
hasCertificate
|
||||
},
|
||||
info: {
|
||||
requirements: includeRequirement ? (metadata?.requirements ?? undefined) : undefined,
|
||||
description: includeDescription ? (metadata?.description ?? undefined) : undefined,
|
||||
goals: includeGoals ? (metadata?.goals ?? undefined) : undefined,
|
||||
certificateUrl: includeCertificate ? metadata?.certificate?.templateUrl : undefined
|
||||
},
|
||||
curriculum: {
|
||||
grouped: course.content?.grouped ?? false,
|
||||
sections: sections.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title ?? 'Lessons',
|
||||
lessons: section.lessons.map((lesson) => ({
|
||||
id: lesson.id,
|
||||
title: lesson.title ?? 'Untitled lesson'
|
||||
})),
|
||||
exerciseCount: section.exerciseCount
|
||||
}))
|
||||
},
|
||||
chips: {
|
||||
skills: metadata?.skills ?? undefined,
|
||||
tools: metadata?.tools ?? undefined
|
||||
},
|
||||
instructor: includeInstructor
|
||||
? {
|
||||
name: instructor.name?.trim() || org.name || '',
|
||||
role: instructor.role || undefined,
|
||||
imgUrl: instructor.imgUrl || org.avatarUrl || undefined,
|
||||
description: instructor.description || undefined,
|
||||
coursesNo: instructor.coursesNo || undefined
|
||||
}
|
||||
: { name: '' },
|
||||
reviews: {
|
||||
items: includeReviews
|
||||
? reviewsVisible.map((r, idx) => ({
|
||||
id: `review-${r.id ?? idx}`,
|
||||
name: r.name ?? 'Anonymous',
|
||||
avatarUrl: r.avatar_url || undefined,
|
||||
rating: r.rating ?? 0,
|
||||
description: r.description ?? '',
|
||||
createdAt: r.created_at ? new Date(r.created_at).toISOString() : undefined
|
||||
}))
|
||||
: [],
|
||||
averageRating
|
||||
},
|
||||
pricing: {
|
||||
cost: course.cost ?? 0,
|
||||
currency: course.currency ?? 'USD',
|
||||
discount: metadata?.discount,
|
||||
showDiscount: metadata?.showDiscount,
|
||||
ctaLabel: pricingCtaLabel,
|
||||
ctaHref: options.enrollHref,
|
||||
reward: metadata?.reward?.show ? { show: true, description: metadata.reward.description ?? '' } : undefined
|
||||
},
|
||||
footer: landing.footer
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
});
|
||||
|
||||
const poster = $derived(source.type === 'upload' ? source.metadata?.thumbnailUrl : undefined);
|
||||
const isHls = $derived(Boolean(source.hls));
|
||||
</script>
|
||||
|
||||
<div class="mb-5 {className}">
|
||||
{#if isMuse}
|
||||
<MusePlayer svid={source.metadata?.svid} {options} />
|
||||
{:else}
|
||||
<PlyrPlayer src={source.url} {poster} {options} />
|
||||
<PlyrPlayer src={source.url} {poster} hls={isHls} {options} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,18 +4,47 @@
|
||||
import type { MediaPlayerOptions } from '../types';
|
||||
import { isYoutubeUrl, getYoutubeVideoId } from '../utils';
|
||||
import { PLYR_DEFAULT_CONTROLS } from './constants';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import type Plyr from 'plyr';
|
||||
|
||||
/**
|
||||
* Resolve an HLS source. The caller passes either a full URL or the
|
||||
* relative shape `/hls/{assetId}/{rest}` we store in `lesson.videos[].link`.
|
||||
* Routes through the typed Hono client so playback uses the same base
|
||||
* URL as every other API call — direct to `PUBLIC_SERVER_URL` in local dev,
|
||||
* and `${origin}/proxy/...` in production. Cloud tenant-router intercepts
|
||||
* `/proxy/hls/*` and serves directly from R2.
|
||||
*
|
||||
* Hono's `$url()` only substitutes named `:params`, leaving wildcards as
|
||||
* a literal `/*` suffix — we strip it and append the real path.
|
||||
*/
|
||||
function resolveHlsUrl(src: string): string {
|
||||
if (/^https?:\/\//i.test(src)) return src;
|
||||
|
||||
const match = src.match(/^\/?hls\/([^/]+)\/(.+)$/);
|
||||
if (!match) return src;
|
||||
|
||||
const [, assetId, rest] = match;
|
||||
const built = classroomio.hls[':assetId']['*'].$url({ param: { assetId } });
|
||||
return built.toString().replace(/\/\*$/, '') + '/' + rest;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
poster?: string;
|
||||
/**
|
||||
* When true, `src` points at an HLS master playlist (.m3u8) and the
|
||||
* player attaches hls.js to handle adaptive bitrate streaming.
|
||||
*/
|
||||
hls?: boolean;
|
||||
options?: MediaPlayerOptions;
|
||||
}
|
||||
|
||||
let { src, poster, options = {} }: Props = $props();
|
||||
let { src, poster, hls = false, options = {} }: Props = $props();
|
||||
let videoElement: HTMLVideoElement | null = null;
|
||||
let containerElement: HTMLDivElement | null = null;
|
||||
let playerInstance: Plyr | null = null;
|
||||
let hlsInstance: import('hls.js').default | null = null;
|
||||
|
||||
const playsinline = $derived(options.playsinline !== false);
|
||||
const maxHeight = $derived(options.maxHeight || '400px');
|
||||
@@ -43,6 +72,36 @@
|
||||
iconPrefix: 'plyr'
|
||||
});
|
||||
} else if (videoElement) {
|
||||
// For HLS, attach hls.js to feed the manifest into the video element
|
||||
// before Plyr wraps it. Safari uses the native HLS playback path
|
||||
// (`canPlayType('application/vnd.apple.mpegurl')`), no hls.js needed.
|
||||
if (hls) {
|
||||
const hlsUrl = resolveHlsUrl(src);
|
||||
const supportsNativeHls = videoElement.canPlayType('application/vnd.apple.mpegurl') !== '';
|
||||
if (supportsNativeHls) {
|
||||
// Native HLS (Safari) — set src here since the markup branch sees
|
||||
// canPlayType before videoElement mounts and may render undefined.
|
||||
videoElement.src = hlsUrl;
|
||||
} else {
|
||||
const HlsModule = await import('hls.js');
|
||||
const Hls = HlsModule.default;
|
||||
if (Hls.isSupported()) {
|
||||
hlsInstance = new Hls({
|
||||
enableWorker: true,
|
||||
// Browser needs to send session cookies to the API when the
|
||||
// dashboard is on a different origin (dev / self-hosted).
|
||||
xhrSetup: (xhr) => {
|
||||
xhr.withCredentials = true;
|
||||
}
|
||||
});
|
||||
hlsInstance.loadSource(hlsUrl);
|
||||
hlsInstance.attachMedia(videoElement);
|
||||
} else {
|
||||
videoElement.src = hlsUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For regular HTML5 video
|
||||
// playsinline is set as an HTML attribute on the video element, not a Plyr option
|
||||
playerInstance = new Plyr(videoElement, {
|
||||
@@ -56,6 +115,15 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (hlsInstance) {
|
||||
try {
|
||||
hlsInstance.destroy();
|
||||
} catch {
|
||||
// Ignore — hls.js may have already torn itself down.
|
||||
} finally {
|
||||
hlsInstance = null;
|
||||
}
|
||||
}
|
||||
if (playerInstance) {
|
||||
try {
|
||||
// Check if player is an embed (YouTube/Vimeo) and handle accordingly
|
||||
@@ -93,8 +161,12 @@
|
||||
{:else}
|
||||
<!-- HTML5 video -->
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<!-- crossorigin omitted so cross-origin poster (e.g. R2 r2.dev) displays without CORS; r2.dev may not send CORS headers even when bucket CORS is set -->
|
||||
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player"></video>
|
||||
<!-- For HLS, hls.js or native HLS attaches programmatically in onMount; the bare <video> has no src here. -->
|
||||
{#if hls}
|
||||
<video bind:this={videoElement} {poster} {playsinline} class="plyr-player"></video>
|
||||
{:else}
|
||||
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player"></video>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ClassroomIO Logo Overlay -->
|
||||
|
||||
@@ -3,6 +3,11 @@ export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse' | 'google_driv
|
||||
export interface VideoSource {
|
||||
type: VideoType;
|
||||
url: string;
|
||||
/**
|
||||
* When set, `url` points at an HLS master playlist (.m3u8). The player
|
||||
* attaches hls.js instead of feeding the URL into a raw HTML5 video.
|
||||
*/
|
||||
hls?: boolean;
|
||||
metadata?: {
|
||||
svid?: string; // muse.ai video ID
|
||||
thumbnailUrl?: string; // upload thumbnail / poster
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
|
||||
import { PublicCoursePoweredBy } from '@cio/ui/custom/public-course';
|
||||
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants/domains';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
@@ -44,7 +45,7 @@
|
||||
/>
|
||||
{:else if $isFreePlan}
|
||||
<a
|
||||
href={`https://classroomio.com?utm_source=${$currentOrg.siteName}.classroomio.com`}
|
||||
href={`https://classroomio.com?utm_source=${$currentOrg.siteName}.${TENANT_ROOT_DOMAIN}`}
|
||||
target="_blank"
|
||||
class={cn('group fixed right-9 bottom-14 z-50 hover:no-underline', className)}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
interface Props {
|
||||
allowedRoles?: number[];
|
||||
@@ -14,16 +16,21 @@
|
||||
let { allowedRoles = [], onDenied = () => {}, onlyStudent = false, children }: Props = $props();
|
||||
|
||||
const userRole: number | null = $derived.by(() => {
|
||||
// In the student experience everyone is treated as a student, so a teacher
|
||||
// previews exactly what a student sees — no matter their real course role.
|
||||
if ($isStudentExperience) return ROLE.STUDENT;
|
||||
|
||||
const user = courseApi.group.people.find((person) => person.profileId === $profile.id);
|
||||
return user ? Number(user.roleId) : null;
|
||||
});
|
||||
|
||||
const show = $derived.by(() => {
|
||||
if (onlyStudent) {
|
||||
// No org-admin bypass while in the student experience — treat the viewer as a student.
|
||||
if (onlyStudent || $isStudentExperience) {
|
||||
return isAllowed(userRole);
|
||||
} else {
|
||||
return isAllowed(userRole) || !!$isOrgAdmin;
|
||||
}
|
||||
|
||||
return isAllowed(userRole) || !!$isOrgAdmin;
|
||||
});
|
||||
|
||||
function isAllowed(userRole: number | null) {
|
||||
@@ -32,7 +39,8 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!$isOrgAdmin && userRole !== null && !isAllowed(userRole)) {
|
||||
const determined = $isStudentExperience || userRole !== null;
|
||||
if (determined && !show) {
|
||||
onDenied();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { basePath, globalStore } from '$lib/utils/store/app';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
import BellPlusIcon from '@lucide/svelte/icons/bell-plus';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import BadgeHelpIcon from '@lucide/svelte/icons/badge-help';
|
||||
import LogOutIcon from '@lucide/svelte/icons/log-out';
|
||||
import MessageSquarePlusIcon from '@lucide/svelte/icons/message-square-plus';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
@@ -16,8 +17,12 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { showUserJotWidget } from '$lib/utils/services/userjot';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
const SUPPORT_EMAIL = 'help@classroomio.com';
|
||||
const DOCS_URL = 'https://classroomio.com/docs';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
function getRoleLabel(roleId: number): string {
|
||||
@@ -107,33 +112,31 @@
|
||||
<div class="cursor-pointer space-y-2">
|
||||
{#if !$globalStore.isOrgSite}
|
||||
<div class="space-y-4">
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a href="https://classroomio.com/roadmap" target="_blank" class="flex w-full items-center gap-2">
|
||||
<DropdownMenu.Item class="m-0" onclick={() => showUserJotWidget('updates')}>
|
||||
<span class="flex w-full items-center gap-2">
|
||||
<BellPlusIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.whats_new')}</p>
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0" onclick={() => showUserJotWidget('feedback')}>
|
||||
<span class="flex w-full items-center gap-2">
|
||||
<MessageSquarePlusIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.feedback')}</p>
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" class="flex w-full items-center gap-2">
|
||||
<BookOpenIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.documentation')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a
|
||||
href="https://classroomio.com/blog/launch-week"
|
||||
target="_blank"
|
||||
class="flex w-full items-center gap-2"
|
||||
>
|
||||
<RocketIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.launch_week')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a
|
||||
href="https://classroomio.com/contact"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex h-full w-full items-center justify-start space-x-2 text-start"
|
||||
>
|
||||
<BadgeHelpIcon size={20} />
|
||||
<p class="text-sm dark:text-white">{$t('org_navigation.help')}</p>
|
||||
<a href="mailto:{SUPPORT_EMAIL}" class="flex w-full items-center gap-2">
|
||||
<BadgeHelpIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.need_help')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
<script module lang="ts">
|
||||
// Module-level singleton: one Promise shared across all mounted instances.
|
||||
// Dynamic imports are already cached by the JS engine after the first load,
|
||||
// but reusing the same Promise avoids redundant microtask chains on remounts.
|
||||
let pending: Promise<typeof import('@cio/ui/custom/editor')> | null = null;
|
||||
|
||||
function loadEditor() {
|
||||
pending ??= import('@cio/ui/custom/editor');
|
||||
return pending;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Editor } from '@cio/ui/custom/editor';
|
||||
// Type-only imports are erased at build time — no static TipTap dependency.
|
||||
import type { HTMLContent, TiptapEditor } from '@cio/ui/custom/editor';
|
||||
|
||||
interface Props {
|
||||
placeholder?: string | ((node: any) => string);
|
||||
// Content of the editor
|
||||
content?: HTMLContent;
|
||||
// Whether the toolbar should be visible
|
||||
showToolBar?: boolean;
|
||||
// Whether the editor is editable
|
||||
editable?: boolean;
|
||||
// Whether to enable localStorage persistence
|
||||
enablePersistence?: boolean;
|
||||
// localStorage key for content persistence
|
||||
contentStorageKey?: string;
|
||||
// localStorage key for editable state persistence
|
||||
editableStorageKey?: string;
|
||||
// CSS class for the editor wrapper
|
||||
class?: string;
|
||||
// CSS class for the editor itself
|
||||
editorClass?: string;
|
||||
// Callback functions
|
||||
onChange?: (content: HTMLContent) => void;
|
||||
onReady?: (editor: TiptapEditor) => void;
|
||||
onEditorDestroy?: () => void;
|
||||
@@ -42,17 +45,21 @@
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Editor
|
||||
{content}
|
||||
{showToolBar}
|
||||
{editable}
|
||||
{enablePersistence}
|
||||
{contentStorageKey}
|
||||
{editableStorageKey}
|
||||
class={className}
|
||||
{editorClass}
|
||||
{placeholder}
|
||||
onContentChange={onChange}
|
||||
onEditorReady={onReady}
|
||||
{onEditorDestroy}
|
||||
/>
|
||||
{#await loadEditor()}
|
||||
<div class="ui:animate-pulse ui:rounded-md ui:bg-muted ui:h-32 ui:w-full" />
|
||||
{:then { Editor }}
|
||||
<Editor
|
||||
{content}
|
||||
{showToolBar}
|
||||
{editable}
|
||||
{enablePersistence}
|
||||
{contentStorageKey}
|
||||
{editableStorageKey}
|
||||
class={className}
|
||||
{editorClass}
|
||||
{placeholder}
|
||||
onContentChange={onChange}
|
||||
onEditorReady={onReady}
|
||||
{onEditorDestroy}
|
||||
/>
|
||||
{/await}
|
||||
|
||||
@@ -46,10 +46,10 @@
|
||||
const MAX_IMAGE_SIZE = 500 * FileDropZone.KILOBYTE;
|
||||
|
||||
async function handleImageClick(img: string) {
|
||||
$handleOpenWidget.open = false;
|
||||
|
||||
onchange?.(img);
|
||||
imageURL = img;
|
||||
|
||||
$handleOpenWidget.open = false;
|
||||
}
|
||||
|
||||
async function handleFilesUpload(files: File[]) {
|
||||
@@ -84,7 +84,7 @@
|
||||
isSearching = true;
|
||||
|
||||
try {
|
||||
unsplashImages = await queryUnsplash(searchQuery || 'rocks');
|
||||
unsplashImages = await queryUnsplash(searchQuery || 'nature landscape architecture');
|
||||
} catch (error) {
|
||||
snackbar.error('snackbar.landing_page_settings.error.fetch_error');
|
||||
console.error('Error fetching images from Unsplash:', error);
|
||||
@@ -102,7 +102,7 @@
|
||||
if (!isOpen) $handleOpenWidget.open = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="ui:z-300! w-3/5">
|
||||
<Dialog.Content class="ui:z-300! w-[95%] max-w-2xl!">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.landing_page.upload_widget.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
@@ -142,10 +142,10 @@
|
||||
</Button>
|
||||
</form>
|
||||
{#if unsplashImages && unsplashImages.length > 0}
|
||||
<div class="hide-scrollbar flex max-h-[300px] flex-row flex-wrap items-center gap-2 px-[10px]">
|
||||
<div class="hide-scrollbar grid max-h-[400px] grid-cols-4 gap-3 px-[10px]">
|
||||
{#each unsplashImages as unsplashImages (unsplashImages.id)}
|
||||
<div>
|
||||
<div class="relative h-[130px] w-[195px] overflow-hidden">
|
||||
<div class="relative aspect-[3/2] overflow-hidden">
|
||||
<button onclick={() => handleImageClick(unsplashImages.urls.regular)}>
|
||||
<img
|
||||
src={unsplashImages.urls.regular}
|
||||
@@ -155,7 +155,7 @@
|
||||
</button>
|
||||
</div>
|
||||
{#if unsplashImages.user.name}
|
||||
<p class="mt-1 text-center text-xs font-light text-gray-500">
|
||||
<p class="mt-1 truncate text-center text-xs font-light text-gray-500">
|
||||
By <a
|
||||
href={`https://unsplash.com/@${unsplashImages.user.username}`}
|
||||
target="_blank"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TWidgetLayoutType } from '@cio/utils/validation/widget';
|
||||
|
||||
/** Served from `apps/dashboard/static/templates/widget-layouts/*.png` (~720px max edge for sharp downscaling). */
|
||||
export const WIDGET_LAYOUT_THUMBNAIL_BASE = '/templates/widget-layouts';
|
||||
/** Served from CDN. */
|
||||
export const WIDGET_LAYOUT_THUMBNAIL_BASE = 'https://assets.cdn.clsrio.com/templates/widget-layouts';
|
||||
|
||||
export function widgetLayoutThumbnailSrc(layoutType: TWidgetLayoutType): string {
|
||||
return `${WIDGET_LAYOUT_THUMBNAIL_BASE}/${layoutType}.png`;
|
||||
|
||||
@@ -37,7 +37,8 @@ const saasDefaults = {
|
||||
'https://*.senja.io',
|
||||
'https://*.ytimg.com',
|
||||
'https://noembed.com',
|
||||
'https://www.googleapis.com'
|
||||
'https://www.googleapis.com',
|
||||
'https://o476906.ingest.us.sentry.io'
|
||||
],
|
||||
frameSrc: [
|
||||
'https://www.youtube.com',
|
||||
|
||||
@@ -4,15 +4,19 @@ import { initUmami } from '$lib/utils/services/umami';
|
||||
import { initUserJot } from '$lib/utils/services/userjot';
|
||||
import { licenseApi } from '$features/license/api/license.svelte';
|
||||
|
||||
let isInitialized = false;
|
||||
let isTrackingInitialized = false;
|
||||
|
||||
export function setupAnalytics(user?: PosthogBootstrapUser) {
|
||||
if (isInitialized) return;
|
||||
isInitialized = true;
|
||||
function setupTracking(user?: PosthogBootstrapUser) {
|
||||
if (isTrackingInitialized) return;
|
||||
isTrackingInitialized = true;
|
||||
|
||||
initPosthog(user);
|
||||
initUmami();
|
||||
}
|
||||
|
||||
export function setupAnalytics(user?: PosthogBootstrapUser) {
|
||||
initUserJot();
|
||||
setupTracking(user);
|
||||
}
|
||||
|
||||
/** Checks if this is cloud deployment and initializes analytics */
|
||||
@@ -23,9 +27,11 @@ export function setupCloudAnalytics(user?: PosthogBootstrapUser) {
|
||||
}
|
||||
|
||||
export function setupAnalyticsBasedOnLicense(user?: PosthogBootstrapUser) {
|
||||
initUserJot();
|
||||
|
||||
if (licenseApi.hasAccess('no-tracking')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setupAnalytics(user);
|
||||
setupTracking(user);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { appInitApi } from '$features/app/init.svelte';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { clearSentryUser } from '$lib/utils/services/sentry';
|
||||
import { clearUserJotUser } from '$lib/utils/services/userjot';
|
||||
import { goto } from '$app/navigation';
|
||||
import posthog from 'posthog-js';
|
||||
import { resolve } from '$app/paths';
|
||||
@@ -18,6 +19,7 @@ export async function logout(redirect = true) {
|
||||
capturePosthogEvent('user_logged_out');
|
||||
posthog.reset();
|
||||
clearSentryUser();
|
||||
clearUserJotUser();
|
||||
|
||||
if (redirect) {
|
||||
goto(resolve('/login', {}));
|
||||
|
||||
@@ -5,10 +5,10 @@ import { env } from '$env/dynamic/public';
|
||||
|
||||
const isSelfHosted = PUBLIC_IS_SELFHOSTED === 'true';
|
||||
|
||||
const DEFAULT_TITLE = 'ClassroomIO | The Open Source Learning Management System for Companies';
|
||||
const DEFAULT_TITLE = 'ClassroomIO | The Most Customizable Platform for Customer, Partner, and Employee Education';
|
||||
const DEFAULT_DESCRIPTION =
|
||||
'A flexible, user-friendly platform for creating, managing, and delivering courses for companies and training organisations';
|
||||
const CLOUD_OG_IMAGE = 'https://brand.cdn.clsrio.com/og/classroomio-og.png';
|
||||
'One platform for customer academies, partner certification, and employee training. Build courses with AI, publish under your domain, and track completions.';
|
||||
const CLOUD_OG_IMAGE = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.png';
|
||||
|
||||
function resolveOgImageUrl(url: URL, orgSiteInfo: OrgSiteInfo): string {
|
||||
const envUrl = env.PUBLIC_OG_IMAGE_URL?.trim();
|
||||
@@ -64,7 +64,7 @@ export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsPro
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
alt: `${siteName} OG Image`,
|
||||
alt: `${siteName} platform for customer, partner, and employee education`,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
secureUrl: ogImageUrl,
|
||||
@@ -79,7 +79,7 @@ export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsPro
|
||||
title,
|
||||
description,
|
||||
image: ogImageUrl,
|
||||
imageAlt: `${siteName} OG Image`
|
||||
imageAlt: `${siteName} platform for customer, partner, and employee education`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ function resolveApiUpstreamPath(pathname: string): string {
|
||||
}
|
||||
|
||||
function resolveApiUpstreamBase(): string | null {
|
||||
const base = process.env.PRIVATE_SERVER_URL || process.env.PUBLIC_SERVER_URL;
|
||||
const base = process.env.PRIVATE_SERVER_URL;
|
||||
if (!base) return null;
|
||||
|
||||
return base.replace(/\/$/, '');
|
||||
@@ -43,7 +43,7 @@ export async function proxyRequestToApi(request: Request): Promise<Response> {
|
||||
const apiBase = resolveApiUpstreamBase();
|
||||
|
||||
if (!apiBase) {
|
||||
console.error('proxyRequestToApi: missing PRIVATE_SERVER_URL / PUBLIC_SERVER_URL');
|
||||
console.error('proxyRequestToApi: missing PRIVATE_SERVER_URL');
|
||||
return new Response('API upstream not configured', { status: 502 });
|
||||
}
|
||||
|
||||
|
||||
@@ -15,19 +15,20 @@ export const getRequestBaseUrl = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
// When on the server, we want to hit the private url which is the docker container of `api` or the private network url of `api`.
|
||||
// if that isn't set then it will fallback to the public url of the `api`
|
||||
// this doesn't need proxy because on the server we are using our api key to make queries.
|
||||
return process.env.PRIVATE_SERVER_URL || env.PUBLIC_SERVER_URL;
|
||||
}
|
||||
|
||||
// Self-hosted: dashboard and API are on different subdomains of the
|
||||
// operator's apex. Browser calls go straight to PUBLIC_SERVER_URL;
|
||||
// cookies cross subdomains via AUTH_COOKIE_DOMAIN. No Worker proxy.
|
||||
if (env.PUBLIC_IS_SELFHOSTED === 'true' || dev) {
|
||||
// Local dev keeps the direct API path so existing Vite/API workflows do not
|
||||
// depend on the SvelteKit proxy.
|
||||
if (dev) {
|
||||
return env.PUBLIC_SERVER_URL ?? '';
|
||||
}
|
||||
|
||||
// Cloud (multi-tenant): same-origin via the Cloudflare Worker `/proxy`
|
||||
// prefix so auth cookies stay host-only on whichever tenant or BYOD
|
||||
// domain the user is currently visiting. The Worker strips `/proxy` before forwarding to the API
|
||||
// Browser calls are same-origin in production. For self-hosted Node builds,
|
||||
// hooks.server.ts proxies `/proxy/*` to PRIVATE_SERVER_URL.
|
||||
// For cloud multi-tenant builds, the Cloudflare Worker handles the same
|
||||
// prefix. In both cases auth cookies stay first-party on the dashboard host.
|
||||
return `${window.location.origin}/proxy`;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { dev } from '$app/environment';
|
||||
|
||||
// Browser baseURL depends on deployment shape:
|
||||
//
|
||||
// - Self-hosted: dashboard and API are on different subdomains under
|
||||
// one apex. Browser calls go straight to PUBLIC_SERVER_URL; cookies
|
||||
// cross subdomains via AUTH_COOKIE_DOMAIN. No Worker proxy.
|
||||
// - Self-hosted: browser auth calls stay on the dashboard origin at
|
||||
// `/api/auth/*`; hooks.server.ts forwards them to the API over
|
||||
// PRIVATE_SERVER_URL.
|
||||
//
|
||||
// - Cloud (multi-tenant): same-origin via the Cloudflare Worker so
|
||||
// auth cookies stay host-only on whichever tenant/BYOD domain the
|
||||
@@ -23,9 +23,12 @@ function resolveBaseURL() {
|
||||
if (typeof window === 'undefined') {
|
||||
return env.PUBLIC_SERVER_URL || 'http://localhost:3002';
|
||||
}
|
||||
if (env.PUBLIC_IS_SELFHOSTED === 'true' || dev) {
|
||||
if (dev) {
|
||||
return env.PUBLIC_SERVER_URL || `${window.location.origin}/api/auth`;
|
||||
}
|
||||
if (env.PUBLIC_IS_SELFHOSTED === 'true') {
|
||||
return `${window.location.origin}/api/auth`;
|
||||
}
|
||||
return `${window.location.origin}/proxy/api/auth`;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getRequestBaseUrl } from '$lib/utils/services/api';
|
||||
export const getSessionData = async (cookies: Cookies): Promise<App.Locals | null> => {
|
||||
try {
|
||||
const cioCookies = getCioCookieString(cookies);
|
||||
console.log('cioCookies', cioCookies);
|
||||
|
||||
if (!cioCookies) return null;
|
||||
|
||||
@@ -39,6 +40,7 @@ export async function getThroughTrpc(allCookies: string) {
|
||||
export async function getThroughAuthClient(allCookies: string) {
|
||||
const baseURL = getRequestBaseUrl();
|
||||
console.log('baseURL', baseURL);
|
||||
console.log('allCookies', allCookies);
|
||||
const session = await authServerClient.getSession({
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
|
||||
@@ -38,7 +38,10 @@ export const initPosthog = (user?: PosthogBootstrapUser): void => {
|
||||
if (dev || hasNoTracking()) return;
|
||||
|
||||
posthog.init('phc_JfdHOZ6v0cVlGELBYx1Tmoen2nxNOrAzvgvrPA6Ksov', {
|
||||
api_host: 'https://eu.posthog.com',
|
||||
// Route PostHog through our own domain via the tenant-router Worker so the
|
||||
// cookie is first-party and doesn't trigger the Lighthouse "third-party cookie" deduction.
|
||||
api_host: `${window.location.origin}/ingest`,
|
||||
ui_host: 'https://eu.posthog.com',
|
||||
...(user && {
|
||||
bootstrap: { distinctID: user.id, isIdentifiedID: true }
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { get } from 'svelte/store';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { licenseApi } from '$features/license/api/license.svelte';
|
||||
|
||||
const USERJOT_APP_ID = 'cm4a6vcmp00jpmdb5n66rmkzz';
|
||||
|
||||
@@ -9,7 +8,6 @@ let isInitialized = false;
|
||||
|
||||
function isWidgetAllowed(): boolean {
|
||||
if (dev) return false;
|
||||
if (licenseApi.hasAccess('no-tracking')) return false;
|
||||
if (get(globalStore).isOrgSite) return false;
|
||||
|
||||
return true;
|
||||
@@ -44,7 +42,7 @@ export function initUserJot(): void {
|
||||
ensureSdkLoaded();
|
||||
|
||||
window.uj.init(USERJOT_APP_ID, {
|
||||
widget: true,
|
||||
trigger: 'custom',
|
||||
position: 'right',
|
||||
theme: 'auto'
|
||||
});
|
||||
@@ -75,3 +73,21 @@ export function identifyUserJotUser({ id, email, fullname, avatarUrl }: UserJotI
|
||||
avatar: avatarUrl ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function clearUserJotUser(): void {
|
||||
if (!isWidgetAllowed()) return;
|
||||
|
||||
ensureSdkLoaded();
|
||||
|
||||
window.uj.identify(null);
|
||||
}
|
||||
|
||||
export type UserJotWidgetSection = 'feedback' | 'roadmap' | 'updates';
|
||||
|
||||
export function showUserJotWidget(section: UserJotWidgetSection): void {
|
||||
if (!isWidgetAllowed()) return;
|
||||
|
||||
ensureSdkLoaded();
|
||||
|
||||
window.uj.showWidget({ section });
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ declare global {
|
||||
theme?: 'auto' | 'light' | 'dark';
|
||||
}
|
||||
) => void;
|
||||
identify: (user: { id: string; email?: string; firstName?: string; lastName?: string; avatar?: string }) => void;
|
||||
identify: (
|
||||
user: { id: string; email?: string; firstName?: string; lastName?: string; avatar?: string } | null
|
||||
) => void;
|
||||
showWidget: (options: { section: 'feedback' | 'roadmap' | 'updates' }) => void;
|
||||
[method: string]: (...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export const isOrgStudent = derived(currentOrg, ($currentOrg) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The goal of this store is to determine whether we should display the app in student or admin/teacher mode.
|
||||
* Cloud: orgSite is always student (a teacher on an org subdomain sees the student view).
|
||||
* Self-hosted: derived from isOrgStudent (role-based).
|
||||
*/
|
||||
|
||||
@@ -350,7 +350,9 @@
|
||||
"thumbnail_updated": "Miniaturebilledet er opdateret",
|
||||
"thumbnail_update_failed": "Kunne ikke opdatere thumbnail",
|
||||
"thumbnail_regen_started": "Regenererer thumbnails...",
|
||||
"thumbnail_regen_failed": "Kunne ikke genskabe miniaturebilleder"
|
||||
"thumbnail_regen_failed": "Kunne ikke genskabe miniaturebilleder",
|
||||
"transcript_saved": "Udskrift opdateret",
|
||||
"transcript_save_failed": "Kunne ikke gemme transskription"
|
||||
},
|
||||
"tags": {
|
||||
"group_created": "Taggruppe oprettet",
|
||||
@@ -420,6 +422,18 @@
|
||||
"workspace_is_primary": "Du kan ikke slette det primære arbejdsområde.",
|
||||
"workspace_not_found": "Arbejdsområdet blev ikke fundet.",
|
||||
"not_primary_workspace": "Skift til dit primære arbejdsområde for at administrere kontoarbejdsområder."
|
||||
},
|
||||
"link_invite": {
|
||||
"generated": "Linkinvitation genereret",
|
||||
"enabled": "Linkinvitation aktiveret",
|
||||
"disabled": "Linkinvitation deaktiveret"
|
||||
},
|
||||
"view_as_student": {
|
||||
"failed": "Elevvisningen kunne ikke åbnes. Prøv venligst igen."
|
||||
},
|
||||
"lesson_video": {
|
||||
"generate_1080_completed": "1080p gengivelse tilføjet",
|
||||
"generate_1080_failed": "Kunne ikke generere 1080p gengivelse"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -905,7 +919,9 @@
|
||||
"short_video": "Kort video",
|
||||
"replace_cover": "Udskift forsidebillede",
|
||||
"replace": "Udskift",
|
||||
"helper": "Indtast et link til en youtube-video"
|
||||
"helper": "Indtast et link til en youtube-video",
|
||||
"remove": "Fjern",
|
||||
"no_cover": "Intet forsidebillede indstillet"
|
||||
},
|
||||
"title": {
|
||||
"header": "Overskrift",
|
||||
@@ -1030,7 +1046,17 @@
|
||||
"members": "Medlemmer",
|
||||
"invite_sent": "Invitation sendt",
|
||||
"you": "Dig",
|
||||
"remove": "Fjern"
|
||||
"remove": "Fjern",
|
||||
"link_invite": {
|
||||
"heading": "Link invitation",
|
||||
"description": "Alle med dette link kan tilslutte sig din organisation.",
|
||||
"generate": "Generer link",
|
||||
"copy": "Kopiér",
|
||||
"copied": "Kopieret!",
|
||||
"toggle_label": "Linkinvitation aktiv",
|
||||
"disabled_notice": "Dette link er deaktiveret. Aktiver det for at tillade nye medlemmer at deltage via link.",
|
||||
"select_role": "Vælg en rolle"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Admin",
|
||||
@@ -1879,7 +1905,11 @@
|
||||
"language": "Language: {language}",
|
||||
"empty": "No transcript yet. You can generate one from this card’s menu after the video is uploaded.",
|
||||
"loading": "Loading transcript…",
|
||||
"open_side_panel": "Åbn transskriptionspanelet"
|
||||
"open_side_panel": "Åbn transskriptionspanelet",
|
||||
"edit": "Rediger udskrift",
|
||||
"save": "Gem",
|
||||
"cancel": "Annuller",
|
||||
"segment_label": "Segmenter kl. {timestamp}"
|
||||
},
|
||||
"simple_card": {
|
||||
"kind_youtube": "YouTube",
|
||||
@@ -1889,7 +1919,13 @@
|
||||
"course_fallback": "Kursus",
|
||||
"menu_aria": "Video muligheder"
|
||||
},
|
||||
"view_transcript": "Se transskription"
|
||||
"view_transcript": "Se transskription",
|
||||
"playback_error": "Indlæsningen af denne video mislykkedes.",
|
||||
"playback_reload": "Genindlæs video",
|
||||
"generate_1080": "Generer 1080p",
|
||||
"generate_1080_in_progress": "Genererer 1080p...",
|
||||
"generate_1080_ready": "1080p tilgængelig",
|
||||
"generate_1080_unavailable": "1080p ikke tilgængelig for denne kildevideo"
|
||||
},
|
||||
"slide": {
|
||||
"title": "Slide",
|
||||
@@ -2085,7 +2121,8 @@
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Se som studerende"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Tilbage til kurset",
|
||||
@@ -2105,12 +2142,12 @@
|
||||
"nav_attendance": "Fremmøde",
|
||||
"nav_submissions": "Indsendelser",
|
||||
"nav_marks": "Mærker",
|
||||
"nav_compliance": "Overholdelse",
|
||||
"nav_people": "Mennesker",
|
||||
"nav_analytics": "Analytics",
|
||||
"nav_certificates": "Certifikater",
|
||||
"nav_landing_page": "Landingsside",
|
||||
"nav_settings": "Indstillinger",
|
||||
"nav_compliance": "Overholdelse",
|
||||
"nav_ai_assistant": "Assistent",
|
||||
"nav_ai_tutor": "AI underviser"
|
||||
},
|
||||
@@ -2202,6 +2239,12 @@
|
||||
"form_title": "Fortæl os om dit produkt"
|
||||
}
|
||||
}
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "Dette vil omdirigere dig til Student LMS.",
|
||||
"description": "Du er ved at se, hvad en studerende vil se, når de tilmelder sig dette kursus",
|
||||
"copy_link": "Kopiér link",
|
||||
"go_to_lms": "Gå til LMS"
|
||||
}
|
||||
},
|
||||
"courses": {
|
||||
@@ -3069,6 +3112,11 @@
|
||||
"field_nav_count_other": "{count} links",
|
||||
"hero_empty": "—",
|
||||
"customize": "Tilpas landingsside"
|
||||
},
|
||||
"preview_tabs": {
|
||||
"label": "Forhåndsvisningsside",
|
||||
"home": "Hjem",
|
||||
"course": "Kursus"
|
||||
}
|
||||
},
|
||||
"organization": {
|
||||
@@ -3322,7 +3370,10 @@
|
||||
"tic_tac": "Tictac tå",
|
||||
"see_more": "Se mere",
|
||||
"whats_new": "Hvad er nyt",
|
||||
"launch_week": "Lanceringsuge"
|
||||
"launch_week": "Lanceringsuge",
|
||||
"feedback": "Feedback",
|
||||
"documentation": "Dokumentation",
|
||||
"need_help": "Har du brug for hjælp?"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "Velkommen tilbage",
|
||||
@@ -3567,6 +3618,18 @@
|
||||
"joined": "Tilsluttet organisation",
|
||||
"join_failed": "Kunne ikke deltage i organisationen"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"page_title": "Tilmeld dig {orgName} på ClassroomIO",
|
||||
"invitation_student": "Du er blevet inviteret til at deltage i {orgName} som elev.",
|
||||
"invitation_teacher": "Du er blevet inviteret til at deltage i {orgName} som lærer.",
|
||||
"invitation_admin": "Du er blevet inviteret til at deltage i {orgName} som administrator.",
|
||||
"invitation_fallback": "Du er blevet inviteret til at deltage i {orgName} som {role}.",
|
||||
"accept_button": "Deltag i organisationen",
|
||||
"go_to_dashboard": "Gå til Dashboard",
|
||||
"disabled": "Dette invitationslink er blevet deaktiveret af organisationen.",
|
||||
"join_failed": "Kunne ikke deltage i organisationen",
|
||||
"joined": "Tilsluttet organisation"
|
||||
}
|
||||
},
|
||||
"automation": {
|
||||
@@ -3889,7 +3952,7 @@
|
||||
"resume": "Genoptag",
|
||||
"stopped_content_kept": "Stoppet. Oprettet indhold er blevet bevaret.",
|
||||
"mention_lesson": "Lektion",
|
||||
"mention_exercise": "Motion",
|
||||
"mention_exercise": "Øvelse",
|
||||
"mention_section": "Sektion",
|
||||
"mention_no_results": "Intet matchende indhold",
|
||||
"uploading_document": "Uploader dokument...",
|
||||
@@ -4053,7 +4116,11 @@
|
||||
"title": "Arbejder reagerer ikke",
|
||||
"body": "Denne kørsel har ventet i mere end 30 sekunder uden nogen afhentning af arbejderen. Baggrundsarbejderen er muligvis nede eller genstarter."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ask_ai": "Spørg AI",
|
||||
"content_input_placeholder": "Spørg om dette indhold...",
|
||||
"ask_ai_expand_aria": "Åbn Spørg AI-input",
|
||||
"ask_ai_input_aria": "Spørg AI om dette indhold"
|
||||
},
|
||||
"widgets": {
|
||||
"heading": "Widgets",
|
||||
|
||||
@@ -350,7 +350,9 @@
|
||||
"thumbnail_updated": "Miniaturbild aktualisiert",
|
||||
"thumbnail_update_failed": "Miniaturansicht konnte nicht aktualisiert werden",
|
||||
"thumbnail_regen_started": "Miniaturansichten werden neu generiert…",
|
||||
"thumbnail_regen_failed": "Die Miniaturansichten konnten nicht neu generiert werden"
|
||||
"thumbnail_regen_failed": "Die Miniaturansichten konnten nicht neu generiert werden",
|
||||
"transcript_saved": "Transkript aktualisiert",
|
||||
"transcript_save_failed": "Das Transkript konnte nicht gespeichert werden"
|
||||
},
|
||||
"tags": {
|
||||
"group_created": "Tag-Gruppe erstellt",
|
||||
@@ -420,6 +422,18 @@
|
||||
"workspace_is_primary": "Sie können den primären Arbeitsbereich nicht löschen.",
|
||||
"workspace_not_found": "Arbeitsbereich nicht gefunden.",
|
||||
"not_primary_workspace": "Wechseln Sie zu Ihrem primären Arbeitsbereich, um Kontoarbeitsbereiche zu verwalten."
|
||||
},
|
||||
"link_invite": {
|
||||
"generated": "Link-Einladung generiert",
|
||||
"enabled": "Link-Einladung aktiviert",
|
||||
"disabled": "Linkeinladung deaktiviert"
|
||||
},
|
||||
"view_as_student": {
|
||||
"failed": "Die Studentenansicht konnte nicht geöffnet werden. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"lesson_video": {
|
||||
"generate_1080_completed": "1080p-Wiedergabe hinzugefügt",
|
||||
"generate_1080_failed": "Die 1080p-Wiedergabe konnte nicht generiert werden"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -905,7 +919,9 @@
|
||||
"short_video": "Kurzes Video",
|
||||
"replace_cover": "Titelbild ersetzen",
|
||||
"replace": "Ersetzen",
|
||||
"helper": "Geben Sie einen Link zu einem YouTube-Video ein"
|
||||
"helper": "Geben Sie einen Link zu einem YouTube-Video ein",
|
||||
"remove": "Entfernen",
|
||||
"no_cover": "Kein Titelbild festgelegt"
|
||||
},
|
||||
"title": {
|
||||
"header": "Kopfzeile",
|
||||
@@ -1030,7 +1046,17 @@
|
||||
"members": "Mitglieder",
|
||||
"invite_sent": "Einladung gesendet",
|
||||
"you": "Du",
|
||||
"remove": "Entfernen"
|
||||
"remove": "Entfernen",
|
||||
"link_invite": {
|
||||
"heading": "Link-Einladung",
|
||||
"description": "Jeder mit diesem Link kann Ihrer Organisation beitreten.",
|
||||
"generate": "Link generieren",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"toggle_label": "Linkeinladung aktiv",
|
||||
"disabled_notice": "Dieser Link ist deaktiviert. Aktivieren Sie es, um neuen Mitgliedern den Beitritt über einen Link zu ermöglichen.",
|
||||
"select_role": "Wählen Sie eine Rolle aus"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Admin",
|
||||
@@ -1879,7 +1905,11 @@
|
||||
"language": "Language: {language}",
|
||||
"empty": "No transcript yet. You can generate one from this card’s menu after the video is uploaded.",
|
||||
"loading": "Loading transcript…",
|
||||
"open_side_panel": "Öffnen Sie das Transkriptfenster"
|
||||
"open_side_panel": "Öffnen Sie das Transkriptfenster",
|
||||
"edit": "Transkript bearbeiten",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"segment_label": "Segmentieren um {timestamp}"
|
||||
},
|
||||
"simple_card": {
|
||||
"kind_youtube": "YouTube",
|
||||
@@ -1889,7 +1919,13 @@
|
||||
"course_fallback": "Kurs",
|
||||
"menu_aria": "Videooptionen"
|
||||
},
|
||||
"view_transcript": "Transkription ansehen"
|
||||
"view_transcript": "Transkription ansehen",
|
||||
"playback_error": "Das Laden dieses Videos ist fehlgeschlagen.",
|
||||
"playback_reload": "Video neu laden",
|
||||
"generate_1080": "Generieren Sie 1080p",
|
||||
"generate_1080_in_progress": "Generiert 1080p…",
|
||||
"generate_1080_ready": "1080p verfügbar",
|
||||
"generate_1080_unavailable": "1080p ist für dieses Quellvideo nicht verfügbar"
|
||||
},
|
||||
"slide": {
|
||||
"title": "Rutsche",
|
||||
@@ -2085,7 +2121,8 @@
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Als Student anzeigen"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Zurück zum Kurs",
|
||||
@@ -2105,12 +2142,12 @@
|
||||
"nav_attendance": "Anwesenheit",
|
||||
"nav_submissions": "Einsendungen",
|
||||
"nav_marks": "Markierungen",
|
||||
"nav_compliance": "Compliance",
|
||||
"nav_people": "Menschen",
|
||||
"nav_analytics": "Analytik",
|
||||
"nav_certificates": "Zertifikate",
|
||||
"nav_landing_page": "Landingpage",
|
||||
"nav_settings": "Einstellungen",
|
||||
"nav_compliance": "Compliance",
|
||||
"nav_ai_assistant": "Assistent",
|
||||
"nav_ai_tutor": "KI-Tutor"
|
||||
},
|
||||
@@ -2202,6 +2239,12 @@
|
||||
"form_title": "Erzählen Sie uns von Ihrem Produkt"
|
||||
}
|
||||
}
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "Dadurch werden Sie zum Student LMS weitergeleitet.",
|
||||
"description": "Sie werden gleich sehen, was ein Student sehen wird, wenn er sich für diesen Kurs anmeldet",
|
||||
"copy_link": "Link kopieren",
|
||||
"go_to_lms": "Gehen Sie zu LMS"
|
||||
}
|
||||
},
|
||||
"courses": {
|
||||
@@ -3069,6 +3112,11 @@
|
||||
"field_nav_count_other": "{count} Links",
|
||||
"hero_empty": "—",
|
||||
"customize": "Landingpage anpassen"
|
||||
},
|
||||
"preview_tabs": {
|
||||
"label": "Vorschauseite",
|
||||
"home": "Startseite",
|
||||
"course": "Kurs"
|
||||
}
|
||||
},
|
||||
"organization": {
|
||||
@@ -3322,7 +3370,10 @@
|
||||
"tic_tac": "Tictac-Zehe",
|
||||
"see_more": "Mehr sehen",
|
||||
"whats_new": "Was ist neu?",
|
||||
"launch_week": "Startwoche"
|
||||
"launch_week": "Startwoche",
|
||||
"feedback": "Rückmeldung",
|
||||
"documentation": "Dokumentation",
|
||||
"need_help": "Brauchen Sie Hilfe?"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "Willkommen zurück",
|
||||
@@ -3567,6 +3618,18 @@
|
||||
"joined": "Der Organisation beigetreten",
|
||||
"join_failed": "Der Beitritt zur Organisation ist fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"page_title": "Treten Sie {orgName} auf ClassroomIO bei",
|
||||
"invitation_student": "Sie wurden eingeladen, sich {orgName} als Student anzuschließen.",
|
||||
"invitation_teacher": "Sie wurden eingeladen, sich {orgName} als Lehrer anzuschließen.",
|
||||
"invitation_admin": "Sie wurden eingeladen, {orgName} als Administrator beizutreten.",
|
||||
"invitation_fallback": "Sie wurden eingeladen, {orgName} als {role} beizutreten.",
|
||||
"accept_button": "Treten Sie der Organisation bei",
|
||||
"go_to_dashboard": "Gehen Sie zum Dashboard",
|
||||
"disabled": "Dieser Einladungslink wurde von der Organisation deaktiviert.",
|
||||
"join_failed": "Der Beitritt zur Organisation ist fehlgeschlagen",
|
||||
"joined": "Der Organisation beigetreten"
|
||||
}
|
||||
},
|
||||
"automation": {
|
||||
@@ -3886,7 +3949,7 @@
|
||||
"new_chat": "Neuer Chat",
|
||||
"tokens_label": "Token",
|
||||
"implementing_course_plan": "Kursplan umsetzen",
|
||||
"resume": "Lebenslauf",
|
||||
"resume": "Fortsetzen",
|
||||
"stopped_content_kept": "Angehalten. Erstellte Inhalte wurden beibehalten.",
|
||||
"mention_lesson": "Lektion",
|
||||
"mention_exercise": "Übung",
|
||||
@@ -4053,7 +4116,11 @@
|
||||
"title": "Arbeiter reagiert nicht",
|
||||
"body": "Dieser Lauf hat mehr als 30 Sekunden gewartet, ohne dass ein Arbeiter abgeholt wurde. Der Hintergrund-Worker ist möglicherweise nicht verfügbar oder wird neu gestartet."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ask_ai": "Fragen Sie die KI",
|
||||
"content_input_placeholder": "Fragen Sie nach diesem Inhalt…",
|
||||
"ask_ai_expand_aria": "Öffnen Sie „Ask AI Input“.",
|
||||
"ask_ai_input_aria": "Fragen Sie AI nach diesem Inhalt"
|
||||
},
|
||||
"widgets": {
|
||||
"heading": "Widgets",
|
||||
|
||||
@@ -386,7 +386,9 @@
|
||||
"tic_tac": "Tictac toe",
|
||||
"see_more": "See more",
|
||||
"whats_new": "What's new",
|
||||
"launch_week": "Launch week"
|
||||
"feedback": "Feedback",
|
||||
"documentation": "Documentation",
|
||||
"need_help": "Need help?"
|
||||
},
|
||||
"settings": {
|
||||
"heading": "Settings",
|
||||
@@ -670,6 +672,11 @@
|
||||
"preview": "Preview",
|
||||
"card_menu_label": "Theme options"
|
||||
},
|
||||
"preview_tabs": {
|
||||
"label": "Preview page",
|
||||
"home": "Home",
|
||||
"course": "Course"
|
||||
},
|
||||
"editor": {
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
@@ -986,8 +993,8 @@
|
||||
"hls_upload": {
|
||||
"stage": {
|
||||
"probing": "Reading video…",
|
||||
"encoding": "Encoding {rung} ({percent}%)",
|
||||
"uploading": "Uploading segments ({uploaded}/{total})",
|
||||
"encoding": "Encoding video…",
|
||||
"uploading": "Uploading…",
|
||||
"thumbnail": "Generating thumbnail…",
|
||||
"finalizing": "Saving…",
|
||||
"done": "Ready",
|
||||
@@ -2125,7 +2132,14 @@
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "View as student"
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "This will redirect you to the Student LMS.",
|
||||
"description": "You are about to see what a student will see when they enroll in this course",
|
||||
"copy_link": "Copy link",
|
||||
"go_to_lms": "Go to LMS"
|
||||
},
|
||||
"navItem": {
|
||||
"news_feed": {
|
||||
@@ -2336,6 +2350,12 @@
|
||||
"generate_transcript": "Generate transcription",
|
||||
"generate_transcript_in_progress": "Transcribing…",
|
||||
"view_transcript": "View transcription",
|
||||
"generate_1080": "Generate 1080p",
|
||||
"generate_1080_in_progress": "Generating 1080p…",
|
||||
"generate_1080_ready": "1080p available",
|
||||
"generate_1080_unavailable": "1080p unavailable for this source video",
|
||||
"playback_error": "Loading this video failed.",
|
||||
"playback_reload": "Reload video",
|
||||
"simple_card": {
|
||||
"kind_youtube": "YouTube",
|
||||
"kind_upload": "Upload",
|
||||
@@ -2350,7 +2370,11 @@
|
||||
"captions_label": "Captions",
|
||||
"language": "Language: {language}",
|
||||
"empty": "No transcript yet. You can generate one from this card’s menu after the video is uploaded.",
|
||||
"loading": "Loading transcript…"
|
||||
"loading": "Loading transcript…",
|
||||
"edit": "Edit transcript",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"segment_label": "Segment at {timestamp}"
|
||||
}
|
||||
},
|
||||
"document": {
|
||||
@@ -3195,7 +3219,17 @@
|
||||
"members": "Members",
|
||||
"invite_sent": "Invite Sent",
|
||||
"you": "You",
|
||||
"remove": "Remove"
|
||||
"remove": "Remove",
|
||||
"link_invite": {
|
||||
"heading": "Link Invite",
|
||||
"description": "Anyone with this link can join your organization.",
|
||||
"generate": "Generate Link",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"toggle_label": "Link invite active",
|
||||
"disabled_notice": "This link is disabled. Enable it to allow new members to join via link.",
|
||||
"select_role": "Select a role"
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificates": {
|
||||
@@ -3307,8 +3341,10 @@
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"short_video": "Short Video",
|
||||
"replace_cover": "Replace cover image",
|
||||
"replace_cover": "Cover image",
|
||||
"replace": "Replace",
|
||||
"remove": "Remove",
|
||||
"no_cover": "No cover image set",
|
||||
"helper": "Enter a link to a youtube video"
|
||||
},
|
||||
"reviews_form": {
|
||||
@@ -3915,6 +3951,9 @@
|
||||
"url_copied": "Course URL copied",
|
||||
"url_copy_failed": "Could not copy URL"
|
||||
},
|
||||
"view_as_student": {
|
||||
"failed": "Couldn't open the student view. Please try again."
|
||||
},
|
||||
"something": "Something went wrong - Please try later",
|
||||
"update": "Update",
|
||||
"update_failed": "Update failed",
|
||||
@@ -3940,7 +3979,13 @@
|
||||
"transcription_started": "Transcription started",
|
||||
"transcription_start_failed": "Could not start transcription",
|
||||
"transcription_completed": "Transcription finished",
|
||||
"transcript_fetch_failed": "Could not load transcript"
|
||||
"transcript_fetch_failed": "Could not load transcript",
|
||||
"transcript_saved": "Transcript updated",
|
||||
"transcript_save_failed": "Could not save transcript"
|
||||
},
|
||||
"lesson_video": {
|
||||
"generate_1080_completed": "1080p rendition added",
|
||||
"generate_1080_failed": "Could not generate 1080p rendition"
|
||||
},
|
||||
"poll": {
|
||||
"error": {
|
||||
@@ -4032,6 +4077,11 @@
|
||||
"remove_fail": "Failed to remove user, please try again",
|
||||
"remove_success": "Member removed successfully"
|
||||
},
|
||||
"link_invite": {
|
||||
"generated": "Link invite generated",
|
||||
"enabled": "Link invite enabled",
|
||||
"disabled": "Link invite disabled"
|
||||
},
|
||||
"tags": {
|
||||
"group_created": "Tag group created",
|
||||
"group_updated": "Tag group updated",
|
||||
@@ -4177,6 +4227,18 @@
|
||||
"joined": "Joined organization",
|
||||
"join_failed": "Failed to join organization"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"page_title": "Join {orgName} on ClassroomIO",
|
||||
"invitation_student": "You've been invited to join {orgName} as a student.",
|
||||
"invitation_teacher": "You've been invited to join {orgName} as a teacher.",
|
||||
"invitation_admin": "You've been invited to join {orgName} as an admin.",
|
||||
"invitation_fallback": "You've been invited to join {orgName} as {role}.",
|
||||
"accept_button": "Join organization",
|
||||
"go_to_dashboard": "Go to Dashboard",
|
||||
"disabled": "This invite link has been disabled by the organization.",
|
||||
"join_failed": "Failed to join organization",
|
||||
"joined": "Joined organization"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
@@ -4339,7 +4401,7 @@
|
||||
"run_completed": "Course generation complete",
|
||||
"run_failed": "Course generation failed",
|
||||
"run_canceled": "Course generation canceled",
|
||||
"run_waiting_for_worker": "Waiting for the background worker",
|
||||
"run_waiting_for_worker": "Getting things ready…",
|
||||
"run_checkpoint": "Saving progress checkpoint",
|
||||
"plan_implement": "Implement Plan",
|
||||
"plan_ask_changes_placeholder": "Tell me what to change about the plan…",
|
||||
@@ -4350,6 +4412,10 @@
|
||||
"resize_panel_aria_label": "Resize AI assistant panel",
|
||||
"generic_working": "Working",
|
||||
"input_placeholder": "Ask the AI assistant...",
|
||||
"ask_ai": "Ask AI",
|
||||
"content_input_placeholder": "Ask about this content…",
|
||||
"ask_ai_expand_aria": "Open Ask AI input",
|
||||
"ask_ai_input_aria": "Ask AI about this content",
|
||||
"quote_lesson_note": "Quote in assistant",
|
||||
"attach_document": "Attach a document (PDF, DOCX, PPTX)",
|
||||
"upgrade_to_upload": "Upgrade to upload documents",
|
||||
|
||||
@@ -350,7 +350,9 @@
|
||||
"thumbnail_updated": "Miniatura actualizada",
|
||||
"thumbnail_update_failed": "No se pudo actualizar la miniatura",
|
||||
"thumbnail_regen_started": "Regenerando miniaturas…",
|
||||
"thumbnail_regen_failed": "No se pudieron regenerar las miniaturas"
|
||||
"thumbnail_regen_failed": "No se pudieron regenerar las miniaturas",
|
||||
"transcript_saved": "Transcripción actualizada",
|
||||
"transcript_save_failed": "No se pudo guardar la transcripción"
|
||||
},
|
||||
"tags": {
|
||||
"group_created": "Grupo de etiquetas creado",
|
||||
@@ -420,6 +422,18 @@
|
||||
"workspace_is_primary": "No puede eliminar el espacio de trabajo principal.",
|
||||
"workspace_not_found": "Espacio de trabajo no encontrado.",
|
||||
"not_primary_workspace": "Cambie a su espacio de trabajo principal para administrar los espacios de trabajo de la cuenta."
|
||||
},
|
||||
"link_invite": {
|
||||
"generated": "Invitación de enlace generada",
|
||||
"enabled": "Invitación de enlace habilitada",
|
||||
"disabled": "Invitación a enlace inhabilitada"
|
||||
},
|
||||
"view_as_student": {
|
||||
"failed": "No se pudo abrir la vista de estudiantes. Por favor inténtalo de nuevo."
|
||||
},
|
||||
"lesson_video": {
|
||||
"generate_1080_completed": "Se agregó una reproducción de 1080p",
|
||||
"generate_1080_failed": "No se pudo generar una reproducción de 1080p"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -905,7 +919,9 @@
|
||||
"short_video": "Vídeo corto",
|
||||
"replace_cover": "Reemplazar imagen de portada",
|
||||
"replace": "Reemplazar",
|
||||
"helper": "Introduce un enlace a un vídeo de youtube."
|
||||
"helper": "Introduce un enlace a un vídeo de youtube.",
|
||||
"remove": "Quitar",
|
||||
"no_cover": "No hay imagen de portada establecida"
|
||||
},
|
||||
"title": {
|
||||
"header": "encabezado",
|
||||
@@ -1030,7 +1046,17 @@
|
||||
"members": "Miembros",
|
||||
"invite_sent": "Invitación enviada",
|
||||
"you": "tu",
|
||||
"remove": "Quitar"
|
||||
"remove": "Quitar",
|
||||
"link_invite": {
|
||||
"heading": "Invitación de enlace",
|
||||
"description": "Cualquiera que tenga este enlace puede unirse a su organización.",
|
||||
"generate": "Generar enlace",
|
||||
"copy": "Copiar",
|
||||
"copied": "¡Copiado!",
|
||||
"toggle_label": "Invitación de enlace activa",
|
||||
"disabled_notice": "Este enlace está deshabilitado. Habilítelo para permitir que nuevos miembros se unan a través de un enlace.",
|
||||
"select_role": "Seleccione un rol"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "administrador",
|
||||
@@ -1879,7 +1905,11 @@
|
||||
"language": "Language: {language}",
|
||||
"empty": "No transcript yet. You can generate one from this card’s menu after the video is uploaded.",
|
||||
"loading": "Loading transcript…",
|
||||
"open_side_panel": "Abrir panel de transcripción"
|
||||
"open_side_panel": "Abrir panel de transcripción",
|
||||
"edit": "Editar transcripción",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"segment_label": "Segmentar en {timestamp}"
|
||||
},
|
||||
"simple_card": {
|
||||
"kind_youtube": "youtube",
|
||||
@@ -1889,7 +1919,13 @@
|
||||
"course_fallback": "Curso",
|
||||
"menu_aria": "Opciones de vídeo"
|
||||
},
|
||||
"view_transcript": "Ver transcripción"
|
||||
"view_transcript": "Ver transcripción",
|
||||
"playback_error": "Error al cargar este vídeo.",
|
||||
"playback_reload": "Recargar vídeo",
|
||||
"generate_1080": "Generar 1080p",
|
||||
"generate_1080_in_progress": "Generando 1080p…",
|
||||
"generate_1080_ready": "1080p disponible",
|
||||
"generate_1080_unavailable": "1080p no disponible para este vídeo fuente"
|
||||
},
|
||||
"slide": {
|
||||
"title": "diapositiva",
|
||||
@@ -2085,7 +2121,8 @@
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Ver como estudiante"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "volver al curso",
|
||||
@@ -2105,12 +2142,12 @@
|
||||
"nav_attendance": "Asistencia",
|
||||
"nav_submissions": "Envíos",
|
||||
"nav_marks": "marcas",
|
||||
"nav_compliance": "Cumplimiento",
|
||||
"nav_people": "gente",
|
||||
"nav_analytics": "Analítica",
|
||||
"nav_certificates": "Certificados",
|
||||
"nav_landing_page": "Página de destino",
|
||||
"nav_settings": "Configuración",
|
||||
"nav_compliance": "Cumplimiento",
|
||||
"nav_ai_assistant": "Asistente",
|
||||
"nav_ai_tutor": "Tutor de IA"
|
||||
},
|
||||
@@ -2202,6 +2239,12 @@
|
||||
"form_title": "Cuéntanos sobre tu producto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "Esto lo redireccionará al Student LMS.",
|
||||
"description": "Estás a punto de ver lo que verá un estudiante cuando se inscriba en este curso.",
|
||||
"copy_link": "Copiar enlace",
|
||||
"go_to_lms": "Ir a LMS"
|
||||
}
|
||||
},
|
||||
"courses": {
|
||||
@@ -3069,6 +3112,11 @@
|
||||
"field_nav_count_other": "{count} enlaces",
|
||||
"hero_empty": "—",
|
||||
"customize": "Personalizar la página de destino"
|
||||
},
|
||||
"preview_tabs": {
|
||||
"label": "Página de vista previa",
|
||||
"home": "Inicio",
|
||||
"course": "Curso"
|
||||
}
|
||||
},
|
||||
"organization": {
|
||||
@@ -3322,7 +3370,10 @@
|
||||
"tic_tac": "dedo del pie tres en raya",
|
||||
"see_more": "Ver más",
|
||||
"whats_new": "¿Qué hay de nuevo?",
|
||||
"launch_week": "Semana de lanzamiento"
|
||||
"launch_week": "Semana de lanzamiento",
|
||||
"feedback": "Comentarios",
|
||||
"documentation": "Documentación",
|
||||
"need_help": "¿Necesitas ayuda?"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "Bienvenido de nuevo",
|
||||
@@ -3567,6 +3618,18 @@
|
||||
"joined": "Organización unida",
|
||||
"join_failed": "No se pudo unir a la organización"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"page_title": "Únase a {orgName} en ClassroomIO",
|
||||
"invitation_student": "Te invitaron a unirte a {orgName} como estudiante.",
|
||||
"invitation_teacher": "Te han invitado a unirte a {orgName} como profesor.",
|
||||
"invitation_admin": "Le invitaron a unirse a {orgName} como administrador.",
|
||||
"invitation_fallback": "Te invitaron a unirte a {orgName} como {role}.",
|
||||
"accept_button": "Unirse a la organización",
|
||||
"go_to_dashboard": "Ir al panel",
|
||||
"disabled": "Este enlace de invitación ha sido deshabilitado por la organización.",
|
||||
"join_failed": "No se pudo unir a la organización",
|
||||
"joined": "Organización unida"
|
||||
}
|
||||
},
|
||||
"automation": {
|
||||
@@ -3886,7 +3949,7 @@
|
||||
"new_chat": "Nuevo chat",
|
||||
"tokens_label": "fichas",
|
||||
"implementing_course_plan": "Implementación del plan del curso",
|
||||
"resume": "Currículum",
|
||||
"resume": "Reanudar",
|
||||
"stopped_content_kept": "Detenido. El contenido creado se ha mantenido.",
|
||||
"mention_lesson": "Lección",
|
||||
"mention_exercise": "ejercicio",
|
||||
@@ -3995,7 +4058,7 @@
|
||||
"context_compacted_badge": "Conversación compactada ({count} mensajes anteriores)",
|
||||
"student_quick_action": {
|
||||
"explain_lesson": "Explica esta lección",
|
||||
"quiz_me": "Pregúntame sobre esto",
|
||||
"quiz_me": "Ponme a prueba",
|
||||
"summarize_section": "Resumir esta sección",
|
||||
"what_to_review": "¿Qué debo revisar a continuación?"
|
||||
},
|
||||
@@ -4053,7 +4116,11 @@
|
||||
"title": "trabajador no responde",
|
||||
"body": "Esta ejecución ha estado esperando más de 30 segundos sin que ningún trabajador haya sido recogido. Es posible que el trabajador en segundo plano esté inactivo o reiniciándose."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ask_ai": "Pregúntale a la IA",
|
||||
"content_input_placeholder": "Pregunta por este contenido...",
|
||||
"ask_ai_expand_aria": "Abrir Preguntar entrada a AI",
|
||||
"ask_ai_input_aria": "Pregúntale a AI sobre este contenido"
|
||||
},
|
||||
"widgets": {
|
||||
"heading": "widgets",
|
||||
|
||||
@@ -350,7 +350,9 @@
|
||||
"thumbnail_updated": "Miniature mise à jour",
|
||||
"thumbnail_update_failed": "Échec de la mise à jour de la vignette",
|
||||
"thumbnail_regen_started": "Régénération des vignettes…",
|
||||
"thumbnail_regen_failed": "Échec de la régénération des miniatures"
|
||||
"thumbnail_regen_failed": "Échec de la régénération des miniatures",
|
||||
"transcript_saved": "Transcription mise à jour",
|
||||
"transcript_save_failed": "Impossible d'enregistrer la transcription"
|
||||
},
|
||||
"tags": {
|
||||
"group_created": "Groupe de balises créé",
|
||||
@@ -420,6 +422,18 @@
|
||||
"workspace_is_primary": "Vous ne pouvez pas supprimer l'espace de travail principal.",
|
||||
"workspace_not_found": "Espace de travail introuvable.",
|
||||
"not_primary_workspace": "Basculez vers votre espace de travail principal pour gérer les espaces de travail de compte."
|
||||
},
|
||||
"link_invite": {
|
||||
"generated": "Invitation de lien générée",
|
||||
"enabled": "Invitation de lien activée",
|
||||
"disabled": "Invitation au lien désactivée"
|
||||
},
|
||||
"view_as_student": {
|
||||
"failed": "Impossible d'ouvrir la vue étudiant. Veuillez réessayer."
|
||||
},
|
||||
"lesson_video": {
|
||||
"generate_1080_completed": "Rendu 1080p ajouté",
|
||||
"generate_1080_failed": "Impossible de générer un rendu 1080p"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -905,7 +919,9 @@
|
||||
"short_video": "Courte vidéo",
|
||||
"replace_cover": "Remplacer l'image de couverture",
|
||||
"replace": "Remplacer",
|
||||
"helper": "Entrez un lien vers une vidéo YouTube"
|
||||
"helper": "Entrez un lien vers une vidéo YouTube",
|
||||
"remove": "Supprimer",
|
||||
"no_cover": "Aucune image de couverture définie"
|
||||
},
|
||||
"title": {
|
||||
"header": "En-tête",
|
||||
@@ -1030,7 +1046,17 @@
|
||||
"members": "Membres",
|
||||
"invite_sent": "Invitation envoyée",
|
||||
"you": "Vous",
|
||||
"remove": "Supprimer"
|
||||
"remove": "Supprimer",
|
||||
"link_invite": {
|
||||
"heading": "Inviter un lien",
|
||||
"description": "Toute personne disposant de ce lien peut rejoindre votre organisation.",
|
||||
"generate": "Générer un lien",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié!",
|
||||
"toggle_label": "Invitation de lien active",
|
||||
"disabled_notice": "Ce lien est désactivé. Activez-le pour permettre aux nouveaux membres de rejoindre via un lien.",
|
||||
"select_role": "Sélectionnez un rôle"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Administrateur",
|
||||
@@ -1879,7 +1905,11 @@
|
||||
"language": "Language: {language}",
|
||||
"empty": "No transcript yet. You can generate one from this card’s menu after the video is uploaded.",
|
||||
"loading": "Loading transcript…",
|
||||
"open_side_panel": "Ouvrir le panneau de transcription"
|
||||
"open_side_panel": "Ouvrir le panneau de transcription",
|
||||
"edit": "Modifier la transcription",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"segment_label": "Segment à {timestamp}"
|
||||
},
|
||||
"simple_card": {
|
||||
"kind_youtube": "YouTube",
|
||||
@@ -1889,7 +1919,13 @@
|
||||
"course_fallback": "Cours",
|
||||
"menu_aria": "Options vidéo"
|
||||
},
|
||||
"view_transcript": "Voir la transcription"
|
||||
"view_transcript": "Voir la transcription",
|
||||
"playback_error": "Le chargement de cette vidéo a échoué.",
|
||||
"playback_reload": "Recharger la vidéo",
|
||||
"generate_1080": "Générer 1080p",
|
||||
"generate_1080_in_progress": "Génération de 1080p…",
|
||||
"generate_1080_ready": "1080p disponible",
|
||||
"generate_1080_unavailable": "1080p indisponible pour cette vidéo source"
|
||||
},
|
||||
"slide": {
|
||||
"title": "Diapositive",
|
||||
@@ -2085,7 +2121,8 @@
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Afficher en tant qu'étudiant"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Retour au cours",
|
||||
@@ -2105,12 +2142,12 @@
|
||||
"nav_attendance": "Participation",
|
||||
"nav_submissions": "Soumissions",
|
||||
"nav_marks": "Marques",
|
||||
"nav_compliance": "Conformité",
|
||||
"nav_people": "Les gens",
|
||||
"nav_analytics": "Analyse",
|
||||
"nav_certificates": "Certificats",
|
||||
"nav_landing_page": "Page de destination",
|
||||
"nav_settings": "Paramètres",
|
||||
"nav_compliance": "Conformité",
|
||||
"nav_ai_assistant": "Assistante",
|
||||
"nav_ai_tutor": "Tuteur IA"
|
||||
},
|
||||
@@ -2202,6 +2239,12 @@
|
||||
"form_title": "Parlez-nous de votre produit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "Cela vous redirigera vers le LMS Étudiant.",
|
||||
"description": "Vous êtes sur le point de voir ce qu'un étudiant verra lorsqu'il s'inscrira à ce cours",
|
||||
"copy_link": "Copier le lien",
|
||||
"go_to_lms": "Aller au LMS"
|
||||
}
|
||||
},
|
||||
"courses": {
|
||||
@@ -3069,6 +3112,11 @@
|
||||
"field_nav_count_other": "{count} liens",
|
||||
"hero_empty": "—",
|
||||
"customize": "Personnaliser la page de destination"
|
||||
},
|
||||
"preview_tabs": {
|
||||
"label": "Page d'aperçu",
|
||||
"home": "Accueil",
|
||||
"course": "Cours"
|
||||
}
|
||||
},
|
||||
"organization": {
|
||||
@@ -3322,7 +3370,10 @@
|
||||
"tic_tac": "Tic-tac",
|
||||
"see_more": "Voir plus",
|
||||
"whats_new": "Quoi de neuf",
|
||||
"launch_week": "Semaine de lancement"
|
||||
"launch_week": "Semaine de lancement",
|
||||
"feedback": "Commentaires",
|
||||
"documentation": "Documentation",
|
||||
"need_help": "Besoin d'aide ?"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "Bon retour",
|
||||
@@ -3567,6 +3618,18 @@
|
||||
"joined": "Organisation rejointe",
|
||||
"join_failed": "Impossible de rejoindre l'organisation"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"page_title": "Rejoignez {orgName} sur ClassroomIO",
|
||||
"invitation_student": "Vous avez été invité à rejoindre {orgName} en tant qu'étudiant.",
|
||||
"invitation_teacher": "Vous avez été invité à rejoindre {orgName} en tant qu'enseignant.",
|
||||
"invitation_admin": "Vous avez été invité à rejoindre {orgName} en tant qu'administrateur.",
|
||||
"invitation_fallback": "Vous avez été invité à rejoindre {orgName} en tant que {role}.",
|
||||
"accept_button": "Rejoindre l'organisation",
|
||||
"go_to_dashboard": "Aller au tableau de bord",
|
||||
"disabled": "Ce lien d'invitation a été désactivé par l'organisation.",
|
||||
"join_failed": "Impossible de rejoindre l'organisation",
|
||||
"joined": "Organisation rejointe"
|
||||
}
|
||||
},
|
||||
"automation": {
|
||||
@@ -3886,7 +3949,7 @@
|
||||
"new_chat": "Nouvelle discussion",
|
||||
"tokens_label": "jetons",
|
||||
"implementing_course_plan": "Mise en œuvre du plan de cours",
|
||||
"resume": "CV",
|
||||
"resume": "Reprendre",
|
||||
"stopped_content_kept": "Arrêté. Le contenu créé a été conservé.",
|
||||
"mention_lesson": "Leçon",
|
||||
"mention_exercise": "Exercice",
|
||||
@@ -4053,7 +4116,11 @@
|
||||
"title": "Le travailleur ne répond pas",
|
||||
"body": "Cette exécution attend depuis plus de 30 secondes sans qu'aucun travailleur ne soit récupéré. Le travailleur en arrière-plan est peut-être en panne ou redémarre."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ask_ai": "Demandez à l'IA",
|
||||
"content_input_placeholder": "Renseignez-vous sur ce contenu…",
|
||||
"ask_ai_expand_aria": "Ouvrir la saisie de Ask AI",
|
||||
"ask_ai_input_aria": "Interrogez AI à propos de ce contenu"
|
||||
},
|
||||
"widgets": {
|
||||
"heading": "Widgets",
|
||||
|
||||
@@ -350,7 +350,9 @@
|
||||
"thumbnail_updated": "थंबनेल अपडेट किया गया",
|
||||
"thumbnail_update_failed": "थंबनेल अपडेट करने में विफल",
|
||||
"thumbnail_regen_started": "थंबनेल पुनर्जीवित हो रहे हैं…",
|
||||
"thumbnail_regen_failed": "थंबनेल पुन: उत्पन्न करने में विफल"
|
||||
"thumbnail_regen_failed": "थंबनेल पुन: उत्पन्न करने में विफल",
|
||||
"transcript_saved": "प्रतिलेख अद्यतन किया गया",
|
||||
"transcript_save_failed": "प्रतिलेख सहेजा नहीं जा सका"
|
||||
},
|
||||
"tags": {
|
||||
"group_created": "टैग समूह बनाया गया",
|
||||
@@ -420,6 +422,18 @@
|
||||
"workspace_is_primary": "आप प्राथमिक कार्यस्थान को हटा नहीं सकते.",
|
||||
"workspace_not_found": "कार्यस्थल नहीं मिला.",
|
||||
"not_primary_workspace": "खाता कार्यस्थान प्रबंधित करने के लिए अपने प्राथमिक कार्यस्थान पर स्विच करें।"
|
||||
},
|
||||
"link_invite": {
|
||||
"generated": "लिंक आमंत्रण जनरेट हुआ",
|
||||
"enabled": "लिंक आमंत्रण सक्षम किया गया",
|
||||
"disabled": "लिंक आमंत्रण अक्षम किया गया"
|
||||
},
|
||||
"view_as_student": {
|
||||
"failed": "विद्यार्थी दृश्य नहीं खुल सका. कृपया पुन: प्रयास करें।"
|
||||
},
|
||||
"lesson_video": {
|
||||
"generate_1080_completed": "1080p प्रस्तुतिकरण जोड़ा गया",
|
||||
"generate_1080_failed": "1080p प्रस्तुतिकरण उत्पन्न नहीं हो सका"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -905,7 +919,9 @@
|
||||
"short_video": "लघु वीडियो",
|
||||
"replace_cover": "कवर छवि बदलें",
|
||||
"replace": "बदलें",
|
||||
"helper": "किसी यूट्यूब वीडियो का लिंक दर्ज करें"
|
||||
"helper": "किसी यूट्यूब वीडियो का लिंक दर्ज करें",
|
||||
"remove": "हटाओ",
|
||||
"no_cover": "कोई कवर छवि सेट नहीं"
|
||||
},
|
||||
"title": {
|
||||
"header": "शीर्ष लेख",
|
||||
@@ -1030,7 +1046,17 @@
|
||||
"members": "सदस्य",
|
||||
"invite_sent": "आमंत्रण भेजा गया",
|
||||
"you": "आप",
|
||||
"remove": "हटाओ"
|
||||
"remove": "हटाओ",
|
||||
"link_invite": {
|
||||
"heading": "लिंक आमंत्रण",
|
||||
"description": "इस लिंक वाला कोई भी व्यक्ति आपके संगठन में शामिल हो सकता है।",
|
||||
"generate": "लिंक जनरेट करें",
|
||||
"copy": "प्रतिलिपि",
|
||||
"copied": "नकल की गई!",
|
||||
"toggle_label": "लिंक आमंत्रण सक्रिय",
|
||||
"disabled_notice": "यह लिंक अक्षम है. नए सदस्यों को लिंक के माध्यम से जुड़ने की अनुमति देने के लिए इसे सक्षम करें।",
|
||||
"select_role": "एक भूमिका चुनें"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "व्यवस्थापक",
|
||||
@@ -1879,7 +1905,11 @@
|
||||
"language": "Language: {language}",
|
||||
"empty": "No transcript yet. You can generate one from this card’s menu after the video is uploaded.",
|
||||
"loading": "Loading transcript…",
|
||||
"open_side_panel": "ट्रांस्क्रिप्ट पैनल खोलें"
|
||||
"open_side_panel": "ट्रांस्क्रिप्ट पैनल खोलें",
|
||||
"edit": "प्रतिलेख संपादित करें",
|
||||
"save": "सहेजें",
|
||||
"cancel": "रद्द करें",
|
||||
"segment_label": "{timestamp} पर खंड"
|
||||
},
|
||||
"simple_card": {
|
||||
"kind_youtube": "यूट्यूब",
|
||||
@@ -1889,7 +1919,13 @@
|
||||
"course_fallback": "पाठ्यक्रम",
|
||||
"menu_aria": "वीडियो विकल्प"
|
||||
},
|
||||
"view_transcript": "प्रतिलेखन देखें"
|
||||
"view_transcript": "प्रतिलेखन देखें",
|
||||
"playback_error": "इस वीडियो को लोड करना विफल रहा.",
|
||||
"playback_reload": "वीडियो पुनः लोड करें",
|
||||
"generate_1080": "1080p जनरेट करें",
|
||||
"generate_1080_in_progress": "1080p जेनरेट हो रहा है...",
|
||||
"generate_1080_ready": "1080p उपलब्ध",
|
||||
"generate_1080_unavailable": "इस स्रोत वीडियो के लिए 1080p अनुपलब्ध है"
|
||||
},
|
||||
"slide": {
|
||||
"title": "फिसल पट्टी",
|
||||
@@ -2085,7 +2121,8 @@
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "विद्यार्थी के रूप में देखें"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
|
||||
@@ -2105,12 +2142,12 @@
|
||||
"nav_attendance": "उपस्थिति",
|
||||
"nav_submissions": "प्रस्तुतियाँ",
|
||||
"nav_marks": "निशान",
|
||||
"nav_compliance": "अनुपालन",
|
||||
"nav_people": "लोग",
|
||||
"nav_analytics": "विश्लेषिकी",
|
||||
"nav_certificates": "प्रमाणपत्र",
|
||||
"nav_landing_page": "लैंडिंग पृष्ठ",
|
||||
"nav_settings": "सेटिंग्स",
|
||||
"nav_compliance": "अनुपालन",
|
||||
"nav_ai_assistant": "सहायक",
|
||||
"nav_ai_tutor": "एआई ट्यूटर"
|
||||
},
|
||||
@@ -2202,6 +2239,12 @@
|
||||
"form_title": "हमें अपने उत्पाद के बारे में बताएं"
|
||||
}
|
||||
}
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "यह आपको स्टूडेंट एलएमएस पर रीडायरेक्ट करेगा।",
|
||||
"description": "आप यह देखने वाले हैं कि इस पाठ्यक्रम में दाखिला लेने पर एक छात्र क्या देखेगा",
|
||||
"copy_link": "लिंक कॉपी करें",
|
||||
"go_to_lms": "एलएमएस पर जाएं"
|
||||
}
|
||||
},
|
||||
"courses": {
|
||||
@@ -3069,6 +3112,11 @@
|
||||
"field_nav_count_other": "{count} लिंक",
|
||||
"hero_empty": "—",
|
||||
"customize": "लैंडिंग पृष्ठ अनुकूलित करें"
|
||||
},
|
||||
"preview_tabs": {
|
||||
"label": "पूर्वावलोकन पृष्ठ",
|
||||
"home": "घर",
|
||||
"course": "पाठ्यक्रम"
|
||||
}
|
||||
},
|
||||
"organization": {
|
||||
@@ -3322,7 +3370,10 @@
|
||||
"tic_tac": "टिकटैक पैर की अंगुली",
|
||||
"see_more": "और देखें",
|
||||
"whats_new": "नया क्या है",
|
||||
"launch_week": "लॉन्च सप्ताह"
|
||||
"launch_week": "लॉन्च सप्ताह",
|
||||
"feedback": "प्रतिक्रिया",
|
||||
"documentation": "दस्तावेज़ीकरण",
|
||||
"need_help": "मदद चाहिए?"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "पुनः स्वागत है",
|
||||
@@ -3567,6 +3618,18 @@
|
||||
"joined": "संगठन से जुड़े",
|
||||
"join_failed": "संगठन में शामिल होने में विफल"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"page_title": "ClassroomIO पर {orgName} से जुड़ें",
|
||||
"invitation_student": "आपको एक छात्र के रूप में {orgName} में शामिल होने के लिए आमंत्रित किया गया है।",
|
||||
"invitation_teacher": "आपको एक शिक्षक के रूप में {orgName} में शामिल होने के लिए आमंत्रित किया गया है।",
|
||||
"invitation_admin": "आपको एक व्यवस्थापक के रूप में {orgName} से जुड़ने के लिए आमंत्रित किया गया है।",
|
||||
"invitation_fallback": "आपको {orgName} में {role} के रूप में शामिल होने के लिए आमंत्रित किया गया है।",
|
||||
"accept_button": "संगठन से जुड़ें",
|
||||
"go_to_dashboard": "डैशबोर्ड पर जाएँ",
|
||||
"disabled": "यह आमंत्रण लिंक संगठन द्वारा अक्षम कर दिया गया है.",
|
||||
"join_failed": "संगठन में शामिल होने में विफल",
|
||||
"joined": "संगठन से जुड़े"
|
||||
}
|
||||
},
|
||||
"automation": {
|
||||
@@ -3886,10 +3949,10 @@
|
||||
"new_chat": "नई चैट",
|
||||
"tokens_label": "टोकन",
|
||||
"implementing_course_plan": "पाठ्यक्रम योजना का कार्यान्वयन",
|
||||
"resume": "बायोडाटा",
|
||||
"resume": "जारी रखें",
|
||||
"stopped_content_kept": "रुक गया. निर्मित सामग्री रखी गई है.",
|
||||
"mention_lesson": "पाठ",
|
||||
"mention_exercise": "व्यायाम",
|
||||
"mention_exercise": "अभ्यास",
|
||||
"mention_section": "धारा",
|
||||
"mention_no_results": "कोई मेल खाती सामग्री नहीं",
|
||||
"uploading_document": "दस्तावेज़ अपलोड हो रहा है...",
|
||||
@@ -4053,7 +4116,11 @@
|
||||
"title": "कर्मी जवाब नहीं दे रहा है",
|
||||
"body": "इस रन के लिए 30 सेकंड से अधिक समय से प्रतीक्षा की जा रही है और कोई कर्मचारी पिकअप नहीं कर रहा है। बैकग्राउंड वर्कर डाउन या रीस्टार्ट हो सकता है।"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ask_ai": "एआई से पूछें",
|
||||
"content_input_placeholder": "इस सामग्री के बारे में पूछें...",
|
||||
"ask_ai_expand_aria": "एआई इनपुट पूछें खोलें",
|
||||
"ask_ai_input_aria": "इस सामग्री के बारे में AI से पूछें"
|
||||
},
|
||||
"widgets": {
|
||||
"heading": "विजेट्स",
|
||||
|
||||
@@ -350,7 +350,9 @@
|
||||
"thumbnail_updated": "Miniatura zaktualizowana",
|
||||
"thumbnail_update_failed": "Nie udało się zaktualizować miniatury",
|
||||
"thumbnail_regen_started": "Regeneruję miniatury…",
|
||||
"thumbnail_regen_failed": "Nie udało się ponownie wygenerować miniatur"
|
||||
"thumbnail_regen_failed": "Nie udało się ponownie wygenerować miniatur",
|
||||
"transcript_saved": "Transkrypcja zaktualizowana",
|
||||
"transcript_save_failed": "Nie udało się zapisać transkrypcji"
|
||||
},
|
||||
"tags": {
|
||||
"group_created": "Utworzono grupę tagów",
|
||||
@@ -420,6 +422,18 @@
|
||||
"workspace_is_primary": "Nie można usunąć podstawowego obszaru roboczego.",
|
||||
"workspace_not_found": "Nie znaleziono obszaru roboczego.",
|
||||
"not_primary_workspace": "Przejdź do podstawowego obszaru roboczego, aby zarządzać obszarami roboczymi konta."
|
||||
},
|
||||
"link_invite": {
|
||||
"generated": "Wygenerowano zaproszenie do linku",
|
||||
"enabled": "Zaproszenie do połączenia włączone",
|
||||
"disabled": "Zaproszenie do połączenia wyłączone"
|
||||
},
|
||||
"view_as_student": {
|
||||
"failed": "Nie można otworzyć widoku ucznia. Spróbuj ponownie."
|
||||
},
|
||||
"lesson_video": {
|
||||
"generate_1080_completed": "Dodano wersję 1080p",
|
||||
"generate_1080_failed": "Nie można wygenerować wersji 1080p"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -905,7 +919,9 @@
|
||||
"short_video": "Krótki film",
|
||||
"replace_cover": "Zamień zdjęcie na okładce",
|
||||
"replace": "Wymień",
|
||||
"helper": "Wprowadź link do filmu na YouTube"
|
||||
"helper": "Wprowadź link do filmu na YouTube",
|
||||
"remove": "Usuń",
|
||||
"no_cover": "Nie ustawiono obrazu na okładce"
|
||||
},
|
||||
"title": {
|
||||
"header": "Nagłówek",
|
||||
@@ -1030,7 +1046,17 @@
|
||||
"members": "Członkowie",
|
||||
"invite_sent": "Zaproszenie wysłane",
|
||||
"you": "Ty",
|
||||
"remove": "Usuń"
|
||||
"remove": "Usuń",
|
||||
"link_invite": {
|
||||
"heading": "Zaproszenie do linku",
|
||||
"description": "Każda osoba mająca ten link może dołączyć do Twojej organizacji.",
|
||||
"generate": "Wygeneruj łącze",
|
||||
"copy": "Kopiuj",
|
||||
"copied": "Skopiowano!",
|
||||
"toggle_label": "Zaproszenie do linku aktywne",
|
||||
"disabled_notice": "To łącze jest wyłączone. Włącz tę opcję, aby umożliwić nowym członkom dołączanie za pośrednictwem łącza.",
|
||||
"select_role": "Wybierz rolę"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Administrator",
|
||||
@@ -1879,7 +1905,11 @@
|
||||
"language": "Language: {language}",
|
||||
"empty": "No transcript yet. You can generate one from this card’s menu after the video is uploaded.",
|
||||
"loading": "Loading transcript…",
|
||||
"open_side_panel": "Otwórz panel transkrypcji"
|
||||
"open_side_panel": "Otwórz panel transkrypcji",
|
||||
"edit": "Edytuj transkrypcję",
|
||||
"save": "Zapisz",
|
||||
"cancel": "Anuluj",
|
||||
"segment_label": "Segment o {timestamp}"
|
||||
},
|
||||
"simple_card": {
|
||||
"kind_youtube": "YouTube",
|
||||
@@ -1889,7 +1919,13 @@
|
||||
"course_fallback": "Kurs",
|
||||
"menu_aria": "Opcje wideo"
|
||||
},
|
||||
"view_transcript": "Zobacz transkrypcję"
|
||||
"view_transcript": "Zobacz transkrypcję",
|
||||
"playback_error": "Ładowanie tego filmu nie powiodło się.",
|
||||
"playback_reload": "Załaduj ponownie wideo",
|
||||
"generate_1080": "Wygeneruj 1080p",
|
||||
"generate_1080_in_progress": "Generowanie 1080p…",
|
||||
"generate_1080_ready": "Dostępna rozdzielczość 1080p",
|
||||
"generate_1080_unavailable": "Rozdzielczość 1080p jest niedostępna dla tego wideo źródłowego"
|
||||
},
|
||||
"slide": {
|
||||
"title": "Przesuń",
|
||||
@@ -2085,7 +2121,8 @@
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Wyświetl jako student"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Powrót na kurs",
|
||||
@@ -2105,12 +2142,12 @@
|
||||
"nav_attendance": "Obecność",
|
||||
"nav_submissions": "Zgłoszenia",
|
||||
"nav_marks": "Znaki",
|
||||
"nav_compliance": "Zgodność",
|
||||
"nav_people": "Ludzie",
|
||||
"nav_analytics": "Analityka",
|
||||
"nav_certificates": "Certyfikaty",
|
||||
"nav_landing_page": "Strona docelowa",
|
||||
"nav_settings": "Ustawienia",
|
||||
"nav_compliance": "Zgodność",
|
||||
"nav_ai_assistant": "Asystent",
|
||||
"nav_ai_tutor": "Nauczyciel AI"
|
||||
},
|
||||
@@ -2202,6 +2239,12 @@
|
||||
"form_title": "Opowiedz nam o swoim produkcie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "Spowoduje to przekierowanie do Student LMS.",
|
||||
"description": "Zaraz zobaczysz, co zobaczy student, który zapisze się na ten kurs",
|
||||
"copy_link": "Skopiuj link",
|
||||
"go_to_lms": "Przejdź do LMS-a"
|
||||
}
|
||||
},
|
||||
"courses": {
|
||||
@@ -3069,6 +3112,11 @@
|
||||
"field_nav_count_other": "{count} linków",
|
||||
"hero_empty": "—",
|
||||
"customize": "Dostosuj stronę docelową"
|
||||
},
|
||||
"preview_tabs": {
|
||||
"label": "Strona podglądu",
|
||||
"home": "Dom",
|
||||
"course": "Kurs"
|
||||
}
|
||||
},
|
||||
"organization": {
|
||||
@@ -3322,7 +3370,10 @@
|
||||
"tic_tac": "Palec kleszczowy",
|
||||
"see_more": "Zobacz więcej",
|
||||
"whats_new": "Co nowego",
|
||||
"launch_week": "Tydzień uruchomienia"
|
||||
"launch_week": "Tydzień uruchomienia",
|
||||
"feedback": "Informacje zwrotne",
|
||||
"documentation": "Dokumentacja",
|
||||
"need_help": "Potrzebujesz pomocy?"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "Witamy z powrotem",
|
||||
@@ -3567,6 +3618,18 @@
|
||||
"joined": "Dołączył do organizacji",
|
||||
"join_failed": "Nie udało się dołączyć do organizacji"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"page_title": "Dołącz do organizacji {orgName} w ClassroomIO",
|
||||
"invitation_student": "Zaproszono Cię do dołączenia do organizacji {orgName} jako student.",
|
||||
"invitation_teacher": "Zaproszono Cię do dołączenia do organizacji {orgName} jako nauczyciel.",
|
||||
"invitation_admin": "Zostałeś zaproszony do dołączenia do organizacji {orgName} jako administrator.",
|
||||
"invitation_fallback": "Zostałeś zaproszony do dołączenia do organizacji {orgName} jako {role}.",
|
||||
"accept_button": "Dołącz do organizacji",
|
||||
"go_to_dashboard": "Przejdź do Panelu",
|
||||
"disabled": "Ten link z zaproszeniem został wyłączony przez organizację.",
|
||||
"join_failed": "Nie udało się dołączyć do organizacji",
|
||||
"joined": "Dołączył do organizacji"
|
||||
}
|
||||
},
|
||||
"automation": {
|
||||
@@ -4053,7 +4116,11 @@
|
||||
"title": "Pracownik nie odpowiada",
|
||||
"body": "Ten przebieg czekał ponad 30 sekund bez odbioru pracownika. Proces roboczy działający w tle może być wyłączony lub uruchamiany ponownie."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ask_ai": "Zapytaj AI",
|
||||
"content_input_placeholder": "Zapytaj o tę treść…",
|
||||
"ask_ai_expand_aria": "Otwórz wejście Zapytaj AI",
|
||||
"ask_ai_input_aria": "Zapytaj AI o tę treść"
|
||||
},
|
||||
"widgets": {
|
||||
"heading": "Widżety",
|
||||
|
||||
@@ -350,7 +350,9 @@
|
||||
"thumbnail_updated": "Miniatura atualizada",
|
||||
"thumbnail_update_failed": "Falha ao atualizar a miniatura",
|
||||
"thumbnail_regen_started": "Regenerando miniaturas…",
|
||||
"thumbnail_regen_failed": "Falha ao regenerar miniaturas"
|
||||
"thumbnail_regen_failed": "Falha ao regenerar miniaturas",
|
||||
"transcript_saved": "Transcrição atualizada",
|
||||
"transcript_save_failed": "Não foi possível salvar a transcrição"
|
||||
},
|
||||
"tags": {
|
||||
"group_created": "Grupo de tags criado",
|
||||
@@ -420,6 +422,18 @@
|
||||
"workspace_is_primary": "Você não pode excluir o espaço de trabalho principal.",
|
||||
"workspace_not_found": "Espaço de trabalho não encontrado.",
|
||||
"not_primary_workspace": "Mude para seu espaço de trabalho principal para gerenciar os espaços de trabalho da conta."
|
||||
},
|
||||
"link_invite": {
|
||||
"generated": "Convite de link gerado",
|
||||
"enabled": "Convite de link ativado",
|
||||
"disabled": "Convite de link desativado"
|
||||
},
|
||||
"view_as_student": {
|
||||
"failed": "Não foi possível abrir a visualização do aluno. Por favor, tente novamente."
|
||||
},
|
||||
"lesson_video": {
|
||||
"generate_1080_completed": "Versão 1080p adicionada",
|
||||
"generate_1080_failed": "Não foi possível gerar a renderização em 1080p"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -905,7 +919,9 @@
|
||||
"short_video": "Vídeo curto",
|
||||
"replace_cover": "Substituir imagem da capa",
|
||||
"replace": "Substituir",
|
||||
"helper": "Insira um link para um vídeo do YouTube"
|
||||
"helper": "Insira um link para um vídeo do YouTube",
|
||||
"remove": "Remover",
|
||||
"no_cover": "Nenhuma imagem de capa definida"
|
||||
},
|
||||
"title": {
|
||||
"header": "Cabeçalho",
|
||||
@@ -1030,7 +1046,17 @@
|
||||
"members": "Membros",
|
||||
"invite_sent": "Convite enviado",
|
||||
"you": "Você",
|
||||
"remove": "Remover"
|
||||
"remove": "Remover",
|
||||
"link_invite": {
|
||||
"heading": "Convite de link",
|
||||
"description": "Qualquer pessoa com este link pode ingressar na sua organização.",
|
||||
"generate": "Gerar link",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado!",
|
||||
"toggle_label": "Link de convite ativo",
|
||||
"disabled_notice": "Este link está desativado. Habilite-o para permitir que novos membros participem via link.",
|
||||
"select_role": "Selecione uma função"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Administrador",
|
||||
@@ -1879,7 +1905,11 @@
|
||||
"language": "Language: {language}",
|
||||
"empty": "No transcript yet. You can generate one from this card’s menu after the video is uploaded.",
|
||||
"loading": "Loading transcript…",
|
||||
"open_side_panel": "Abrir painel de transcrição"
|
||||
"open_side_panel": "Abrir painel de transcrição",
|
||||
"edit": "Editar transcrição",
|
||||
"save": "Salvar",
|
||||
"cancel": "Cancelar",
|
||||
"segment_label": "Segmentar em {timestamp}"
|
||||
},
|
||||
"simple_card": {
|
||||
"kind_youtube": "YouTube",
|
||||
@@ -1889,7 +1919,13 @@
|
||||
"course_fallback": "Curso",
|
||||
"menu_aria": "Opções de vídeo"
|
||||
},
|
||||
"view_transcript": "Ver transcrição"
|
||||
"view_transcript": "Ver transcrição",
|
||||
"playback_error": "Falha ao carregar este vídeo.",
|
||||
"playback_reload": "Recarregar vídeo",
|
||||
"generate_1080": "Gerar 1080p",
|
||||
"generate_1080_in_progress": "Gerando 1080p…",
|
||||
"generate_1080_ready": "1080p disponível",
|
||||
"generate_1080_unavailable": "1080p indisponível para este vídeo de origem"
|
||||
},
|
||||
"slide": {
|
||||
"title": "Deslizar",
|
||||
@@ -2085,7 +2121,8 @@
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Ver como aluno"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "De volta ao curso",
|
||||
@@ -2105,12 +2142,12 @@
|
||||
"nav_attendance": "Presença",
|
||||
"nav_submissions": "Envios",
|
||||
"nav_marks": "Marcas",
|
||||
"nav_compliance": "Conformidade",
|
||||
"nav_people": "Pessoas",
|
||||
"nav_analytics": "Análise",
|
||||
"nav_certificates": "Certificados",
|
||||
"nav_landing_page": "Página inicial",
|
||||
"nav_settings": "Configurações",
|
||||
"nav_compliance": "Conformidade",
|
||||
"nav_ai_assistant": "Assistente",
|
||||
"nav_ai_tutor": "Tutor de IA"
|
||||
},
|
||||
@@ -2202,6 +2239,12 @@
|
||||
"form_title": "Conte-nos sobre seu produto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "Isso irá redirecioná-lo para o Student LMS.",
|
||||
"description": "Você está prestes a ver o que um aluno verá ao se inscrever neste curso",
|
||||
"copy_link": "Copiar link",
|
||||
"go_to_lms": "Acesse o LMS"
|
||||
}
|
||||
},
|
||||
"courses": {
|
||||
@@ -3069,6 +3112,11 @@
|
||||
"field_nav_count_other": "{count} links",
|
||||
"hero_empty": "-",
|
||||
"customize": "Personalize a página de destino"
|
||||
},
|
||||
"preview_tabs": {
|
||||
"label": "Página de visualização",
|
||||
"home": "Página inicial",
|
||||
"course": "Curso"
|
||||
}
|
||||
},
|
||||
"organization": {
|
||||
@@ -3322,7 +3370,10 @@
|
||||
"tic_tac": "Dedo do jogo",
|
||||
"see_more": "Ver mais",
|
||||
"whats_new": "O que há de novo",
|
||||
"launch_week": "Semana de lançamento"
|
||||
"launch_week": "Semana de lançamento",
|
||||
"feedback": "Comentários",
|
||||
"documentation": "Documentação",
|
||||
"need_help": "Precisa de ajuda?"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "Bem vindo de volta",
|
||||
@@ -3567,6 +3618,18 @@
|
||||
"joined": "Organização aderida",
|
||||
"join_failed": "Falha ao ingressar na organização"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"page_title": "Junte-se a {orgName} no ClassroomIO",
|
||||
"invitation_student": "Você foi convidado para ingressar em {orgName} como estudante.",
|
||||
"invitation_teacher": "Você foi convidado para ingressar em {orgName} como professor.",
|
||||
"invitation_admin": "Você foi convidado para ingressar em {orgName} como administrador.",
|
||||
"invitation_fallback": "Você foi convidado para participar de {orgName} como {role}.",
|
||||
"accept_button": "Junte-se à organização",
|
||||
"go_to_dashboard": "Vá para o painel",
|
||||
"disabled": "Este link de convite foi desativado pela organização.",
|
||||
"join_failed": "Falha ao ingressar na organização",
|
||||
"joined": "Organização aderida"
|
||||
}
|
||||
},
|
||||
"automation": {
|
||||
@@ -3886,7 +3949,7 @@
|
||||
"new_chat": "Novo bate-papo",
|
||||
"tokens_label": "fichas",
|
||||
"implementing_course_plan": "Implementando Plano de Curso",
|
||||
"resume": "Currículo",
|
||||
"resume": "Continuar",
|
||||
"stopped_content_kept": "Parado. O conteúdo criado foi mantido.",
|
||||
"mention_lesson": "Lição",
|
||||
"mention_exercise": "Exercício",
|
||||
@@ -4053,7 +4116,11 @@
|
||||
"title": "O trabalhador não responde",
|
||||
"body": "Esta execução está aguardando há mais de 30 segundos sem nenhuma coleta de trabalhador. O trabalhador em segundo plano pode estar inativo ou reiniciando."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ask_ai": "Pergunte à IA",
|
||||
"content_input_placeholder": "Pergunte sobre este conteúdo…",
|
||||
"ask_ai_expand_aria": "Abra a entrada Ask AI",
|
||||
"ask_ai_input_aria": "Pergunte à IA sobre este conteúdo"
|
||||
},
|
||||
"widgets": {
|
||||
"heading": "Widgets",
|
||||
|
||||
@@ -350,7 +350,9 @@
|
||||
"thumbnail_updated": "Значок обновлен.",
|
||||
"thumbnail_update_failed": "Не удалось обновить миниатюру.",
|
||||
"thumbnail_regen_started": "Восстановление миниатюр…",
|
||||
"thumbnail_regen_failed": "Не удалось восстановить миниатюры."
|
||||
"thumbnail_regen_failed": "Не удалось восстановить миниатюры.",
|
||||
"transcript_saved": "Стенограмма обновлена",
|
||||
"transcript_save_failed": "Не удалось сохранить расшифровку"
|
||||
},
|
||||
"tags": {
|
||||
"group_created": "Группа тегов создана.",
|
||||
@@ -420,6 +422,18 @@
|
||||
"workspace_is_primary": "Вы не можете удалить основное рабочее пространство.",
|
||||
"workspace_not_found": "Рабочее пространство не найдено.",
|
||||
"not_primary_workspace": "Переключитесь на основное рабочее пространство, чтобы управлять рабочими пространствами учетной записи."
|
||||
},
|
||||
"link_invite": {
|
||||
"generated": "Приглашение по ссылке создано",
|
||||
"enabled": "Приглашение по ссылке включено",
|
||||
"disabled": "Приглашение по ссылке отключено"
|
||||
},
|
||||
"view_as_student": {
|
||||
"failed": "Не удалось открыть представление учащихся. Пожалуйста, попробуйте еще раз."
|
||||
},
|
||||
"lesson_video": {
|
||||
"generate_1080_completed": "добавлено воспроизведение 1080p",
|
||||
"generate_1080_failed": "Не удалось создать воспроизведение 1080p."
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -905,7 +919,9 @@
|
||||
"short_video": "Короткое видео",
|
||||
"replace_cover": "Заменить изображение обложки",
|
||||
"replace": "Заменить",
|
||||
"helper": "Введите ссылку на видео на YouTube"
|
||||
"helper": "Введите ссылку на видео на YouTube",
|
||||
"remove": "Удалить",
|
||||
"no_cover": "Обложка не установлена."
|
||||
},
|
||||
"title": {
|
||||
"header": "Заголовок",
|
||||
@@ -1030,7 +1046,17 @@
|
||||
"members": "Члены",
|
||||
"invite_sent": "Приглашение отправлено",
|
||||
"you": "ты",
|
||||
"remove": "Удалить"
|
||||
"remove": "Удалить",
|
||||
"link_invite": {
|
||||
"heading": "Ссылка Пригласить",
|
||||
"description": "Любой, у кого есть эта ссылка, может присоединиться к вашей организации.",
|
||||
"generate": "Создать ссылку",
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано!",
|
||||
"toggle_label": "Приглашение по ссылке активно",
|
||||
"disabled_notice": "Эта ссылка отключена. Включите его, чтобы позволить новым участникам присоединяться по ссылке.",
|
||||
"select_role": "Выберите роль"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Админ",
|
||||
@@ -1879,7 +1905,11 @@
|
||||
"language": "Language: {language}",
|
||||
"empty": "No transcript yet. You can generate one from this card’s menu after the video is uploaded.",
|
||||
"loading": "Loading transcript…",
|
||||
"open_side_panel": "Открыть панель стенограммы"
|
||||
"open_side_panel": "Открыть панель стенограммы",
|
||||
"edit": "Изменить стенограмму",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"segment_label": "Сегмент в {timestamp}"
|
||||
},
|
||||
"simple_card": {
|
||||
"kind_youtube": "Ютуб",
|
||||
@@ -1889,7 +1919,13 @@
|
||||
"course_fallback": "Курс",
|
||||
"menu_aria": "Параметры видео"
|
||||
},
|
||||
"view_transcript": "Посмотреть транскрипцию"
|
||||
"view_transcript": "Посмотреть транскрипцию",
|
||||
"playback_error": "Не удалось загрузить это видео.",
|
||||
"playback_reload": "Перезагрузить видео",
|
||||
"generate_1080": "Создать 1080p",
|
||||
"generate_1080_in_progress": "Генерация 1080p…",
|
||||
"generate_1080_ready": "доступно 1080p",
|
||||
"generate_1080_unavailable": "Разрешение 1080p недоступно для этого исходного видео."
|
||||
},
|
||||
"slide": {
|
||||
"title": "Слайд",
|
||||
@@ -2085,7 +2121,8 @@
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Посмотреть как студент"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Вернуться к курсу",
|
||||
@@ -2105,12 +2142,12 @@
|
||||
"nav_attendance": "посещаемость",
|
||||
"nav_submissions": "Материалы",
|
||||
"nav_marks": "Знаки",
|
||||
"nav_compliance": "Соответствие",
|
||||
"nav_people": "Люди",
|
||||
"nav_analytics": "Аналитика",
|
||||
"nav_certificates": "Сертификаты",
|
||||
"nav_landing_page": "Целевая страница",
|
||||
"nav_settings": "Настройки",
|
||||
"nav_compliance": "Соответствие",
|
||||
"nav_ai_assistant": "Ассистент",
|
||||
"nav_ai_tutor": "Репетитор по искусственному интеллекту"
|
||||
},
|
||||
@@ -2202,6 +2239,12 @@
|
||||
"form_title": "Расскажите нам о своем продукте"
|
||||
}
|
||||
}
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "Вы будете перенаправлены в Student LMS.",
|
||||
"description": "Вы скоро увидите, что увидит студент, записавшись на этот курс.",
|
||||
"copy_link": "Копировать ссылку",
|
||||
"go_to_lms": "Перейти в СДО"
|
||||
}
|
||||
},
|
||||
"courses": {
|
||||
@@ -3069,6 +3112,11 @@
|
||||
"field_nav_count_other": "{count} ссылок",
|
||||
"hero_empty": "—",
|
||||
"customize": "Настройте целевую страницу"
|
||||
},
|
||||
"preview_tabs": {
|
||||
"label": "Страница предварительного просмотра",
|
||||
"home": "Главная",
|
||||
"course": "Курс"
|
||||
}
|
||||
},
|
||||
"organization": {
|
||||
@@ -3322,7 +3370,10 @@
|
||||
"tic_tac": "Крестики-палочки",
|
||||
"see_more": "Посмотреть больше",
|
||||
"whats_new": "Что нового",
|
||||
"launch_week": "Неделя запуска"
|
||||
"launch_week": "Неделя запуска",
|
||||
"feedback": "Обратная связь",
|
||||
"documentation": "Документация",
|
||||
"need_help": "Нужна помощь?"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "С возвращением",
|
||||
@@ -3567,6 +3618,18 @@
|
||||
"joined": "Присоединившаяся организация",
|
||||
"join_failed": "Не удалось присоединиться к организации"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"page_title": "Присоединяйтесь к {orgName} в ClassroomIO.",
|
||||
"invitation_student": "Вас пригласили присоединиться к {orgName} в качестве студента.",
|
||||
"invitation_teacher": "Вас пригласили присоединиться к {orgName} в качестве преподавателя.",
|
||||
"invitation_admin": "Вас пригласили присоединиться к {orgName} в качестве администратора.",
|
||||
"invitation_fallback": "Вас пригласили присоединиться к {orgName} в качестве {role}.",
|
||||
"accept_button": "Присоединиться к организации",
|
||||
"go_to_dashboard": "Перейти на панель управления",
|
||||
"disabled": "Эта ссылка для приглашения отключена организацией.",
|
||||
"join_failed": "Не удалось присоединиться к организации",
|
||||
"joined": "Присоединившаяся организация"
|
||||
}
|
||||
},
|
||||
"automation": {
|
||||
@@ -3886,7 +3949,7 @@
|
||||
"new_chat": "Новый чат",
|
||||
"tokens_label": "жетоны",
|
||||
"implementing_course_plan": "Реализация плана курса",
|
||||
"resume": "Резюме",
|
||||
"resume": "Продолжить",
|
||||
"stopped_content_kept": "Остановился. Созданный контент сохранен.",
|
||||
"mention_lesson": "Урок",
|
||||
"mention_exercise": "Упражнение",
|
||||
@@ -3995,7 +4058,7 @@
|
||||
"context_compacted_badge": "Разговор сжат (предыдущие сообщения: {count})",
|
||||
"student_quick_action": {
|
||||
"explain_lesson": "Объясните этот урок",
|
||||
"quiz_me": "спроси меня об этом",
|
||||
"quiz_me": "Протестируй меня",
|
||||
"summarize_section": "Подведите итоги этого раздела",
|
||||
"what_to_review": "Что мне следует проверить дальше?"
|
||||
},
|
||||
@@ -4043,7 +4106,7 @@
|
||||
},
|
||||
"run_monitor": {
|
||||
"cancel": "Отменить запуск",
|
||||
"dismiss": "Уволить",
|
||||
"dismiss": "Отклонить",
|
||||
"now_doing": "Сейчас",
|
||||
"input_disabled_hint": "Чат приостановлен, пока агент завершает создание курса. Отмените или дождитесь завершения.",
|
||||
"dismiss_confirm": "Уйти",
|
||||
@@ -4053,7 +4116,11 @@
|
||||
"title": "Работник не отвечает",
|
||||
"body": "Этот запуск ожидает более 30 секунд, а работник не подъезжает. Фоновый рабочий процесс может быть отключен или перезапущен."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ask_ai": "Спросите ИИ",
|
||||
"content_input_placeholder": "Спросите об этом контенте…",
|
||||
"ask_ai_expand_aria": "Открыть вход Ask AI",
|
||||
"ask_ai_input_aria": "Спросите ИИ об этом контенте"
|
||||
},
|
||||
"widgets": {
|
||||
"heading": "Виджеты",
|
||||
|
||||
@@ -350,7 +350,9 @@
|
||||
"thumbnail_updated": "Đã cập nhật hình thu nhỏ",
|
||||
"thumbnail_update_failed": "Không cập nhật được hình thu nhỏ",
|
||||
"thumbnail_regen_started": "Đang tạo lại hình thu nhỏ…",
|
||||
"thumbnail_regen_failed": "Không thể tạo lại hình thu nhỏ"
|
||||
"thumbnail_regen_failed": "Không thể tạo lại hình thu nhỏ",
|
||||
"transcript_saved": "Đã cập nhật bản ghi",
|
||||
"transcript_save_failed": "Không thể lưu bản ghi"
|
||||
},
|
||||
"tags": {
|
||||
"group_created": "Đã tạo nhóm thẻ",
|
||||
@@ -420,6 +422,18 @@
|
||||
"workspace_is_primary": "Bạn không thể xóa không gian làm việc chính.",
|
||||
"workspace_not_found": "Không tìm thấy không gian làm việc.",
|
||||
"not_primary_workspace": "Chuyển sang không gian làm việc chính của bạn để quản lý không gian làm việc của tài khoản."
|
||||
},
|
||||
"link_invite": {
|
||||
"generated": "Đã tạo liên kết lời mời",
|
||||
"enabled": "Đã bật lời mời liên kết",
|
||||
"disabled": "Lời mời liên kết đã bị vô hiệu hóa"
|
||||
},
|
||||
"view_as_student": {
|
||||
"failed": "Không thể mở chế độ xem sinh viên. Vui lòng thử lại."
|
||||
},
|
||||
"lesson_video": {
|
||||
"generate_1080_completed": "Đã thêm phiên bản 1080p",
|
||||
"generate_1080_failed": "Không thể tạo phiên bản 1080p"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
@@ -905,7 +919,9 @@
|
||||
"short_video": "Video ngắn",
|
||||
"replace_cover": "Thay thế ảnh bìa",
|
||||
"replace": "Thay thế",
|
||||
"helper": "Nhập liên kết tới video youtube"
|
||||
"helper": "Nhập liên kết tới video youtube",
|
||||
"remove": "Xóa",
|
||||
"no_cover": "Chưa đặt ảnh bìa"
|
||||
},
|
||||
"title": {
|
||||
"header": "tiêu đề",
|
||||
@@ -1030,7 +1046,17 @@
|
||||
"members": "Thành viên",
|
||||
"invite_sent": "Lời mời đã gửi",
|
||||
"you": "bạn",
|
||||
"remove": "Xóa"
|
||||
"remove": "Xóa",
|
||||
"link_invite": {
|
||||
"heading": "Liên kết mời",
|
||||
"description": "Bất kỳ ai có liên kết này đều có thể tham gia tổ chức của bạn.",
|
||||
"generate": "Tạo liên kết",
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép!",
|
||||
"toggle_label": "Liên kết lời mời đang hoạt động",
|
||||
"disabled_notice": "Liên kết này bị vô hiệu hóa. Kích hoạt nó để cho phép thành viên mới tham gia thông qua liên kết.",
|
||||
"select_role": "Chọn một vai trò"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Quản trị viên",
|
||||
@@ -1879,7 +1905,11 @@
|
||||
"language": "Language: {language}",
|
||||
"empty": "No transcript yet. You can generate one from this card’s menu after the video is uploaded.",
|
||||
"loading": "Loading transcript…",
|
||||
"open_side_panel": "Mở bảng chép lời"
|
||||
"open_side_panel": "Mở bảng chép lời",
|
||||
"edit": "Chỉnh sửa bản ghi",
|
||||
"save": "Lưu",
|
||||
"cancel": "Hủy bỏ",
|
||||
"segment_label": "Đoạn tại {timestamp}"
|
||||
},
|
||||
"simple_card": {
|
||||
"kind_youtube": "YouTube",
|
||||
@@ -1889,7 +1919,13 @@
|
||||
"course_fallback": "Khóa học",
|
||||
"menu_aria": "Tùy chọn video"
|
||||
},
|
||||
"view_transcript": "Xem bản chép lời"
|
||||
"view_transcript": "Xem bản chép lời",
|
||||
"playback_error": "Tải video này không thành công.",
|
||||
"playback_reload": "Tải lại video",
|
||||
"generate_1080": "Tạo 1080p",
|
||||
"generate_1080_in_progress": "Đang tạo 1080p…",
|
||||
"generate_1080_ready": "1080p có sẵn",
|
||||
"generate_1080_unavailable": "1080p không có sẵn cho video nguồn này"
|
||||
},
|
||||
"slide": {
|
||||
"title": "Trượt",
|
||||
@@ -2085,7 +2121,8 @@
|
||||
},
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Xem với tư cách sinh viên"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Quay lại khóa học",
|
||||
@@ -2105,12 +2142,12 @@
|
||||
"nav_attendance": "Tham dự",
|
||||
"nav_submissions": "bài nộp",
|
||||
"nav_marks": "Điểm",
|
||||
"nav_compliance": "Tuân thủ",
|
||||
"nav_people": "mọi người",
|
||||
"nav_analytics": "Phân tích",
|
||||
"nav_certificates": "Chứng chỉ",
|
||||
"nav_landing_page": "Trang đích",
|
||||
"nav_settings": "Cài đặt",
|
||||
"nav_compliance": "Tuân thủ",
|
||||
"nav_ai_assistant": "Trợ lý",
|
||||
"nav_ai_tutor": "Gia sư AI"
|
||||
},
|
||||
@@ -2202,6 +2239,12 @@
|
||||
"form_title": "Hãy cho chúng tôi biết về sản phẩm của bạn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "Thao tác này sẽ chuyển hướng bạn đến LMS dành cho sinh viên.",
|
||||
"description": "Bạn sắp biết học viên sẽ thấy gì khi họ đăng ký khóa học này",
|
||||
"copy_link": "Sao chép liên kết",
|
||||
"go_to_lms": "Đi tới LMS"
|
||||
}
|
||||
},
|
||||
"courses": {
|
||||
@@ -3069,6 +3112,11 @@
|
||||
"field_nav_count_other": "{count} liên kết",
|
||||
"hero_empty": "—",
|
||||
"customize": "Tùy chỉnh trang đích"
|
||||
},
|
||||
"preview_tabs": {
|
||||
"label": "Trang xem trước",
|
||||
"home": "Trang chủ",
|
||||
"course": "Khóa học"
|
||||
}
|
||||
},
|
||||
"organization": {
|
||||
@@ -3322,7 +3370,10 @@
|
||||
"tic_tac": "ngón chân tictac",
|
||||
"see_more": "Xem thêm",
|
||||
"whats_new": "Có gì mới",
|
||||
"launch_week": "Tuần ra mắt"
|
||||
"launch_week": "Tuần ra mắt",
|
||||
"feedback": "Phản hồi",
|
||||
"documentation": "Tài liệu",
|
||||
"need_help": "Cần giúp đỡ?"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "Chào mừng trở lại",
|
||||
@@ -3567,6 +3618,18 @@
|
||||
"joined": "Đã tham gia tổ chức",
|
||||
"join_failed": "Không thể tham gia tổ chức"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"page_title": "Tham gia cùng {orgName} trên ClassroomIO",
|
||||
"invitation_student": "Bạn đã được mời tham gia {orgName} với tư cách là sinh viên.",
|
||||
"invitation_teacher": "Bạn đã được mời tham gia {orgName} với tư cách là giáo viên.",
|
||||
"invitation_admin": "Bạn đã được mời tham gia {orgName} với tư cách quản trị viên.",
|
||||
"invitation_fallback": "Bạn đã được mời tham gia {orgName} với tư cách là {role}.",
|
||||
"accept_button": "Tham gia tổ chức",
|
||||
"go_to_dashboard": "Đi tới Trang tổng quan",
|
||||
"disabled": "Liên kết mời này đã bị tổ chức vô hiệu hóa.",
|
||||
"join_failed": "Không thể tham gia tổ chức",
|
||||
"joined": "Đã tham gia tổ chức"
|
||||
}
|
||||
},
|
||||
"automation": {
|
||||
@@ -3889,7 +3952,7 @@
|
||||
"resume": "Tiếp tục",
|
||||
"stopped_content_kept": "Đã dừng lại. Nội dung được tạo đã được giữ lại.",
|
||||
"mention_lesson": "Bài học",
|
||||
"mention_exercise": "tập thể dục",
|
||||
"mention_exercise": "bài tập",
|
||||
"mention_section": "Phần",
|
||||
"mention_no_results": "Không có nội dung phù hợp",
|
||||
"uploading_document": "Đang tải tài liệu lên...",
|
||||
@@ -4053,7 +4116,11 @@
|
||||
"title": "Công nhân không phản hồi",
|
||||
"body": "Lần chạy này đã chờ hơn 30 giây mà không có công nhân đón. Nhân viên chạy nền có thể không hoạt động hoặc đang khởi động lại."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ask_ai": "Hỏi AI",
|
||||
"content_input_placeholder": "Hỏi về nội dung này…",
|
||||
"ask_ai_expand_aria": "Mở đầu vào Hỏi AI",
|
||||
"ask_ai_input_aria": "Hỏi AI về nội dung này"
|
||||
},
|
||||
"widgets": {
|
||||
"heading": "Widget",
|
||||
|
||||
@@ -13,14 +13,21 @@
|
||||
import { courseApi } from '$features/course/api';
|
||||
import ContentCreateModal from '$features/course/components/content/content-create-modal.svelte';
|
||||
import CourseCompletionModal from '$features/course/components/ceritficate/course-completion-modal.svelte';
|
||||
import { aiAssistantPanelDefinition } from '$features/ai-assistant';
|
||||
import { aiAssistantPanelDefinition, ContentAskAiBar, AI_ASSISTANT_PANEL_ID } from '$features/ai-assistant';
|
||||
import {
|
||||
getContentAskAiBarWidthClass,
|
||||
isCourseContentLockedForStudent
|
||||
} from '$features/ai-assistant/utils/content-ask-ai-bar';
|
||||
import { initialChatPrompt, openAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { sidePanel, SidePanelRail } from '$features/side-panel';
|
||||
import { transcriptPanelDefinition } from '$features/course/components/lesson/video/transcript-panel-definition';
|
||||
import { get } from 'svelte/store';
|
||||
import { page } from '$app/state';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { CourseMember } from '$features/course/utils/types';
|
||||
import {
|
||||
COURSE_SIDEBAR_DEFAULT_WIDTH,
|
||||
@@ -79,6 +86,28 @@
|
||||
|
||||
const isExercisePage = $derived(!!data.exerciseId);
|
||||
|
||||
const lessonId = $derived(page.params.lessonId as string | undefined);
|
||||
const exerciseId = $derived((page.params.exerciseId as string | undefined) ?? data.exerciseId);
|
||||
const isLessonOrExercisePage = $derived(Boolean(lessonId || exerciseId));
|
||||
const isLessonEditMode = $derived(page.url.searchParams.get('mode') === 'edit');
|
||||
|
||||
const contentAskAiWidthClass = $derived(getContentAskAiBarWidthClass({ lessonId, isLessonEditMode }));
|
||||
|
||||
const isContentLockedForStudent = $derived(
|
||||
isCourseContentLockedForStudent(
|
||||
courseApi.course,
|
||||
lessonId ?? exerciseId,
|
||||
lessonId ? ContentType.Lesson : exerciseId ? ContentType.Exercise : null
|
||||
)
|
||||
);
|
||||
|
||||
const showContentAskAiBar = $derived(
|
||||
isCourseReady &&
|
||||
isLessonOrExercisePage &&
|
||||
!($isOrgStudent && isContentLockedForStudent) &&
|
||||
sidePanel.activePanelId !== AI_ASSISTANT_PANEL_ID
|
||||
);
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width));
|
||||
}
|
||||
@@ -188,6 +217,10 @@
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
{#if showContentAskAiBar}
|
||||
<ContentAskAiBar class={contentAskAiWidthClass} />
|
||||
{/if}
|
||||
{/if}
|
||||
</Sidebar.Inset>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user