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:
@@ -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 = '[email protected]';
|
||||
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`;
|
||||
|
||||
Reference in New Issue
Block a user