Enhance audience management features with new components and API integrations

- Added `AssignCoursesModal`, `AudienceMemberRow`, `AudienceTable`, and related components for improved audience management UI.
- Implemented audience invite actions including resend and revoke functionality.
- Updated audience API to support importing members and assigning courses.
- Refactored audience data handling to include status and invite management.
- Improved overall user experience with pagination and selection features in the audience table.
This commit is contained in:
Rotimi Best
2026-03-22 18:42:23 +01:00
parent e9c4082f89
commit 3919ff6c2f
157 changed files with 13183 additions and 872 deletions
+20
View File
@@ -177,6 +177,23 @@ You can bind form controls directly to a writable store’s properties. Svelte c
<Select.Root type="single" bind:value={$inviteSettingsStore.preset}> <Select.Root type="single" bind:value={$inviteSettingsStore.preset}>
``` ```
### Reactive built-in collections
In Svelte 5, the built-in `Set` and `Map` classes are **not** reactive. Use `SvelteSet` and `SvelteMap` from `svelte/reactivity` instead. They are already reactive on their own — do **not** wrap them in `$state()`.
```svelte
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
let selectedIds = new SvelteSet<string>();
// Mutations are reactive — no clone-and-reassign needed
selectedIds.add('abc');
selectedIds.delete('abc');
selectedIds.clear();
</script>
```
### Server-Side API Calls ### Server-Side API Calls
Use `.server.ts` files for server-side code to isolate API keys. Use `.server.ts` files for server-side code to isolate API keys.
@@ -205,6 +222,7 @@ Use `.server.ts` files for server-side code to isolate API keys.
- Use `.server.ts` for server-side API calls with API keys - Use `.server.ts` for server-side API calls with API keys
- Use non-null assertion (`!`) for `user` when `authMiddleware` is present - Use non-null assertion (`!`) for `user` when `authMiddleware` is present
- Put all user-facing copy in translations and reference by key - Put all user-facing copy in translations and reference by key
- Use `SvelteSet`/`SvelteMap` from `svelte/reactivity` for reactive collections (not `new Set`/`new Map`)
### ❌ DON'T ### ❌ DON'T
- Put business logic in routes or queries - Put business logic in routes or queries
@@ -217,6 +235,8 @@ Use `.server.ts` files for server-side code to isolate API keys.
- Call RPC client directly in server files (use `.server.ts` classes) - Call RPC client directly in server files (use `.server.ts` classes)
- Add unnecessary null checks for `user` when `authMiddleware` is present - Add unnecessary null checks for `user` when `authMiddleware` is present
- Write plain English strings in components - Write plain English strings in components
- Use `new Set()`/`new Map()` for mutable reactive state — use `SvelteSet`/`SvelteMap` instead
- Wrap `SvelteSet`/`SvelteMap` in `$state()` — they are already reactive
- **Use inline type imports** (e.g. `import('Package').Type` in type positions) — use top-level `import type` instead - **Use inline type imports** (e.g. `import('Package').Type` in type positions) — use top-level `import type` instead
## Checklist for New Routes ## Checklist for New Routes
-3
View File
@@ -11,7 +11,6 @@ import { getUserOrgRole } from '@cio/db/queries/organization';
export const orgMemberMiddleware = async (c: Context, next: Next) => { export const orgMemberMiddleware = async (c: Context, next: Next) => {
try { try {
const user = c.get('user'); const user = c.get('user');
console.log('orgMemberMiddleware user', user);
if (!user) { if (!user) {
return c.json( return c.json(
{ {
@@ -24,7 +23,6 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => {
} }
const orgId = c.req.header('cio-org-id'); const orgId = c.req.header('cio-org-id');
console.log('orgMemberMiddleware orgId', orgId);
if (!orgId) { if (!orgId) {
return c.json( return c.json(
{ {
@@ -37,7 +35,6 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => {
} }
const roleId = await getUserOrgRole(orgId, user.id); const roleId = await getUserOrgRole(orgId, user.id);
console.log('orgMemberMiddleware roleId', orgId);
if (roleId === null) { if (roleId === null) {
return c.json( return c.json(
{ {
@@ -1,4 +1,6 @@
import { import {
ZAssignAudienceCourses,
ZAudienceInviteByEmail,
ZCancelOrgPlan, ZCancelOrgPlan,
ZCreateOrgPlan, ZCreateOrgPlan,
ZCreateOrganization, ZCreateOrganization,
@@ -7,12 +9,20 @@ import {
ZGetOrganizationCoursesQuery, ZGetOrganizationCoursesQuery,
ZGetOrganizations, ZGetOrganizations,
ZGetUserAnalytics, ZGetUserAnalytics,
ZImportAudienceMembers,
ZInviteTeamMembers, ZInviteTeamMembers,
ZLMSExercisesParam, ZLMSExercisesParam,
ZRemoveTeamMember, ZRemoveTeamMember,
ZUpdateOrgPlan, ZUpdateOrgPlan,
ZUpdateOrganization ZUpdateOrganization
} from '@cio/utils/validation/organization'; } from '@cio/utils/validation/organization';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import {
assignAudienceToCourses,
importAudienceMembers,
resendAudienceInvite,
revokeAudiencePendingInvite
} from '@api/services/organization/audience';
import { import {
cancelOrgPlan, cancelOrgPlan,
createOrg, createOrg,
@@ -33,13 +43,13 @@ import {
} from '@api/services/organization'; } from '@api/services/organization';
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { ROLE } from '@cio/utils/constants';
import { TOrganization } from '@db/types'; import { TOrganization } from '@db/types';
import { automationRouter } from '@api/routes/organization/automation';
import { assetsRouter } from '@api/routes/organization/assets'; import { assetsRouter } from '@api/routes/organization/assets';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { automationRouter } from '@api/routes/organization/automation';
import { courseImportRouter } from '@api/routes/organization/course-import'; import { courseImportRouter } from '@api/routes/organization/course-import';
import { getLMSExercisesService } from '@api/services/exercise'; import { getLMSExercisesService } from '@api/services/exercise';
import { handleError } from '@api/utils/errors'; import { handleError } from '@api/utils/errors';
@@ -47,9 +57,9 @@ import { inviteTeamMembers } from '@api/services/organization/invite';
import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key'; import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { quizRouter } from '@api/routes/organization/quiz'; import { quizRouter } from '@api/routes/organization/quiz';
import { tagsRouter } from '@api/routes/organization/tags'; import { tagsRouter } from '@api/routes/organization/tags';
import { ROLE } from '@cio/utils/constants';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const organizationRouter = new Hono() export const organizationRouter = new Hono()
@@ -100,7 +110,7 @@ export const organizationRouter = new Hono()
* Gets organization team members (non-students) * Gets organization team members (non-students)
* Requires authentication * Requires authentication
*/ */
.get('/team', authMiddleware, orgMemberMiddleware, async (c) => { .get('/team', authMiddleware, orgTeamMemberMiddleware, async (c) => {
try { try {
const orgId = c.req.header('cio-org-id')!; const orgId = c.req.header('cio-org-id')!;
const team = await getOrgTeam(orgId); const team = await getOrgTeam(orgId);
@@ -168,7 +178,7 @@ export const organizationRouter = new Hono()
* Gets organization audience (students) * Gets organization audience (students)
* Requires authentication * Requires authentication
*/ */
.get('/audience', authMiddleware, orgMemberMiddleware, async (c) => { .get('/audience', authMiddleware, orgTeamMemberMiddleware, async (c) => {
try { try {
const orgId = c.req.header('cio-org-id')!; const orgId = c.req.header('cio-org-id')!;
const audience = await getOrgAudience(orgId); const audience = await getOrgAudience(orgId);
@@ -184,6 +194,48 @@ export const organizationRouter = new Hono()
return handleError(c, error, 'Failed to fetch organization audience'); return handleError(c, error, 'Failed to fetch organization audience');
} }
}) })
/**
* POST /organization/audience/resend-invite
* Resends a student org invite email (new token; optional course metadata from last invite)
*/
.post(
'/audience/resend-invite',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('json', ZAudienceInviteByEmail),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const data = c.req.valid('json');
const result = await resendAudienceInvite(orgId, data, user.id);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to resend audience invite');
}
}
)
/**
* POST /organization/audience/revoke-invite
* Revokes an active (non-expired) pending student org invite
*/
.post(
'/audience/revoke-invite',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('json', ZAudienceInviteByEmail),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const data = c.req.valid('json');
const result = await revokeAudiencePendingInvite(orgId, data, user.id);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to revoke audience invite');
}
}
)
/** /**
* GET /organization/audience/:userId/analytics * GET /organization/audience/:userId/analytics
* Gets user analytics for a specific user in an organization * Gets user analytics for a specific user in an organization
@@ -192,7 +244,7 @@ export const organizationRouter = new Hono()
.get( .get(
'/audience/:userId/analytics', '/audience/:userId/analytics',
authMiddleware, authMiddleware,
orgMemberMiddleware, orgTeamMemberMiddleware,
zValidator('param', ZGetUserAnalytics), zValidator('param', ZGetUserAnalytics),
async (c) => { async (c) => {
try { try {
@@ -458,7 +510,6 @@ export const organizationRouter = new Hono()
*/ */
.put('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZUpdateOrganization), async (c) => { .put('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZUpdateOrganization), async (c) => {
try { try {
console.log('updateOrg');
console.log(c.req.valid('json')); console.log(c.req.valid('json'));
const orgId = c.req.header('cio-org-id')!; const orgId = c.req.header('cio-org-id')!;
@@ -515,6 +566,65 @@ export const organizationRouter = new Hono()
return handleError(c, error, 'Failed to fetch LMS exercises'); return handleError(c, error, 'Failed to fetch LMS exercises');
} }
}) })
/**
* POST /organization/audience/import
* Imports users into the organization as students with optional course assignment
* Requires authentication and admin role
*/
.post(
'/audience/import',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('json', ZImportAudienceMembers),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const data = c.req.valid('json');
const result = await importAudienceMembers(orgId, data, user.id);
return c.json(
{
success: true,
data: result
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to import audience members');
}
}
)
/**
* POST /organization/audience/assign-courses
* Assigns existing audience members to one or more courses
* Requires authentication and admin role
*/
.post(
'/audience/assign-courses',
authMiddleware,
orgAdminMiddleware,
zValidator('json', ZAssignAudienceCourses),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const data = c.req.valid('json');
const result = await assignAudienceToCourses(orgId, data);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to assign audience to courses');
}
}
)
.route('/automation', automationRouter) .route('/automation', automationRouter)
.route('/course-import', courseImportRouter) .route('/course-import', courseImportRouter)
.route('/tags', tagsRouter) .route('/tags', tagsRouter)
+5 -1
View File
@@ -23,7 +23,7 @@ import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getCourseTeachers } from '@cio/db/queries/course/people'; import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth'; import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email'; import { buildEmailFromName, sendEmail } from '@cio/email';
import { getProfileByEmail } from '@cio/db/queries/auth'; import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
import { generateSlug } from '@cio/utils/functions'; import { generateSlug } from '@cio/utils/functions';
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED'; type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
@@ -948,6 +948,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
.limit(1); .limit(1);
if (existingMember) { if (existingMember) {
await markUserAndProfileEmailVerified(user.id, tx);
await recordInviteAudit(invite.id, invite.courseId, 'ACCEPTED', { await recordInviteAudit(invite.id, invite.courseId, 'ACCEPTED', {
actorProfileId: user.id, actorProfileId: user.id,
targetEmail: normalizedEmail, targetEmail: normalizedEmail,
@@ -1029,6 +1031,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
email: normalizedEmail email: normalizedEmail
}); });
await markUserAndProfileEmailVerified(user.id, tx);
const [consumeInvite] = await tx const [consumeInvite] = await tx
.update(schema.courseInvite) .update(schema.courseInvite)
.set({ .set({
+1 -1
View File
@@ -2,7 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash'; import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash';
import { OrganisationAnalytics } from '@api/types'; import { OrganisationAnalytics } from '@api/types';
import { getOrgIdBySiteName } from '@db/queries'; import { getOrgIdBySiteName } from '@cio/db/queries';
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> { export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
const analytics: OrganisationAnalytics = { const analytics: OrganisationAnalytics = {
+21 -4
View File
@@ -1,11 +1,12 @@
import { AppError, ErrorCodes } from '@api/utils/errors'; import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan, TPlan } from '@db/types'; import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan } from '@db/types';
import { import {
cancelOrganizationPlan, cancelOrganizationPlan,
createOrganizationPlan, createOrganizationPlan,
deleteOrganizationMember, deleteOrganizationMember,
getOrgIdBySiteName, getOrgIdBySiteName,
getFirstOrganizationWithPlans, getFirstOrganizationWithPlans,
getLatestOrgInvitesByEmails,
getOrganizationAudience, getOrganizationAudience,
getOrganizationBySiteName, getOrganizationBySiteName,
getOrganizationTeam, getOrganizationTeam,
@@ -32,6 +33,8 @@ import { ROLE } from '@cio/utils/constants';
import { createOrganizationWithOwner } from '@api/services/onboarding'; import { createOrganizationWithOwner } from '@api/services/onboarding';
import { getProfileById } from '@cio/db/queries/auth'; import { getProfileById } from '@cio/db/queries/auth';
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite'; import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
import type { OrgAudienceMember } from '@api/types/org';
/** /**
* Creates a new organization with the current user as owner * Creates a new organization with the current user as owner
@@ -116,12 +119,26 @@ export async function getOrgTeam(orgId: string) {
/** /**
* Gets organization audience (students) * Gets organization audience (students)
* @param orgId - The organization ID * @param orgId - The organization ID
* @returns Array of student profiles * @returns Audience members with invite status for profile-less rows
*/ */
export async function getOrgAudience(orgId: string) { export async function getOrgAudience(orgId: string): Promise<OrgAudienceMember[]> {
try { try {
const audience = await getOrganizationAudience(orgId); const audience = await getOrganizationAudience(orgId);
return audience;
const emailsWithoutProfile = audience.filter((m) => !m.profileId && m.email).map((m) => m.email.toLowerCase());
const invites = await getLatestOrgInvitesByEmails(orgId, emailsWithoutProfile);
const inviteByEmail = new Map(invites.map((i) => [i.email.toLowerCase(), i]));
return audience.map(
(member): OrgAudienceMember => ({
...member,
status: deriveAudienceMemberStatus(
member.profileId,
member.email ? inviteByEmail.get(member.email.toLowerCase()) : undefined
)
})
);
} catch (error) { } catch (error) {
throw new AppError( throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch organization audience', error instanceof Error ? error.message : 'Failed to fetch organization audience',
@@ -0,0 +1,531 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type {
TAssignAudienceCourses,
TAudienceInviteByEmail,
TImportAudienceMembers
} from '@cio/utils/validation/organization';
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
import { buildEmailFromName, sendEmail } from '@cio/email';
import {
checkEmailsExistInOrg,
createOrganizationInvite,
createOrganizationInviteAudits,
createOrganizationInvites,
createOrganizationMembers,
getLatestOrganizationInviteRowByOrgAndEmail,
getOrgMembersByProfileIds,
getOrganizationById,
getStudentOrganizationMemberByOrgAndEmail,
hasActiveOrganizationInviteForEmail,
revokeActiveOrganizationInvitesByEmails
} from '@cio/db/queries/organization';
import { getCourseGroupIds, getOrgCourseGroups, getOrgCourses } from '@cio/db/queries/course';
import { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getProfilesByEmails } from '@cio/db/queries/auth';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
/** Parallel outbound invite emails; avoids sequential SMTP/API latency per recipient. */
const EMAIL_SEND_CONCURRENCY = 5;
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]> {
if (items.length === 0) {
return [];
}
const results: R[] = new Array(items.length);
let next = 0;
const workerCount = Math.min(concurrency, items.length);
const workers = Array.from({ length: workerCount }, async () => {
while (true) {
const i = next++;
if (i >= items.length) break;
results[i] = await fn(items[i]);
}
});
await Promise.all(workers);
return results;
}
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
function generateToken(): string {
return crypto.randomBytes(32).toString('base64url');
}
function buildInviteLink(token: string): string {
return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`;
}
function getExpiryLabel(expiresAtIso: string): string {
return new Date(expiresAtIso).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'UTC'
});
}
function parseRecipientCsv(recipientCsv: string): string[] {
return recipientCsv
.split(/[\n,;\t ]+/g)
.map((value) => value.trim())
.filter(Boolean);
}
function getNormalizedRecipients(recipientCsv: string): {
valid: string[];
invalid: string[];
duplicates: string[];
} {
const raw = parseRecipientCsv(recipientCsv);
const seen = new Set<string>();
const valid: string[] = [];
const invalid: string[] = [];
const duplicates: string[] = [];
for (const recipient of raw) {
const normalized = recipient.toLowerCase().trim();
if (!normalized) continue;
if (!EMAIL_REGEX.test(normalized)) {
invalid.push(normalized);
continue;
}
if (seen.has(normalized)) {
duplicates.push(normalized);
continue;
}
seen.add(normalized);
valid.push(normalized);
}
return { valid, invalid, duplicates };
}
export async function importAudienceMembers(orgId: string, data: TImportAudienceMembers, invitedByProfileId: string) {
const organization = await getOrganizationById(orgId);
if (!organization || !organization.siteName) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const recipients = getNormalizedRecipients(data.recipientCsv);
if (recipients.invalid.length > 0) {
throw new AppError(
`Invalid emails found: ${recipients.invalid.slice(0, 5).join(', ')}`,
ErrorCodes.VALIDATION_ERROR,
400,
'recipientCsv'
);
}
if (recipients.valid.length === 0) {
throw new AppError('No valid emails provided', ErrorCodes.VALIDATION_ERROR, 400, 'recipientCsv');
}
const existingEmails = await checkEmailsExistInOrg(orgId, recipients.valid);
const emailsToImport = recipients.valid.filter((email) => !existingEmails.includes(email));
if (emailsToImport.length === 0) {
return {
imported: 0,
skipped: existingEmails.length,
duplicates: recipients.duplicates.length,
emailsSent: 0,
emailsFailed: 0
};
}
await createOrganizationMembers(
emailsToImport.map((email) => ({
organizationId: orgId,
email,
roleId: ROLE.STUDENT,
verified: false
}))
);
let courseIds: string[] = [];
let courseNames: string[] = [];
if (data.allCourses) {
const courses = await getOrgCourses({ orgId });
courseIds = courses.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean);
} else if (data.courseIds && data.courseIds.length > 0) {
const courses = await getOrgCourses({ orgId, courseIds: data.courseIds });
courseIds = courses.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean);
}
if (courseIds.length > 0) {
const courseGroupMappings = await getCourseGroupIds(courseIds);
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
if (validGroupIds.length > 0) {
const profiles = await getProfilesByEmails(emailsToImport);
if (profiles.length > 0) {
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
}
}
}
await revokeActiveOrganizationInvitesByEmails(orgId, emailsToImport, invitedByProfileId);
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
const inviteInputs = emailsToImport.map((email) => {
const token = generateToken();
return {
email,
token,
row: {
organizationId: orgId,
roleId: ROLE.STUDENT,
email,
tokenHash: hashToken(token),
createdByProfileId: invitedByProfileId,
expiresAt,
isRevoked: false,
metadata: {
source: 'AUDIENCE_IMPORT',
courseIds: courseIds.length > 0 ? courseIds : undefined
}
}
};
});
const invites = await createOrganizationInvites(inviteInputs.map((i) => i.row));
const inviteByEmail = new Map(invites.map((inv) => [inv.email.toLowerCase(), inv]));
const tokenByEmail = new Map(inviteInputs.map((i) => [i.email.toLowerCase(), i.token]));
await createOrganizationInviteAudits(
emailsToImport.map((email) => {
const invite = inviteByEmail.get(email)!;
return {
inviteId: invite.id,
organizationId: orgId,
eventType: 'CREATED' as const,
actorProfileId: invitedByProfileId,
targetEmail: email,
ipAddress: null,
userAgent: null,
metadata: {
roleId: ROLE.STUDENT,
roleName: 'Student',
expiresAt,
courseIds
}
};
})
);
let emailsSent = 0;
let emailsFailed = 0;
if (data.sendEmail) {
const emailOutcomes = await mapWithConcurrency(emailsToImport, EMAIL_SEND_CONCURRENCY, async (email) => {
const invite = inviteByEmail.get(email)!;
const token = tokenByEmail.get(email)!;
try {
const inviteLink = buildInviteLink(token);
const responses = await sendEmail('studentOrgInvite', {
to: email,
fields: {
email,
orgName: organization.name,
inviteLink,
expiresAt: getExpiryLabel(expiresAt),
courseNames: courseNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
const allSuccessful = responses.every((r) => r.success);
if (allSuccessful) {
return {
inviteId: invite.id,
email,
success: true as const,
error: undefined as string | undefined
};
}
return {
inviteId: invite.id,
email,
success: false as const,
error: responses
.filter((r) => !r.success)
.map((r) => r.error || 'Unknown')
.join('; ')
};
} catch (emailError) {
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
return { inviteId: invite.id, email, success: false as const, error: message };
}
});
emailsSent = emailOutcomes.filter((o) => o.success).length;
emailsFailed = emailOutcomes.length - emailsSent;
await createOrganizationInviteAudits(
emailOutcomes.map((o) => ({
inviteId: o.inviteId,
organizationId: orgId,
eventType: o.success ? ('EMAIL_SENT' as const) : ('EMAIL_FAILED' as const),
actorProfileId: invitedByProfileId,
targetEmail: o.email,
ipAddress: null,
userAgent: null,
metadata: o.success ? {} : { error: o.error ?? 'Unknown' }
}))
);
}
return {
imported: emailsToImport.length,
skipped: existingEmails.length,
duplicates: recipients.duplicates.length,
emailsSent,
emailsFailed
};
}
export async function resendAudienceInvite(orgId: string, data: TAudienceInviteByEmail, invitedByProfileId: string) {
const organization = await getOrganizationById(orgId);
if (!organization || !organization.siteName) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const member = await getStudentOrganizationMemberByOrgAndEmail(orgId, data.email);
if (!member) {
throw new AppError('Audience member not found', ErrorCodes.VALIDATION_ERROR, 404, 'email');
}
if (member.profileId) {
throw new AppError('Member has already joined', ErrorCodes.VALIDATION_ERROR, 400, 'email');
}
const emailToUse = member.email.toLowerCase().trim();
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse);
const meta = latestInvite?.metadata as { courseIds?: string[] } | undefined;
const courseIdsFromMetadata = meta?.courseIds?.filter(Boolean) ?? [];
let courseIds: string[] = [];
let courseNames: string[] = [];
if (courseIdsFromMetadata.length > 0) {
const courses = await getOrgCourses({ orgId, courseIds: courseIdsFromMetadata });
courseIds = courses.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean);
}
await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId);
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
const token = generateToken();
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
const invite = await createOrganizationInvite({
organizationId: orgId,
roleId: ROLE.STUDENT,
email: emailToUse,
tokenHash: hashToken(token),
createdByProfileId: invitedByProfileId,
expiresAt,
isRevoked: false,
metadata: {
source: 'AUDIENCE_RESEND',
courseIds: courseIds.length > 0 ? courseIds : undefined
}
});
await createOrganizationInviteAudits([
{
inviteId: invite.id,
organizationId: orgId,
eventType: 'CREATED',
actorProfileId: invitedByProfileId,
targetEmail: emailToUse,
ipAddress: null,
userAgent: null,
metadata: {
roleId: ROLE.STUDENT,
roleName: 'Student',
expiresAt,
courseIds
}
}
]);
let emailSent = false;
try {
const inviteLink = buildInviteLink(token);
const responses = await sendEmail('studentOrgInvite', {
to: emailToUse,
fields: {
email: emailToUse,
orgName: organization.name,
inviteLink,
expiresAt: getExpiryLabel(expiresAt),
courseNames: courseNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
const allSuccessful = responses.every((r) => r.success);
emailSent = allSuccessful;
await createOrganizationInviteAudits([
{
inviteId: invite.id,
organizationId: orgId,
eventType: allSuccessful ? 'EMAIL_SENT' : 'EMAIL_FAILED',
actorProfileId: invitedByProfileId,
targetEmail: emailToUse,
ipAddress: null,
userAgent: null,
metadata: allSuccessful
? {}
: {
error: responses
.filter((r) => !r.success)
.map((r) => r.error || 'Unknown')
.join('; ')
}
}
]);
} catch (emailError) {
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
await createOrganizationInviteAudits([
{
inviteId: invite.id,
organizationId: orgId,
eventType: 'EMAIL_FAILED',
actorProfileId: invitedByProfileId,
targetEmail: emailToUse,
ipAddress: null,
userAgent: null,
metadata: { error: message }
}
]);
}
return { emailSent };
}
export async function revokeAudiencePendingInvite(
orgId: string,
data: TAudienceInviteByEmail,
revokedByProfileId: string
) {
const member = await getStudentOrganizationMemberByOrgAndEmail(orgId, data.email);
if (!member) {
throw new AppError('Audience member not found', ErrorCodes.VALIDATION_ERROR, 404, 'email');
}
if (member.profileId) {
throw new AppError('Member has already joined', ErrorCodes.VALIDATION_ERROR, 400, 'email');
}
const emailToUse = member.email.toLowerCase().trim();
const hasActive = await hasActiveOrganizationInviteForEmail(orgId, emailToUse);
if (!hasActive) {
throw new AppError('No active pending invite to revoke', ErrorCodes.VALIDATION_ERROR, 400);
}
const revoked = await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], revokedByProfileId);
if (revoked.length === 0) {
throw new AppError('No invite was revoked', ErrorCodes.VALIDATION_ERROR, 400);
}
await createOrganizationInviteAudits(
revoked.map((inv) => ({
inviteId: inv.id,
organizationId: orgId,
eventType: 'REVOKED' as const,
actorProfileId: revokedByProfileId,
targetEmail: emailToUse,
ipAddress: null,
userAgent: null,
metadata: { reason: 'audience_revoked_by_admin' }
}))
);
return { revoked: true };
}
export async function assignAudienceToCourses(orgId: string, data: TAssignAudienceCourses) {
const organization = await getOrganizationById(orgId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const orgMembers = await getOrgMembersByProfileIds(orgId, data.profileIds);
const validProfileIds = new Set(orgMembers.map((m) => m.profileId).filter(Boolean));
const profileEmailMap = new Map(orgMembers.filter((m) => m.profileId).map((m) => [m.profileId!, m.email ?? '']));
const courseGroups = await getOrgCourseGroups(orgId, data.courseIds);
if (courseGroups.length === 0) {
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
}
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle]));
const validProfiles = data.profileIds.filter((id) => validProfileIds.has(id));
const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId })));
const existingSet = await getExistingGroupMembers(pairs);
const toInsert = pairs.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`));
const alreadyEnrolled = pairs.length - toInsert.length;
if (toInsert.length > 0) {
await addGroupMembers(
toInsert.map((p) => ({
groupId: p.groupId,
roleId: ROLE.STUDENT,
profileId: p.profileId,
email: profileEmailMap.get(p.profileId) || undefined
}))
);
}
let emailsSent = 0;
if (data.sendEmail && toInsert.length > 0) {
const emailPromises = toInsert
.filter((p) => profileEmailMap.get(p.profileId))
.map(async (p) => {
const email = profileEmailMap.get(p.profileId)!;
try {
await sendEmail('studentCourseWelcome', {
to: email,
fields: {
orgName: organization.name,
courseName: courseTitleByGroupId.get(p.groupId) || 'Course'
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
emailsSent++;
} catch (emailError) {
console.error(`assignAudienceToCourses email error for ${email}:`, emailError);
}
});
await Promise.all(emailPromises);
}
return {
assigned: toInsert.length,
alreadyEnrolled,
emailsSent
};
}
+32 -2
View File
@@ -11,11 +11,14 @@ import {
getOrganizationInviteByTokenHash, getOrganizationInviteByTokenHash,
revokeActiveOrganizationInvitesByEmails revokeActiveOrganizationInvitesByEmails
} from '@cio/db/queries/organization'; } from '@cio/db/queries/organization';
import { getCourseGroupIds } from '@cio/db/queries/course';
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
import { ROLE } from '@cio/utils/constants'; import { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { db } from '@cio/db/drizzle'; import { db } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { sendEmail } from '@cio/email'; import { sendEmail } from '@cio/email';
@@ -46,12 +49,13 @@ function normalizeEmails(emails: string[]): string[] {
} }
function buildInviteLink(token: string): string { function buildInviteLink(token: string): string {
return `${getDashboardBaseUrl()}/invite/t/${encodeURIComponent(token)}`; return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`;
} }
function getRoleLabel(roleId: number): string { function getRoleLabel(roleId: number): string {
if (roleId === ROLE.ADMIN) return 'Admin'; if (roleId === ROLE.ADMIN) return 'Admin';
if (roleId === ROLE.TUTOR) return 'Tutor'; if (roleId === ROLE.TUTOR) return 'Tutor';
if (roleId === ROLE.STUDENT) return 'Student';
return `Role ${roleId}`; return `Role ${roleId}`;
} }
@@ -405,6 +409,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
const siteName = result.organization.siteName || ''; const siteName = result.organization.siteName || '';
const invite = await getOrganizationInviteByTokenHash(tokenHash); const invite = await getOrganizationInviteByTokenHash(tokenHash);
if (invite) { if (invite) {
await recordOrganizationInviteAudit(invite.invite.id, invite.invite.organizationId, 'ACCEPTED', { await recordOrganizationInviteAudit(invite.invite.id, invite.invite.organizationId, 'ACCEPTED', {
actorProfileId: user.id, actorProfileId: user.id,
@@ -415,10 +420,35 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
}); });
} }
// Auto-enroll in courses if invite metadata contains courseIds (from audience import).
// Run regardless of alreadyAccepted — enrollment is idempotent and the user may
// have accepted the org invite previously without getting course access.
if (invite) {
const metadata = invite.invite.metadata;
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
if (courseIds.length > 0) {
try {
// Same as importAudienceMembers after course ids are known: resolve groups from course rows only.
const courseGroupMappings = await getCourseGroupIds(courseIds);
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
await enrollUsersInCourseGroups(
validGroupIds,
[{ profileId: user.id, email: normalizedEmail }],
invite.invite.roleId
);
} catch (error) {
console.error('acceptOrganizationInvite course enrollment error:', error);
}
}
}
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
return { return {
organizationId: result.organization.id, organizationId: result.organization.id,
roleId: result.roleId, roleId: result.roleId,
alreadyAccepted: result.alreadyAccepted, alreadyAccepted: result.alreadyAccepted,
redirectTo: siteName ? `/org/${siteName}` : '/org' redirectTo
}; };
} }
+1
View File
@@ -1,3 +1,4 @@
// Re-export all types // Re-export all types
export * from './database'; export * from './database';
export * from './dash'; export * from './dash';
export * from './org';
+11
View File
@@ -0,0 +1,11 @@
import type { AudienceMemberStatus } from '@api/utils/audience-member-status';
export type OrgAudienceMember = {
id: number;
profileId: string | null;
name: string;
email: string;
avatarUrl: string;
createdAt: string;
status: AudienceMemberStatus;
};
@@ -0,0 +1,23 @@
export type AudienceMemberStatus = 'active' | 'pending' | 'expired' | 'revoked';
export function deriveAudienceMemberStatus(
profileId: string | null,
invite: { acceptedAt: string | null; isRevoked: boolean; expiresAt: string } | undefined
): AudienceMemberStatus {
if (profileId) {
return 'active';
}
if (invite?.acceptedAt) {
return 'active';
}
if (!invite) {
return 'pending';
}
if (invite.isRevoked) {
return 'revoked';
}
if (new Date(invite.expiresAt) <= new Date()) {
return 'expired';
}
return 'pending';
}
+25
View File
@@ -0,0 +1,25 @@
/** Normalize JSONB organization-invite metadata for course ids (matches importAudienceMembers storage). */
export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
let obj: Record<string, unknown> | null = null;
if (metadata == null) {
return [];
}
if (typeof metadata === 'string') {
try {
const parsed = JSON.parse(metadata) as unknown;
obj = typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
} catch {
return [];
}
} else if (typeof metadata === 'object') {
obj = metadata as Record<string, unknown>;
}
if (!obj) {
return [];
}
const raw = obj.courseIds ?? obj.course_ids;
if (!Array.isArray(raw)) {
return [];
}
return raw.filter((id): id is string => typeof id === 'string' && id.length > 0);
}
+4 -4
View File
@@ -59,11 +59,11 @@ html .dark {
} }
.prose h1 { .prose h1 {
@apply mt-0 text-4xl font-extrabold; @apply mt-0 text-4xl font-normal;
} }
.prose h2 { .prose h2 {
@apply mt-10 border-b pb-2 text-3xl font-semibold transition-colors; @apply mt-0 text-3xl font-normal transition-colors;
} }
.prose h2:first-child { .prose h2:first-child {
@@ -71,11 +71,11 @@ html .dark {
} }
.prose h3 { .prose h3 {
@apply mt-8 text-2xl font-semibold; @apply mt-0 text-2xl font-normal;
} }
.prose h4 { .prose h4 {
@apply mt-8 text-xl font-semibold; @apply mt-0 text-xl font-normal;
} }
.prose p, .prose p,
+2 -2
View File
@@ -1,5 +1,5 @@
import { getSessionData } from '$lib/utils/services/auth/session'; import { getSessionData } from '$lib/utils/services/auth/session';
import { getCioCookieString } from '$lib/utils/functions/cookies'; import { getHasCioCookies } from '$lib/utils/functions/cookies';
import { applyCspExtensions } from '$lib/utils/csp'; import { applyCspExtensions } from '$lib/utils/csp';
import { type Handle, redirect } from '@sveltejs/kit'; import { type Handle, redirect } from '@sveltejs/kit';
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute'; import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
@@ -26,7 +26,7 @@ export const handle: Handle = async (args) => {
const handlePagesRoutes: Handle = async ({ event, resolve }) => { const handlePagesRoutes: Handle = async ({ event, resolve }) => {
const { pathname } = event.url; const { pathname } = event.url;
const hasCioCookie = Boolean(getCioCookieString(event.cookies)); const hasCioCookie = getHasCioCookies(event.cookies);
if (isPublicRoute(pathname)) { if (isPublicRoute(pathname)) {
return resolve(event); return resolve(event);
@@ -119,7 +119,8 @@ class AppInitApi extends BaseApi {
} }
// This allows you to be on the landing page of an organization site and not be redirected // This allows you to be on the landing page of an organization site and not be redirected
if (isOrgSite && isPublicRoute(window.location.pathname)) { const path = window.location.pathname;
if (isPublicRoute(path) && (path !== '/' || isOrgSite)) {
console.log('no redirect is needed'); console.log('no redirect is needed');
return; return;
} }
@@ -0,0 +1,102 @@
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { CheckboxField, MultiSelectList } from '@cio/ui';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$features/org/api/org.svelte';
import { snackbar } from '$features/ui/snackbar/store';
import { SvelteSet } from 'svelte/reactivity';
interface Course {
id: string;
title: string;
}
interface Props {
open: boolean;
selectedProfileIds: string[];
courses: Course[];
onclose: () => void;
onassigned: () => void;
}
let { open = $bindable(), selectedProfileIds, courses, onclose, onassigned }: Props = $props();
let fields = $state({
selectedCourseIds: new SvelteSet<string>(),
sendEmail: true
});
let isSubmitting = $state(false);
function toggleCourse(courseId: string) {
if (fields.selectedCourseIds.has(courseId)) {
fields.selectedCourseIds.delete(courseId);
} else {
fields.selectedCourseIds.add(courseId);
}
}
function handleClose() {
fields.selectedCourseIds.clear();
fields.sendEmail = true;
onclose();
}
async function handleSubmit() {
if (fields.selectedCourseIds.size === 0) {
snackbar.error('audience.assign.snackbar_no_courses');
return;
}
isSubmitting = true;
try {
await orgApi.assignAudienceToCourses({
profileIds: selectedProfileIds,
courseIds: [...fields.selectedCourseIds],
sendEmail: fields.sendEmail
});
fields.selectedCourseIds.clear();
fields.sendEmail = true;
onassigned();
} catch {
snackbar.error('audience.assign.snackbar_failed');
} finally {
isSubmitting = false;
}
}
</script>
<Dialog.Root bind:open onOpenChange={(v) => !v && handleClose()}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('audience.assign.title')}</Dialog.Title>
<Dialog.Description>{$t('audience.assign.description')}</Dialog.Description>
</Dialog.Header>
<MultiSelectList
heading={$t('audience.assign.select_courses')}
emptyMessage={$t('audience.assign.select_courses_placeholder')}
items={courses.map((c) => ({ id: c.id, label: c.title || c.id }))}
isSelected={(id) => fields.selectedCourseIds.has(id)}
onToggle={toggleCourse}
namePrefix="assign-course"
/>
<CheckboxField name="assign-send-email" label={$t('audience.assign.send_email')} bind:checked={fields.sendEmail} />
<Dialog.Footer class="mt-4">
<Button variant="outline" onclick={handleClose}>
{$t('audience.assign.cancel')}
</Button>
<Button
onclick={handleSubmit}
loading={isSubmitting}
disabled={isSubmitting || fields.selectedCourseIds.size === 0}
>
{$t('audience.assign.submit')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,102 @@
<script lang="ts">
import * as Table from '@cio/ui/base/table';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as Avatar from '@cio/ui/base/avatar';
import { Checkbox } from '@cio/ui/base/checkbox';
import { Badge } from '@cio/ui/base/badge';
import UserIcon from '@lucide/svelte/icons/user';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import type { OrganizationAudienceMember } from '$features/org/utils/types';
import { t } from '$lib/utils/functions/translations';
import {
canResendAudienceInvite,
canRevokeAudienceInvite,
statusBadgeVariant,
statusLabelKey
} from '$features/audience/utils/audience-utils';
interface Props {
row: OrganizationAudienceMember;
memberDetailHref: string | null;
selected: boolean;
onToggleSelect: () => void;
inviteActionEmail: string | null;
onResendInvite: (email: string) => void;
onRevokeInvite: (email: string) => void;
}
let { row, memberDetailHref, selected, onToggleSelect, inviteActionEmail, onResendInvite, onRevokeInvite }: Props =
$props();
const showInviteActions = $derived(
!row.profileId && (canResendAudienceInvite(row.status) || canRevokeAudienceInvite(row.status))
);
</script>
<Table.Row>
<Table.Cell class="w-10">
<Checkbox disabled={!row.profileId} checked={selected} onCheckedChange={onToggleSelect} />
</Table.Cell>
<Table.Cell>
{#if memberDetailHref}
<a href={memberDetailHref} class="ui:text-primary flex items-center gap-2 hover:underline">
<Avatar.Root class="h-5 w-5">
{#if row.avatarUrl}
<Avatar.Image src={row.avatarUrl} alt={row.name ? row.name : 'User'} />
{/if}
<Avatar.Fallback>
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root>
{row.name}
</a>
{:else}
<span class="flex items-center gap-2">
<Avatar.Root class="h-5 w-5">
{#if row.avatarUrl}
<Avatar.Image src={row.avatarUrl} alt={row.name ? row.name : 'User'} />
{/if}
<Avatar.Fallback>
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root>
{row.name}
</span>
{/if}
</Table.Cell>
<Table.Cell>{row.email}</Table.Cell>
<Table.Cell>
<Badge variant={statusBadgeVariant(row.status)}>{$t(statusLabelKey(row.status))}</Badge>
</Table.Cell>
<Table.Cell>{row.createdAt}</Table.Cell>
<Table.Cell class="text-right">
{#if showInviteActions}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="hover:ui:bg-muted inline-flex items-center justify-center rounded-md p-1.5"
aria-label={$t('audience.invite.row_actions_aria')}
disabled={inviteActionEmail !== null}
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon class="ui:size-4 ui:text-muted-foreground" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if canResendAudienceInvite(row.status)}
<DropdownMenu.Item disabled={inviteActionEmail === row.email} onclick={() => onResendInvite(row.email)}>
{$t('audience.invite.resend')}
</DropdownMenu.Item>
{/if}
{#if canRevokeAudienceInvite(row.status)}
<DropdownMenu.Item
class="ui:text-destructive focus:ui:text-destructive"
disabled={inviteActionEmail === row.email}
onclick={() => onRevokeInvite(row.email)}
>
{$t('audience.invite.revoke')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</Table.Cell>
</Table.Row>
@@ -0,0 +1,40 @@
<script lang="ts">
import * as Pagination from '@cio/ui/base/pagination';
interface Props {
count: number;
perPage: number;
page: number;
onPageChange: (page: number) => void;
}
let { count, perPage, page, onPageChange }: Props = $props();
</script>
<div class="flex items-center justify-between">
<Pagination.Root {count} {perPage} {page} {onPageChange}>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as pageItem (pageItem.key)}
{#if pageItem.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link page={pageItem} isActive={activePage === pageItem.value}>
{pageItem.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
interface Props {
rowCount?: number;
}
let { rowCount = 20 }: Props = $props();
</script>
<div class="space-y-2">
{#each Array(rowCount) as _, i (i)}
<Skeleton class="h-12 w-full" />
{/each}
</div>
@@ -0,0 +1,30 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { Search } from '@cio/ui/custom/search';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
interface Props {
hasSelection: boolean;
selectedCount: number;
searchValue?: string;
onOpenAssign: () => void;
}
let { hasSelection, selectedCount, searchValue = $bindable(''), onOpenAssign }: Props = $props();
</script>
<Page.BodyHeader>
{#if hasSelection}
<div class="flex items-center gap-3 rounded-md border px-4 py-2">
<span class="ui:text-muted-foreground text-sm">
{$t('audience.selected_count', { count: selectedCount })}
</span>
<Button variant="secondary" size="sm" onclick={onOpenAssign}>
{$t('audience.assign_courses')}
</Button>
</div>
{:else}
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="" />
{/if}
</Page.BodyHeader>
@@ -0,0 +1,80 @@
<script lang="ts">
import * as Table from '@cio/ui/base/table';
import { Checkbox } from '@cio/ui/base/checkbox';
import type { OrganizationAudienceMember } from '$features/org/utils/types';
import { t } from '$lib/utils/functions/translations';
import AudienceMemberRow from './audience-member-row.svelte';
import { resolve } from '$app/paths';
interface Header {
key: string;
value: string;
}
interface Props {
headers: Header[];
rows: OrganizationAudienceMember[];
allPageSelected: boolean;
somePageSelected: boolean;
onToggleSelectAll: () => void;
isRowSelected: (rowId: string) => boolean;
onToggleRow: (rowId: string) => void;
inviteActionEmail: string | null;
onResendInvite: (email: string) => void;
onRevokeInvite: (email: string) => void;
}
let {
headers,
rows,
allPageSelected,
somePageSelected,
onToggleSelectAll,
isRowSelected,
onToggleRow,
inviteActionEmail,
onResendInvite,
onRevokeInvite
}: Props = $props();
function memberHref(row: OrganizationAudienceMember): string | null {
if (!row.profileId) return null;
return resolve(`${window.location.pathname}/${row.profileId}`, {});
}
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-10">
<Checkbox
checked={allPageSelected}
indeterminate={somePageSelected}
onCheckedChange={onToggleSelectAll}
aria-label={$t('audience.select_all')}
/>
</Table.Head>
{#each headers as header (header)}
<Table.Head>{header.value}</Table.Head>
{/each}
<Table.Head class="w-12 text-right">
<span class="sr-only">{$t('audience.invite.row_actions_aria')}</span>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as row (String(row.id))}
<AudienceMemberRow
{row}
memberDetailHref={memberHref(row)}
selected={isRowSelected(String(row.id))}
onToggleSelect={() => onToggleRow(String(row.id))}
{inviteActionEmail}
{onResendInvite}
{onRevokeInvite}
/>
{/each}
</Table.Body>
</Table.Root>
</div>
@@ -1 +1,6 @@
// Components exports will be added here when needed export { default as AssignCoursesModal } from './assign-courses-modal.svelte';
export { default as AudienceMemberRow } from './audience-member-row.svelte';
export { default as AudiencePagination } from './audience-pagination.svelte';
export { default as AudienceTable } from './audience-table.svelte';
export { default as AudienceTableSkeleton } from './audience-table-skeleton.svelte';
export { default as AudienceTableToolbar } from './audience-table-toolbar.svelte';
@@ -1,41 +1,68 @@
<script lang="ts"> <script lang="ts">
import * as Table from '@cio/ui/base/table';
import { page } from '$app/state';
import { Skeleton } from '@cio/ui/base/skeleton';
import { Search } from '@cio/ui/custom/search';
import * as Pagination from '@cio/ui/base/pagination';
import UsersIcon from '@lucide/svelte/icons/users'; import UsersIcon from '@lucide/svelte/icons/users';
import { orgApi } from '$features/org/api/org.svelte'; import { orgApi } from '$features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty'; import { Empty } from '@cio/ui/custom/empty';
import { UpgradeBanner } from '$features/ui'; import { UpgradeBanner } from '$features/ui';
import { currentOrgMaxAudience } from '$lib/utils/store/org'; import { currentOrgMaxAudience } from '$lib/utils/store/org';
import * as Avatar from '@cio/ui/base/avatar';
import * as Page from '@cio/ui/base/page';
import { shortenName } from '$lib/utils/functions/string';
import type { OrganizationAudience } from '$features/org/utils/types'; import type { OrganizationAudience } from '$features/org/utils/types';
import AssignCoursesModal from '$features/audience/components/assign-courses-modal.svelte';
import AudienceTableToolbar from '$features/audience/components/audience-table-toolbar.svelte';
import AudienceTableSkeleton from '$features/audience/components/audience-table-skeleton.svelte';
import AudienceTable from '$features/audience/components/audience-table.svelte';
import AudiencePagination from '$features/audience/components/audience-pagination.svelte';
import { SvelteSet } from 'svelte/reactivity';
interface Course {
id: string;
title: string;
}
interface Props { interface Props {
audience?: OrganizationAudience; audience?: OrganizationAudience;
courses?: Course[];
} }
let { audience }: Props = $props(); let { audience, courses = [] }: Props = $props();
// Initialize audience from prop if provided
$effect(() => { $effect(() => {
if (audience) { if (audience) {
orgApi.audience = audience; orgApi.audience = audience;
} }
}); });
const headers = [ const headers = $derived([
{ key: 'name', value: $t('audience.name') }, { key: 'name', value: t.get('audience.name') },
{ key: 'email', value: $t('audience.email') }, { key: 'email', value: t.get('audience.email') },
{ key: 'date_joined', value: $t('audience.date_joined') } { key: 'status', value: t.get('audience.status') },
]; { key: 'date_joined', value: t.get('audience.date_joined') }
let pageSize = $state(5); ]);
let inviteActionEmail = $state<string | null>(null);
async function handleResendInvite(email: string) {
inviteActionEmail = email;
try {
await orgApi.resendAudienceInvite({ email });
} finally {
inviteActionEmail = null;
}
}
async function handleRevokeInvite(email: string) {
inviteActionEmail = email;
try {
await orgApi.revokeAudienceInvite({ email });
} finally {
inviteActionEmail = null;
}
}
let pageSize = $state(20);
let currentPage = $state(1); let currentPage = $state(1);
let searchValue = $state(''); let searchValue = $state('');
let selectedIds = new SvelteSet<string>();
let assignModalOpen = $state(false);
const filteredRows = $derived( const filteredRows = $derived(
searchValue searchValue
@@ -50,6 +77,45 @@
const endIndex = $derived(startIndex + pageSize); const endIndex = $derived(startIndex + pageSize);
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex)); const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
const selectablePageRows = $derived(paginatedRows.filter((row) => row.profileId));
const allPageSelected = $derived(
selectablePageRows.length > 0 && selectablePageRows.every((row) => selectedIds.has(String(row.id)))
);
const somePageSelected = $derived(
selectablePageRows.some((row) => selectedIds.has(String(row.id))) && !allPageSelected
);
const hasSelection = $derived(selectedIds.size > 0);
function toggleSelectAll() {
if (allPageSelected) {
for (const row of selectablePageRows) {
selectedIds.delete(String(row.id));
}
} else {
for (const row of selectablePageRows) {
selectedIds.add(String(row.id));
}
}
}
function toggleRow(id: string) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
}
const selectedProfileIds = $derived(
orgApi.audience.filter((r) => selectedIds.has(String(r.id)) && r.profileId).map((r) => r.profileId!)
);
function handleAssigned() {
assignModalOpen = false;
selectedIds.clear();
}
$effect(() => { $effect(() => {
if (filteredRows.length > 0 && currentPage > totalPages) { if (filteredRows.length > 0 && currentPage > totalPages) {
currentPage = 1; currentPage = 1;
@@ -61,90 +127,47 @@
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner> <UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
{/if} {/if}
<Page.BodyHeader> <AudienceTableToolbar
<Search placeholder="Search..." bind:value={searchValue} class="" /> {hasSelection}
</Page.BodyHeader> selectedCount={selectedIds.size}
bind:searchValue
onOpenAssign={() => (assignModalOpen = true)}
/>
{#if orgApi.audience.length || orgApi.isLoading} {#if orgApi.audience.length || orgApi.isLoading}
<div class="w-full space-y-4"> <div class="w-full space-y-4">
<!-- Table -->
{#if orgApi.isLoading} {#if orgApi.isLoading}
<div class="space-y-2"> <AudienceTableSkeleton rowCount={pageSize} />
{#each Array(pageSize) as _, i (i)}
<Skeleton class="h-12 w-full" />
{/each}
</div>
{:else} {:else}
<div class="rounded-md border"> <AudienceTable
<Table.Root> {headers}
<Table.Header> rows={paginatedRows}
<Table.Row> {allPageSelected}
{#each headers as header (header)} {somePageSelected}
<Table.Head>{header.value}</Table.Head> onToggleSelectAll={toggleSelectAll}
{/each} isRowSelected={(id) => selectedIds.has(id)}
</Table.Row> onToggleRow={toggleRow}
</Table.Header> {inviteActionEmail}
<Table.Body> onResendInvite={handleResendInvite}
{#each paginatedRows as row (row.id)} onRevokeInvite={handleRevokeInvite}
<Table.Row> />
<Table.Cell>
<a
href={`${page.url.href}/${row.id}`}
class="ui:text-primary flex items-center gap-2 hover:underline"
>
<Avatar.Root class="h-5 w-5">
<Avatar.Image
src={row.avatarUrl ? row.avatarUrl : '/logo-192.png'}
alt={row.name ? row.name : 'User'}
/>
<Avatar.Fallback>{shortenName(row.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
{row.name}
</a>
</Table.Cell>
<Table.Cell>{row.email}</Table.Cell>
<Table.Cell>{row.createdAt}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if} {/if}
<!-- Pagination --> <AudiencePagination
<div class="flex items-center justify-between"> count={filteredRows.length}
<Pagination.Root perPage={pageSize}
count={filteredRows.length} page={currentPage}
perPage={pageSize} onPageChange={(p) => (currentPage = p)}
page={currentPage} />
onPageChange={(page) => (currentPage = page)}
>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={activePage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
</div> </div>
{:else} {:else}
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" /> <Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
{/if} {/if}
<AssignCoursesModal
bind:open={assignModalOpen}
{selectedProfileIds}
{courses}
onclose={() => (assignModalOpen = false)}
onassigned={handleAssigned}
/>
@@ -0,0 +1,118 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { MultiSelectList } from '@cio/ui';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { Label } from '@cio/ui/base/label';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$features/org/api/org.svelte';
import { snackbar } from '$features/ui/snackbar/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { SvelteSet } from 'svelte/reactivity';
interface Course {
id: string;
title: string;
}
interface Props {
courses: Course[];
}
let { courses }: Props = $props();
let recipientCsv = $state('');
let courseAccessMode = $state('none');
let selectedCourseIds = new SvelteSet<string>();
let sendEmail = $state(true);
let isSubmitting = $state(false);
function toggleCourse(courseId: string) {
if (selectedCourseIds.has(courseId)) {
selectedCourseIds.delete(courseId);
} else {
selectedCourseIds.add(courseId);
}
}
const audiencePath = $derived(page.url.pathname.replace(/\/import$/, ''));
async function handleSubmit() {
if (!recipientCsv.trim()) {
snackbar.error('audience.import.snackbar_no_emails');
return;
}
isSubmitting = true;
try {
const result = await orgApi.importAudienceMembers({
recipientCsv,
allCourses: courseAccessMode === 'all',
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
sendEmail
});
if (result) {
await goto(resolve(audiencePath, {}));
}
} catch {
snackbar.error('audience.import.snackbar_failed');
} finally {
isSubmitting = false;
}
}
</script>
<div class="space-y-6">
<p class="ui:text-muted-foreground text-sm">{$t('audience.import.description')}</p>
<TextareaField
label={$t('audience.import.emails_label')}
bind:value={recipientCsv}
rows={6}
className="w-full"
placeholder={$t('audience.import.emails_placeholder')}
/>
<div class="space-y-3">
<Label class="text-sm font-medium">{$t('audience.import.course_access')}</Label>
<RadioGroup.Root bind:value={courseAccessMode} class="space-y-2">
<div class="flex items-center gap-2">
<RadioGroup.Item value="none" id="course-none" />
<Label for="course-none" class="font-normal">{$t('audience.import.no_courses')}</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="all" id="course-all" />
<Label for="course-all" class="font-normal">{$t('audience.import.all_courses')}</Label>
</div>
<div class="flex items-center gap-2">
<RadioGroup.Item value="select" id="course-select" />
<Label for="course-select" class="font-normal">{$t('audience.import.select_courses')}</Label>
</div>
</RadioGroup.Root>
{#if courseAccessMode === 'select'}
<MultiSelectList
class="ml-6"
listClass="max-h-40"
heading={$t('audience.assign.select_courses')}
emptyMessage={$t('audience.import.select_courses_placeholder')}
items={courses.map((c) => ({ id: c.id, label: c.title || c.id }))}
isSelected={(id) => selectedCourseIds.has(id)}
onToggle={toggleCourse}
namePrefix="import-course"
/>
{/if}
</div>
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
<div class="flex gap-3">
<Button onclick={handleSubmit} loading={isSubmitting} disabled={isSubmitting}>
{$t('audience.import.submit')}
</Button>
</div>
</div>
@@ -1,2 +1,3 @@
export { default as AudiencePage } from './audience.svelte'; export { default as AudiencePage } from './audience.svelte';
export { default as ImportAudiencePage } from './import.svelte';
export { default as UserAnalyticsPage } from './user-analytics.svelte'; export { default as UserAnalyticsPage } from './user-analytics.svelte';
@@ -4,11 +4,11 @@
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical'; import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
import BookOpenIcon from '@lucide/svelte/icons/book-open'; import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ChartLineIcon from '@lucide/svelte/icons/chart-line'; import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import { Progress } from '@cio/ui/base/progress'; import { Progress } from '@cio/ui/base/progress';
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui'; import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
import { BackButton } from '@cio/ui';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org'; import { currentOrgPath } from '$lib/utils/store/org';
import type { UserAnalytics } from '$lib/utils/types/analytics'; import type { UserAnalytics } from '$lib/utils/types/analytics';
@@ -61,10 +61,7 @@
{#if userAnalytics} {#if userAnalytics}
<div class="p-5"> <div class="p-5">
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}> <BackButton href={`${$currentOrgPath}/audience`} label={$t('community.ask.go_back')} />
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
</div> </div>
<div class="px-5 py-1"> <div class="px-5 py-1">
@@ -116,7 +113,7 @@
</div> </div>
</div> </div>
{#each filteredCourses as course, index} {#each filteredCourses as course (course.id)}
{#key index} {#key index}
<div <div
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${ class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
@@ -0,0 +1,38 @@
import type { BadgeVariant } from '@cio/ui/base/badge';
export function statusBadgeVariant(status: string): BadgeVariant {
switch (status) {
case 'active':
return 'secondary';
case 'pending':
return 'outline';
case 'expired':
case 'revoked':
return 'destructive';
default:
return 'outline';
}
}
export function statusLabelKey(status: string): string {
switch (status) {
case 'active':
return 'audience.status_active';
case 'pending':
return 'audience.status_pending';
case 'expired':
return 'audience.status_expired';
case 'revoked':
return 'audience.status_revoked';
default:
return 'audience.status_pending';
}
}
export function canResendAudienceInvite(status: string): boolean {
return status === 'pending' || status === 'expired' || status === 'revoked';
}
export function canRevokeAudienceInvite(status: string): boolean {
return status === 'pending';
}
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import * as Avatar from '@cio/ui/base/avatar'; import * as Avatar from '@cio/ui/base/avatar';
import TrashIcon from '@lucide/svelte/icons/trash'; import TrashIcon from '@lucide/svelte/icons/trash';
import UserIcon from '@lucide/svelte/icons/user';
import { calDateDiff } from '$lib/utils/functions/date'; import { calDateDiff } from '$lib/utils/functions/date';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
import { shortenName } from '$lib/utils/functions/string';
import { Vote } from '$features/ui'; import { Vote } from '$features/ui';
import { CircleCheckIcon } from '$features/ui/icons'; import { CircleCheckIcon } from '$features/ui/icons';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
@@ -40,11 +40,15 @@
<header class="flex items-center justify-between p-2 leading-none"> <header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black"> <div class="flex items-center text-black">
<Avatar.Root class="size-7"> <Avatar.Root class="size-7">
<Avatar.Image {#if comment.author?.avatarUrl}
src={comment.author?.avatarUrl ? comment.author.avatarUrl : '/logo-192.png'} <Avatar.Image
alt={comment.author?.fullname ? comment.author.fullname : 'User'} src={comment.author.avatarUrl}
/> alt={comment.author?.fullname ? comment.author.fullname : 'User'}
<Avatar.Fallback>{shortenName(comment.author?.fullname) || 'U'}</Avatar.Fallback> />
{/if}
<Avatar.Fallback>
<UserIcon class="ui:size-3.5 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<p class="ml-2 text-sm dark:text-white">{comment.author?.fullname}</p> <p class="ml-2 text-sm dark:text-white">{comment.author?.fullname}</p>
@@ -5,6 +5,7 @@
import * as Avatar from '@cio/ui/base/avatar'; import * as Avatar from '@cio/ui/base/avatar';
import { Skeleton } from '@cio/ui/base/skeleton'; import { Skeleton } from '@cio/ui/base/skeleton';
import TrashIcon from '@lucide/svelte/icons/trash'; import TrashIcon from '@lucide/svelte/icons/trash';
import UserIcon from '@lucide/svelte/icons/user';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date'; import { calDateDiff } from '$lib/utils/functions/date';
@@ -17,19 +18,20 @@
import { Vote } from '$features/ui'; import { Vote } from '$features/ui';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field'; import { InputField } from '@cio/ui/custom/input-field';
import { shortenName } from '$lib/utils/functions/string';
import { TextEditor } from '$features/ui'; import { TextEditor } from '$features/ui';
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components'; import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
import { BackButton } from '@cio/ui';
import type { CommunityQuestionSuccess } from '../utils/types'; import type { CommunityQuestionSuccess } from '../utils/types';
import { currentCommunityQuestion } from '../utils/store'; import { currentCommunityQuestion } from '../utils/store';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface Props { interface Props {
slug: string; slug: string;
backHref?: string;
question?: CommunityQuestionSuccess['data'] | null; question?: CommunityQuestionSuccess['data'] | null;
} }
let { question }: Props = $props(); let { question, backHref }: Props = $props();
let commentEditor: TiptapEditor | undefined = $state(); let commentEditor: TiptapEditor | undefined = $state();
let isValidAnswer = false; // V2 allow admin mark an answer as accepted let isValidAnswer = false; // V2 allow admin mark an answer as accepted
@@ -100,6 +102,9 @@
<Page.Root class="mx-auto w-[90%] md:mx-10 md:max-w-3xl lg:mb-20"> <Page.Root class="mx-auto w-[90%] md:mx-10 md:max-w-3xl lg:mb-20">
<Page.Header> <Page.Header>
<Page.HeaderContent> <Page.HeaderContent>
{#if backHref}
<BackButton href={backHref} label={$t('community.ask.go_back')} />
{/if}
{#if communityApi.isEditMode} {#if communityApi.isEditMode}
<div class="flex w-full items-center gap-2"> <div class="flex w-full items-center gap-2">
<InputField <InputField
@@ -175,13 +180,15 @@
<div class="flex items-center justify-between p-2 leading-none"> <div class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline"> <div class="flex items-center text-black no-underline hover:underline">
<Avatar.Root class="h-7 w-7"> <Avatar.Root class="h-7 w-7">
<Avatar.Image {#if communityApi.question?.authorAvatarUrl}
src={communityApi.question?.authorAvatarUrl <Avatar.Image
? communityApi.question?.authorAvatarUrl src={communityApi.question.authorAvatarUrl}
: '/logo-192.png'} alt={communityApi.question?.authorFullname ? communityApi.question.authorFullname : 'User'}
alt={communityApi.question?.authorFullname ? communityApi.question?.authorFullname : 'User'} />
/> {/if}
<Avatar.Fallback>{shortenName(communityApi.question?.authorFullname) || 'U'}</Avatar.Fallback> <Avatar.Fallback>
<UserIcon class="ui:size-3.5 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<p class="ml-2 text-sm dark:text-white">{communityApi.question?.authorFullname}</p> <p class="ml-2 text-sm dark:text-white">{communityApi.question?.authorFullname}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white"> <p class="ml-2 text-sm text-gray-500 dark:text-white">
@@ -4,6 +4,8 @@
import { Empty } from '@cio/ui/custom/empty'; import { Empty } from '@cio/ui/custom/empty';
import * as Item from '@cio/ui/base/item'; import * as Item from '@cio/ui/base/item';
import { Search } from '@cio/ui/custom/search'; import { Search } from '@cio/ui/custom/search';
import { Button } from '@cio/ui/base/button';
import { resolve } from '$app/paths';
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more'; import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus'; import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
@@ -62,10 +64,8 @@
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
<Select.Item value="">{$t('community.all')}</Select.Item> <Select.Item value="">{$t('community.all')}</Select.Item>
{#each communityApi.courses as course} {#each communityApi.courses as course (course.id)}
{#if course.id} <Select.Item value={course.id}>{course.title}</Select.Item>
<Select.Item value={course.id}>{course.title}</Select.Item>
{/if}
{/each} {/each}
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
@@ -77,10 +77,10 @@
<CommunityListLoader /> <CommunityListLoader />
<CommunityListLoader /> <CommunityListLoader />
{:else} {:else}
{#each filteredQuestions as question} {#each filteredQuestions as question (question.slug)}
<Item.Root variant="outline"> <Item.Root variant="outline">
{#snippet child({ props })} {#snippet child({ props })}
<a href="{$basePath}/community/{question.slug}" {...props}> <a href={resolve(`${$basePath}/community/[slug]`, { slug: question.slug })} {...props}>
<Vote value={question.votes} /> <Vote value={question.votes} />
<Item.Content class="gap-y-0.5"> <Item.Content class="gap-y-0.5">
<Item.Title class="mt-0"> <Item.Title class="mt-0">
@@ -89,11 +89,15 @@
<Item.Description> <Item.Description>
{question?.authorFullname} asked {calDateDiff(question.createdAt || new Date())} {question?.authorFullname} asked {calDateDiff(question.createdAt || new Date())}
</Item.Description> </Item.Description>
<a class="m-0" href="/courses/{question.courseId}" onclick={(e) => e.stopPropagation()}> <Button
class="m-0! justify-start! px-0! py-0! underline"
href={resolve(`/courses/[id]`, { id: question.courseId })}
variant="ghost"
>
<span class="text-muted-foreground p-0 text-xs"> <span class="text-muted-foreground p-0 text-xs">
#{question?.courseTitle} #{question?.courseTitle}
</span> </span>
</a> </Button>
</Item.Content> </Item.Content>
<Item.Actions> <Item.Actions>
<div class="flex items-center"> <div class="flex items-center">
@@ -24,6 +24,7 @@ import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import { coursesApi } from './courses.svelte';
import { ROLE } from '@cio/utils/constants'; import { ROLE } from '@cio/utils/constants';
import { ContentType } from '@cio/utils/constants/content'; import { ContentType } from '@cio/utils/constants/content';
import type { CourseMembers } from '../utils/types'; import type { CourseMembers } from '../utils/types';
@@ -499,6 +500,7 @@ export class CourseApi extends BaseApiWithErrors {
}), }),
logContext: 'deleting course', logContext: 'deleting course',
onSuccess: (response) => { onSuccess: (response) => {
coursesApi.removeCourseFromLists(courseId);
if (response.data) { if (response.data) {
snackbar.success('Course deleted successfully'); snackbar.success('Course deleted successfully');
this.success = true; this.success = true;
@@ -8,6 +8,8 @@ import type {
UserEnrolledCourses UserEnrolledCourses
} from '$features/course/types'; } from '$features/course/types';
import { SvelteSet } from 'svelte/reactivity';
/** /**
* API class for course operations * API class for course operations
*/ */
@@ -16,12 +18,19 @@ export class CoursesApi extends BaseApiWithErrors {
enrolledCourses = $state<UserEnrolledCourses>([]); enrolledCourses = $state<UserEnrolledCourses>([]);
recommendedCourses = $state<RecommendedCourses>([]); recommendedCourses = $state<RecommendedCourses>([]);
/** Keeps list UIs in sync after a course is deleted (server data is updated separately on navigation). */
removeCourseFromLists(courseId: string) {
this.orgCourses = this.orgCourses.filter((c) => c.id !== courseId);
this.enrolledCourses = this.enrolledCourses.filter((c) => c.id !== courseId);
this.recommendedCourses = this.recommendedCourses.filter((c) => c.id !== courseId);
}
/** /**
* Fetches org courses for the current organization * Fetches org courses for the current organization
* Org ID is automatically added from currentOrg store * Org ID is automatically added from currentOrg store
*/ */
async getOrgCourses(tagSlugs: string[] = []) { async getOrgCourses(tagSlugs: string[] = []) {
const normalizedTagSlugs = Array.from(new Set(tagSlugs.map((tag) => tag.trim()).filter(Boolean))); const normalizedTagSlugs = Array.from(new SvelteSet(tagSlugs.map((tag) => tag.trim()).filter(Boolean)));
return this.execute<GetOrgCoursesRequest>({ return this.execute<GetOrgCoursesRequest>({
requestFn: () => requestFn: () =>
@@ -32,7 +41,7 @@ export class CoursesApi extends BaseApiWithErrors {
tags: normalizedTagSlugs.join(',') tags: normalizedTagSlugs.join(',')
} }
} }
: {} : { query: {} }
), ),
logContext: 'fetching org courses', logContext: 'fetching org courses',
onSuccess: (response) => { onSuccess: (response) => {
@@ -77,7 +77,6 @@ export class LessonApi extends BaseApiWithErrors {
translations = $state<Record<string, Record<TLocale, string>>>({}); translations = $state<Record<string, Record<TLocale, string>>>({});
note = $state(''); note = $state('');
private autoSaveTimeoutId: NodeJS.Timeout | null = null;
/** /**
* Gets a lesson by ID * Gets a lesson by ID
@@ -841,23 +840,6 @@ export class LessonApi extends BaseApiWithErrors {
) )
]); ]);
} }
/**
* Starts autosave with debouncing
* @param courseId Course ID
* @param lessonId Lesson ID
*/
startAutoSave(courseId: string, lessonId: string) {
if (!this.isDirty || this.isSaving) return;
if (this.autoSaveTimeoutId) clearTimeout(this.autoSaveTimeoutId);
this.isSaving = true;
this.autoSaveTimeoutId = setTimeout(async () => {
await this.save(courseId, lessonId);
this.isSaving = false;
this.autoSaveTimeoutId = null;
}, 1500);
}
} }
export const lessonApi = new LessonApi(); export const lessonApi = new LessonApi();
@@ -13,7 +13,6 @@
import EmptyState from './empty-state.svelte'; import EmptyState from './empty-state.svelte';
import * as Avatar from '@cio/ui/base/avatar'; import * as Avatar from '@cio/ui/base/avatar';
import type { CourseAnalytics } from '$features/course/utils/types'; import type { CourseAnalytics } from '$features/course/utils/types';
import { shortenName } from '$lib/utils/functions/string';
interface Props { interface Props {
students?: CourseAnalytics['students']; students?: CourseAnalytics['students'];
@@ -37,7 +36,10 @@
if (!student) return; if (!student) return;
goto( goto(
resolve(`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`) resolve(
`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`,
{}
)
); );
}; };
@@ -79,11 +81,15 @@
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Avatar.Root class="size-8"> <Avatar.Root class="size-8">
<Avatar.Image {#if student.profile.avatar_url}
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'} <Avatar.Image
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')} src={student.profile.avatar_url}
/> alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback> />
{/if}
<Avatar.Fallback>
<UserIcon class="ui:size-4 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate font-medium text-gray-900 dark:text-white"> <p class="truncate font-medium text-gray-900 dark:text-white">
@@ -21,6 +21,7 @@
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions'; import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
import CardDropdown from './card-dropdown.svelte'; import CardDropdown from './card-dropdown.svelte';
import CoursePublishBadge from './course-publish-badge.svelte'; import CoursePublishBadge from './course-publish-badge.svelte';
import CourseTagsOverflow from './course-tags-overflow.svelte';
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types'; import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
import type { OrgPublicCourses } from '$features/org/utils/types'; import type { OrgPublicCourses } from '$features/org/utils/types';
@@ -57,7 +58,7 @@
title: course.title, title: course.title,
type: course.type, type: course.type,
description: course.description, description: course.description,
isPublished: course.isPublished, isPublished: !!course.isPublished,
pricingData: { pricingData: {
cost: course.cost, cost: course.cost,
discount: course.metadata?.discount || 0, discount: course.metadata?.discount || 0,
@@ -147,25 +148,14 @@
</Item.Header> </Item.Header>
<Item.Content> <Item.Content>
<Item.Title class="line-clamp-1 min-h-[44px] text-base!"> <Item.Title class="line-clamp-2 min-h-14 text-base!">
{title} {title}
</Item.Title> </Item.Title>
<Item.Description class="min-h-[63px]">{description}</Item.Description> <Item.Description class="min-h-[63px]">{description}</Item.Description>
<div class="ui:text-muted-foreground flex min-h-[12px] flex-wrap items-center gap-1"> {#if !isLMS}
{#if !isLMS && courseTags.length > 0} <CourseTagsOverflow tags={courseTags} variant="card" />
{#each courseTags as tag (tag.id)} {/if}
<Item.SubDescription class="border-border inline-flex items-center gap-1.5 rounded-full border px-2">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Item.SubDescription>
{/each}
{/if}
</div>
<Separator class="my-3" /> <Separator class="my-3" />
@@ -1,5 +1,11 @@
<script lang="ts"> <script lang="ts">
import { COURSE_SORT_OPTIONS } from '../utils/constants'; import {
COURSE_SORT_OPTIONS,
CourseSortOrder,
DEFAULT_COURSE_SORT,
DEFAULT_SORT_ORDER,
type CourseSortBy
} from '../utils/constants';
import * as Popover from '@cio/ui/base/popover'; import * as Popover from '@cio/ui/base/popover';
import * as ToggleGroup from '@cio/ui/base/toggle-group'; import * as ToggleGroup from '@cio/ui/base/toggle-group';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
@@ -27,8 +33,8 @@
type SortOption = (typeof COURSE_SORT_OPTIONS)[number]; type SortOption = (typeof COURSE_SORT_OPTIONS)[number];
interface Props { interface Props {
selectedId?: string; sortKey?: CourseSortBy;
selectedOrder?: 'asc' | 'desc'; selectedOrder?: CourseSortOrder;
selectedTags?: string[]; selectedTags?: string[];
tagGroups?: FilterTagGroup[]; tagGroups?: FilterTagGroup[];
sortOptions?: readonly SortOption[]; sortOptions?: readonly SortOption[];
@@ -38,8 +44,8 @@
} }
let { let {
selectedId = $bindable('0'), sortKey = $bindable(DEFAULT_COURSE_SORT),
selectedOrder = $bindable('desc' as 'asc' | 'desc'), selectedOrder = $bindable(DEFAULT_SORT_ORDER),
selectedTags = [], selectedTags = [],
tagGroups = [], tagGroups = [],
sortOptions = COURSE_SORT_OPTIONS, sortOptions = COURSE_SORT_OPTIONS,
@@ -50,7 +56,9 @@
let isOpen = $state(false); let isOpen = $state(false);
const hasActiveFilters = $derived(selectedId !== '0' || selectedOrder !== 'desc' || selectedTags.length > 0); const hasActiveFilters = $derived(
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
);
function isTagSelected(tagSlug: string) { function isTagSelected(tagSlug: string) {
return selectedTags.includes(tagSlug); return selectedTags.includes(tagSlug);
@@ -96,8 +104,8 @@
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant={selectedId === option.value ? 'secondary' : 'outline'} variant={sortKey === option.value ? 'secondary' : 'outline'}
onclick={() => (selectedId = option.value)} onclick={() => (sortKey = option.value)}
> >
{$t(option.label)} {$t(option.label)}
</Button> </Button>
@@ -115,13 +123,13 @@
size="sm" size="sm"
value={selectedOrder} value={selectedOrder}
onValueChange={(value) => { onValueChange={(value) => {
if (value === 'asc' || value === 'desc') { if (value === CourseSortOrder.Asc || value === CourseSortOrder.Desc) {
selectedOrder = value; selectedOrder = value;
} }
}} }}
> >
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item> <ToggleGroup.Item value={CourseSortOrder.Asc}>{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item> <ToggleGroup.Item value={CourseSortOrder.Desc}>{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root> </ToggleGroup.Root>
</div> </div>
@@ -0,0 +1,201 @@
<script lang="ts">
import { tick } from 'svelte';
import * as Tooltip from '@cio/ui/base/tooltip';
import * as Item from '@cio/ui/base/item';
import { t } from '$lib/utils/functions/translations';
interface Tag {
id: string;
name: string;
slug: string;
color?: string | null;
}
interface Props {
tags: Tag[];
variant?: 'card' | 'table';
}
let { tags, variant = 'card' }: Props = $props();
let tagsRowEl: HTMLDivElement | undefined = $state();
let tagsMeasureEl: HTMLDivElement | undefined = $state();
let overflowBadgeMeasureEl: HTMLSpanElement | undefined = $state();
/** -1 = not measured yet (show all tags until layout runs) */
let visibleTagCount = $state(-1);
const tagGapPx = $derived(variant === 'table' ? 8 : 4);
const gapClass = $derived(variant === 'table' ? 'gap-2' : 'gap-1');
function computeVisibleTagCount() {
if (tags.length === 0) {
visibleTagCount = 0;
return;
}
if (!tagsRowEl || !tagsMeasureEl) {
visibleTagCount = -1;
return;
}
const widths = Array.from(tagsMeasureEl.children).map((el) => (el as HTMLElement).offsetWidth);
const containerWidth = tagsRowEl.clientWidth;
const badgeMaxWidth = overflowBadgeMeasureEl?.offsetWidth ?? 40;
const n = widths.length;
const gap = tagGapPx;
if (containerWidth <= 0) {
visibleTagCount = n;
return;
}
let best = 0;
for (let k = 0; k <= n; k++) {
const rest = n - k;
let sum = 0;
for (let i = 0; i < k; i++) {
sum += widths[i] ?? 0;
if (i > 0) sum += gap;
}
const needBadge = rest > 0;
const total = sum + (needBadge ? gap + badgeMaxWidth : 0);
if (total <= containerWidth) {
best = k;
}
}
visibleTagCount = best;
}
const displayTagCount = $derived(visibleTagCount < 0 ? tags.length : visibleTagCount);
$effect(() => {
void tags;
void tagsRowEl;
void tagsMeasureEl;
void overflowBadgeMeasureEl;
void variant;
const schedule = () => {
requestAnimationFrame(() => {
computeVisibleTagCount();
});
};
tick().then(schedule);
if (!tagsRowEl) {
return;
}
const ro = new ResizeObserver(schedule);
ro.observe(tagsRowEl);
return () => ro.disconnect();
});
const hiddenTags = $derived(tags.slice(displayTagCount));
const hiddenTagsLabel = $derived(hiddenTags.map((t) => t.name).join(', '));
</script>
{#if tags.length > 0}
<div class="relative min-h-[12px] w-full min-w-0">
<div
class="pointer-events-none invisible absolute top-0 left-0 -z-10 flex {gapClass}"
bind:this={tagsMeasureEl}
aria-hidden="true"
>
{#each tags as tag (tag.id)}
{#if variant === 'card'}
<Item.SubDescription class="border-border inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Item.SubDescription>
{:else}
<span class="ui:text-muted-foreground text-2xs inline-flex shrink-0 items-center gap-1.5">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</span>
{/if}
{/each}
</div>
<span
bind:this={overflowBadgeMeasureEl}
class="border-border ui:text-muted-foreground pointer-events-none invisible absolute top-0 left-0 -z-10 inline-flex shrink-0 items-center rounded-full border {variant ===
'table'
? 'text-2xs px-1.5 py-0.5'
: 'px-2 text-xs'}"
aria-hidden="true"
>
+{tags.length}
</span>
<div
bind:this={tagsRowEl}
class="ui:text-muted-foreground flex min-w-0 flex-nowrap items-center overflow-hidden {gapClass} {variant ===
'table'
? 'text-2xs'
: ''}"
>
{#each tags.slice(0, displayTagCount) as tag (tag.id)}
{#if variant === 'card'}
<Item.SubDescription class="border-border inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Item.SubDescription>
{:else}
<span class="inline-flex shrink-0 items-center gap-1.5">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</span>
{/if}
{/each}
{#if hiddenTags.length > 0}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{#if variant === 'card'}
<Item.SubDescription
{...props}
class="border-border inline-flex shrink-0 cursor-default items-center gap-1.5 rounded-full border px-2"
aria-label={$t('courses.course_card.tags_overflow_aria', {
count: hiddenTags.length
})}
>
+{hiddenTags.length}
</Item.SubDescription>
{:else}
<span
{...props}
class="border-border ui:text-muted-foreground text-2xs inline-flex shrink-0 cursor-default items-center rounded-full border px-1.5 py-0.5"
aria-label={$t('courses.course_card.tags_overflow_aria', {
count: hiddenTags.length
})}
>
+{hiddenTags.length}
</span>
{/if}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={4} class="max-w-xs">
{hiddenTagsLabel}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
</div>
</div>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getExerciseQuestionContractKey } from '@cio/question-types'; import { getExerciseQuestionContractKey } from '@cio/question-types';
import type { AnswerData } from '@cio/question-types'; import type { AnswerData, ExerciseQuestionModel } from '@cio/question-types';
import type { Question } from '$features/course/types';
import { Grade, ReasonBox } from '$features/ui/question'; import { Grade, ReasonBox } from '$features/ui/question';
import { toExerciseQuestionModel } from './question-type-utils'; import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels'; import { getExerciseQuestionLabels } from './question-labels';
@@ -8,15 +9,15 @@
import type { QuestionnaireMetaData } from './store'; import type { QuestionnaireMetaData } from './store';
interface Props { interface Props {
questions?: any; questions?: Question[];
questionnaireMetaData?: Partial<QuestionnaireMetaData>; questionnaireMetaData?: Partial<QuestionnaireMetaData>;
/** Per-question scores (e.g. from `questionnaireMetaData.grades` or grading modal `questionAnswerByPoint`). */
grades?: Record<string, number>; grades?: Record<string, number>;
onSubmit?: any;
onPrevious?: any;
disableGrading?: boolean; disableGrading?: boolean;
isGradeWithAI?: boolean; isGradeWithAI?: boolean;
isLoading?: boolean; isLoading?: boolean;
reasons?: any; /** Optional AI grading copy keyed by question id. */
reasons?: Record<string, string>;
} }
let { let {
@@ -33,12 +34,12 @@
isGradeWithAI = false; isGradeWithAI = false;
} }
function rejectGradeSuggestion(questionId) { function rejectGradeSuggestion(questionId: Question['id']) {
isGradeWithAI = false; isGradeWithAI = false;
grades[questionId] = 0; grades[String(questionId)] = 0;
} }
function getAnswerForQuestion(questionModel, fallbackIndex = 0) { function getAnswerForQuestion(questionModel: ExerciseQuestionModel, fallbackIndex = 0): AnswerData | undefined {
const questionKey = getExerciseQuestionContractKey(questionModel, fallbackIndex); const questionKey = getExerciseQuestionContractKey(questionModel, fallbackIndex);
return _questionnaireMetaData?.answers?.[questionKey]; return _questionnaireMetaData?.answers?.[questionKey];
} }
@@ -59,11 +60,11 @@
{#if disableGrading && !Object.keys(grades || {}).length} {#if disableGrading && !Object.keys(grades || {}).length}
<ExerciseQuestion.QuestionList <ExerciseQuestion.QuestionList
contract={{ mode: 'view', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }} contract={{ mode: 'review', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
itemClass="mb-4" itemClass="mb-4"
/> />
{:else if disableGrading && Object.keys(grades || {}).length} {:else if disableGrading && Object.keys(grades || {}).length}
{#each questions as currentQuestion, index} {#each questions as currentQuestion, index (currentQuestion.id)}
{@const questionModel = questionModels[index]} {@const questionModel = questionModels[index]}
<div class="mb-4 space-y-3"> <div class="mb-4 space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -73,7 +74,7 @@
<ExerciseQuestion.QuestionRenderer <ExerciseQuestion.QuestionRenderer
contract={{ contract={{
mode: 'view', mode: 'review',
question: questionModel, question: questionModel,
answer: getAnswerForQuestion(questionModel, index), answer: getAnswerForQuestion(questionModel, index),
labels: questionLabels, labels: questionLabels,
@@ -83,7 +84,7 @@
</div> </div>
{/each} {/each}
{:else} {:else}
{#each questions as currentQuestion, index} {#each questions as currentQuestion, index (currentQuestion.id)}
{@const questionModel = questionModels[index]} {@const questionModel = questionModels[index]}
<div class="mb-4 space-y-3"> <div class="mb-4 space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -93,7 +94,7 @@
<ExerciseQuestion.QuestionRenderer <ExerciseQuestion.QuestionRenderer
contract={{ contract={{
mode: 'view', mode: 'review',
question: questionModel, question: questionModel,
answer: getAnswerForQuestion(questionModel, index), answer: getAnswerForQuestion(questionModel, index),
labels: questionLabels, labels: questionLabels,
@@ -18,6 +18,9 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'common.correct_badge': t.get( 'common.correct_badge': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.common.correct_badge' 'course.navItem.lessons.exercises.all_exercises.shared_question.common.correct_badge'
), ),
'common.incorrect_badge': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.common.incorrect_badge'
),
'common.not_set': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.not_set'), 'common.not_set': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.not_set'),
'navigation.previous': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.previous'), 'navigation.previous': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.previous'),
'navigation.next': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.next'), 'navigation.next': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.next'),
@@ -131,6 +134,12 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'short_answer.preview.helper': t.get( 'short_answer.preview.helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.preview.helper' 'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.preview.helper'
), ),
'short_answer.review.your_answer_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.review.your_answer_label'
),
'short_answer.review.accepted_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.review.accepted_label'
),
'numeric.edit.correct_value_label': t.get( 'numeric.edit.correct_value_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_label' 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_label'
), ),
@@ -158,6 +167,15 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'numeric.preview.tolerance_label': t.get( 'numeric.preview.tolerance_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.tolerance_label' 'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.tolerance_label'
), ),
'numeric.review.your_answer_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.your_answer_label'
),
'numeric.review.in_range': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.in_range'
),
'numeric.review.out_of_range': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.out_of_range'
),
'fill_blank.edit.accepted_answers_placeholder': t.get( 'fill_blank.edit.accepted_answers_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder' 'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder'
), ),
@@ -167,6 +185,9 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'fill_blank.preview.accepted_answers_label': t.get( 'fill_blank.preview.accepted_answers_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.preview.accepted_answers_label' 'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.preview.accepted_answers_label'
), ),
'fill_blank.review.your_answer_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.review.your_answer_label'
),
'file_upload.edit.accepted_types_placeholder': t.get( 'file_upload.edit.accepted_types_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder' 'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
), ),
@@ -81,7 +81,7 @@
<div class="pb-4"> <div class="pb-4">
<ExerciseQuestion.QuestionRenderer <ExerciseQuestion.QuestionRenderer
contract={{ contract={{
mode: 'view', mode: 'review',
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` }, question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
answer: getStudentAnswerForQuestion($submissions[studentSelected], q), answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
labels: questionLabels, labels: questionLabels,
@@ -7,6 +7,7 @@ export { default as CourseFilterPopover } from './course-filter-popover.svelte';
export { default as CourseTagPicker } from './course-tag-picker.svelte'; export { default as CourseTagPicker } from './course-tag-picker.svelte';
export { default as CreateCourseButton } from './create-course-button.svelte'; export { default as CreateCourseButton } from './create-course-button.svelte';
export { default as CourseList } from './list.svelte'; export { default as CourseList } from './list.svelte';
export { default as CourseTagsOverflow } from './course-tags-overflow.svelte';
export { default as CourseListLoader } from './list-loader.svelte'; export { default as CourseListLoader } from './list-loader.svelte';
export { default as NewCourseModal } from './new-course-modal.svelte'; export { default as NewCourseModal } from './new-course-modal.svelte';
export { default as QuestionContainer } from './question-container.svelte'; export { default as QuestionContainer } from './question-container.svelte';
@@ -2,10 +2,10 @@
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu'; import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import UserIcon from '@lucide/svelte/icons/user';
import * as Avatar from '@cio/ui/base/avatar'; import * as Avatar from '@cio/ui/base/avatar';
import { TextareaField } from '@cio/ui/custom/textarea-field'; import { TextareaField } from '@cio/ui/custom/textarea-field';
import { shortenName } from '$lib/utils/functions/string';
import { DeleteModal } from '$features/ui'; import { DeleteModal } from '$features/ui';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
@@ -99,11 +99,12 @@
<div> <div>
<div class="flex h-full items-start gap-3"> <div class="flex h-full items-start gap-3">
<Avatar.Root class="mt-2 size-6!"> <Avatar.Root class="mt-2 size-6!">
<Avatar.Image {#if $profile.avatarUrl}
src={$profile.avatarUrl ? $profile.avatarUrl : '/logo-192.png'} <Avatar.Image src={$profile.avatarUrl} alt={$profile.fullname ? $profile.fullname : 'User'} />
alt={$profile.fullname ? $profile.fullname : 'User'} {/if}
/> <Avatar.Fallback>
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback> <UserIcon class="ui:size-3 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div class="h-full w-full"> <div class="h-full w-full">
<TextareaField <TextareaField
@@ -125,11 +126,12 @@
{#each comments as commentItem (commentItem.id)} {#each comments as commentItem (commentItem.id)}
<div class="mt-2 flex items-start gap-3 pb-2"> <div class="mt-2 flex items-start gap-3 pb-2">
<Avatar.Root class="mt-2 size-6!"> <Avatar.Root class="mt-2 size-6!">
<Avatar.Image {#if commentItem.avatar}
src={commentItem.avatar ? commentItem.avatar : '/logo-192.png'} <Avatar.Image src={commentItem.avatar} alt={commentItem.name ? commentItem.name : 'User'} />
alt={commentItem.name ? commentItem.name : 'User'} {/if}
/> <Avatar.Fallback>
<Avatar.Fallback>{shortenName(commentItem.name) || 'U'}</Avatar.Fallback> <UserIcon class="ui:size-3 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div class="w-full rounded-md border px-3 py-2 dark:border-neutral-700"> <div class="w-full rounded-md border px-3 py-2 dark:border-neutral-700">
@@ -5,6 +5,7 @@
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { CircleCheckIcon } from '$features/ui/icons'; import { CircleCheckIcon } from '$features/ui/icons';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import * as Tooltip from '@cio/ui/base/tooltip';
import { globalStore } from '$lib/utils/store/app'; import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { courseApi, lessonApi } from '$features/course/api'; import { courseApi, lessonApi } from '$features/course/api';
@@ -115,8 +116,25 @@
} }
}; };
} }
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (INTERACTIVE_TAGS.has(target.tagName) || target.isContentEditable) return;
if (event.key === 'ArrowLeft' && !isPrevDisabled) {
event.preventDefault();
goToContent(prevNextContent.prev);
} else if (event.key === 'ArrowRight' && !isNextDisabled) {
event.preventDefault();
goToContent(prevNextContent.next);
}
}
</script> </script>
<svelte:window onkeydown={handleKeydown} />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if showMarkComplete && lessonId} {#if showMarkComplete && lessonId}
<Button <Button
@@ -131,27 +149,41 @@
</Button> </Button>
{/if} {/if}
<div class="flex items-center gap-1"> <Tooltip.Provider>
<Button <div class="flex items-center gap-1">
size="icon-sm" <Tooltip.Root>
variant="outline" <Tooltip.Trigger>
onclick={() => goToContent(prevNextContent.prev)} <Button
disabled={isPrevDisabled} size="icon-sm"
aria-label={$t('course.navItem.lessons.prev')} variant="outline"
title={$t('course.navItem.lessons.prev')} onclick={() => goToContent(prevNextContent.prev)}
> disabled={isPrevDisabled}
<ChevronLeftIcon size={14} /> aria-label={$t('course.navItem.lessons.prev')}
</Button> >
<ChevronLeftIcon size={14} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side="bottom" sideOffset={4}>
{$t('course.navItem.lessons.prev_shortcut')}
</Tooltip.Content>
</Tooltip.Root>
<Button <Tooltip.Root>
size="icon-sm" <Tooltip.Trigger>
variant="outline" <Button
onclick={() => goToContent(prevNextContent.next)} size="icon-sm"
disabled={isNextDisabled} variant="outline"
aria-label={$t('course.navItem.lessons.next')} onclick={() => goToContent(prevNextContent.next)}
title={$t('course.navItem.lessons.next')} disabled={isNextDisabled}
> aria-label={$t('course.navItem.lessons.next')}
<ChevronRightIcon size={14} /> >
</Button> <ChevronRightIcon size={14} />
</div> </Button>
</Tooltip.Trigger>
<Tooltip.Content side="bottom" sideOffset={4}>
{$t('course.navItem.lessons.next_shortcut')}
</Tooltip.Content>
</Tooltip.Root>
</div>
</Tooltip.Provider>
</div> </div>
@@ -9,6 +9,7 @@
import type { TLocale } from '@cio/db/types'; import type { TLocale } from '@cio/db/types';
import AIButton from '$features/course/components/lesson/ai-button.svelte'; import AIButton from '$features/course/components/lesson/ai-button.svelte';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import { saveDraft } from '$features/course/utils/lesson-draft';
interface Props { interface Props {
mode?: (typeof MODES)[keyof typeof MODES]; mode?: (typeof MODES)[keyof typeof MODES];
@@ -33,12 +34,7 @@
} }
lessonApi.translations[lessonId][lessonApi.currentLocale] = `${content}`; lessonApi.translations[lessonId][lessonApi.currentLocale] = `${content}`;
try { saveDraft(lessonId, lessonApi.currentLocale, `${content}`);
localStorage.setItem(`lesson-${lessonId}-${lessonApi.currentLocale}`, `${content}`);
} catch (error) {
console.error('Error saving lesson note to localStorage', error);
}
lessonApi.isDirty = true; lessonApi.isDirty = true;
} }
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import * as Table from '@cio/ui/base/table'; import * as Table from '@cio/ui/base/table';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu'; import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
@@ -7,6 +8,7 @@
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte'; import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte'; import CoursePublishBadge from './course-publish-badge.svelte';
import CourseTagsOverflow from './course-tags-overflow.svelte';
interface Props { interface Props {
id?: string; id?: string;
@@ -60,29 +62,18 @@
} }
</script> </script>
<Table.Row class="cursor-pointer" onclick={() => goto(`/courses/${id}`)}> <Table.Row class="cursor-pointer" onclick={() => goto(resolve(`/courses/[id]`, { id }))}>
<Table.Cell class="truncate"><p class="font-semibold">{title}</p></Table.Cell> <Table.Cell class="truncate"><p class="font-semibold">{title}</p></Table.Cell>
<Table.Cell class="truncate"> <Table.Cell class="truncate">
<p>{description}</p> <p>{description}</p>
</Table.Cell> </Table.Cell>
{#if !isMobileStore.current} {#if !isMobileStore.current}
<Table.Cell class="truncate">{type}</Table.Cell> <Table.Cell class="truncate">{type}</Table.Cell>
<Table.Cell> <Table.Cell class="w-3/12 min-w-0">
{#if tags.length === 0} {#if tags.length === 0}
<span class="ui:text-muted-foreground text-2xs">-</span> <span class="ui:text-muted-foreground text-2xs">-</span>
{:else} {:else}
<div class="ui:text-muted-foreground text-2xs flex max-w-[220px] flex-wrap items-center gap-2"> <CourseTagsOverflow {tags} variant="table" />
{#each tags as tag (tag.id)}
<span class="inline-flex items-center gap-1.5">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</span>
{/each}
</div>
{/if} {/if}
</Table.Cell> </Table.Cell>
<Table.Cell>{totalLessons}</Table.Cell> <Table.Cell>{totalLessons}</Table.Cell>
@@ -3,10 +3,10 @@
import { TextareaField } from '@cio/ui/custom/textarea-field'; import { TextareaField } from '@cio/ui/custom/textarea-field';
import { CheckboxField } from '@cio/ui/custom/checkbox-field'; import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { inviteSettingsStore, studentInviteLinkStore } from './store';
import { getInvitePayload } from './invite-utils';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import { peopleApi } from '$features/course/api'; import { orgApi } from '$features/org/api/org.svelte';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
const INVITE_MODAL = 'course.navItem.people.invite_modal'; const INVITE_MODAL = 'course.navItem.people.invite_modal';
@@ -19,72 +19,58 @@
let recipientCsv = $state(''); let recipientCsv = $state('');
let sendInviteEmails = $state(true); let sendInviteEmails = $state(true);
let isSubmitting = $state(false);
async function createEmailInvites() { async function inviteNewStudents() {
if (!courseId) return; if (!courseId) return;
if (!recipientCsv.trim()) { if (!recipientCsv.trim()) {
snackbar.error(`${INVITE_MODAL}.snackbar_add_one_recipient`); snackbar.error('audience.import.snackbar_no_emails');
return; return;
} }
studentInviteLinkStore.update((s) => ({ ...s, isCreating: true })); isSubmitting = true;
try { try {
const settings = $inviteSettingsStore; const response = await orgApi.importAudienceMembers({
const payload = getInvitePayload(settings);
const response = await peopleApi.createStudentInvite(courseId, {
...payload,
recipientCsv, recipientCsv,
courseIds: [courseId],
sendEmail: sendInviteEmails sendEmail: sendInviteEmails
}); });
const createdCount = response?.invites?.length || 0; if (response) {
const sent = response?.delivery?.sent || 0; recipientCsv = '';
const failed = response?.delivery?.failed || 0; await courseApi.refreshCourse(courseId, $profile.id);
const duplicates = response?.duplicatesSkipped?.length || 0; await onInviteCreated?.();
}
snackbar.success(
$t(`${INVITE_MODAL}.snackbar_email_invites_success`, {
count: createdCount,
sent,
failed,
duplicates
})
);
recipientCsv = '';
onInviteCreated?.();
} catch (error) { } catch (error) {
console.error('Failed to create email invites', error); console.error('Failed to invite new students into organization', error);
snackbar.error(`${INVITE_MODAL}.snackbar_failed_create_email_invites`); snackbar.error('audience.import.snackbar_failed');
} finally { } finally {
studentInviteLinkStore.update((s) => ({ ...s, isCreating: false })); isSubmitting = false;
} }
} }
const isCreating = $derived($studentInviteLinkStore.isCreating);
</script> </script>
<div class="rounded-md border p-4"> <div class="rounded-md border p-4">
<p class="mb-2 text-base font-semibold">{$t(`${INVITE_MODAL}.direct_email_bulk`)}</p> <p class="mb-2 text-base font-semibold">{$t(`${INVITE_MODAL}.invite_new_students_title`)}</p>
<p class="ui:text-muted-foreground mb-2 text-sm"> <p class="ui:text-muted-foreground mb-2 text-sm">
{$t(`${INVITE_MODAL}.direct_email_description`)} {$t(`${INVITE_MODAL}.invite_new_students_description`)}
</p> </p>
<TextareaField <TextareaField
label={$t(`${INVITE_MODAL}.recipient_emails`)} label={$t('audience.import.emails_label')}
bind:value={recipientCsv} bind:value={recipientCsv}
rows={4} rows={4}
className="w-full" className="w-full"
placeholder={$t(`${INVITE_MODAL}.recipient_emails_placeholder`)} placeholder={$t('audience.import.emails_placeholder')}
/> />
<CheckboxField <CheckboxField
className="mt-2" className="mt-2"
name="send-invite-emails" name="send-invite-emails"
label={$t(`${INVITE_MODAL}.send_invite_emails`)} label={$t('audience.import.send_email')}
bind:checked={sendInviteEmails} bind:checked={sendInviteEmails}
/> />
<div class="mt-3"> <div class="mt-3">
<Button variant="secondary" onclick={createEmailInvites} loading={isCreating}> <Button variant="secondary" onclick={inviteNewStudents} loading={isSubmitting}>
{$t(`${INVITE_MODAL}.create_email_invites`)} {$t(`${INVITE_MODAL}.invite_new_students`)}
</Button> </Button>
</div> </div>
</div> </div>
@@ -0,0 +1,149 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { Button } from '@cio/ui/base/button';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { Search } from '@cio/ui/custom/search';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { orgApi } from '$features/org/api/org.svelte';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import type { OrgStudent } from './types';
interface Props {
courseId: string;
students: OrgStudent[];
isLoading?: boolean;
onAssigned?: () => void | Promise<void>;
}
let { courseId, students, isLoading = false, onAssigned }: Props = $props();
let searchValue = $state('');
const selectedProfileIds = new SvelteSet<string>();
let sendEmail = $state(true);
let isSubmitting = $state(false);
const filteredStudents = $derived.by(() => {
const query = searchValue.trim().toLowerCase();
const sortedStudents = [...students].sort((a, b) =>
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
);
if (!query) {
return sortedStudents;
}
return sortedStudents.filter((student) => {
const name = student.name?.toLowerCase() || '';
const email = student.email?.toLowerCase() || '';
return name.includes(query) || email.includes(query);
});
});
const selectedCount = $derived(selectedProfileIds.size);
function toggleStudent(profileId: string) {
if (selectedProfileIds.has(profileId)) {
selectedProfileIds.delete(profileId);
} else {
selectedProfileIds.add(profileId);
}
}
async function assignStudents() {
if (!courseId) return;
if (selectedProfileIds.size === 0) {
snackbar.error('course.navItem.people.invite_modal.select_at_least_one_student');
return;
}
isSubmitting = true;
try {
const result = await orgApi.assignAudienceToCourses({
profileIds: [...selectedProfileIds],
courseIds: [courseId],
sendEmail
});
if (result) {
await courseApi.refreshCourse(courseId, $profile.id);
selectedProfileIds.clear();
searchValue = '';
await onAssigned?.();
}
} catch (error) {
console.error('Failed to assign existing students to course', error);
snackbar.error('audience.assign.snackbar_failed');
} finally {
isSubmitting = false;
}
}
</script>
<div class="rounded-md border p-4">
<div class="space-y-1">
<p class="text-base font-semibold">{$t('course.navItem.people.invite_modal.existing_students_title')}</p>
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.existing_students_description')}
</p>
</div>
<div class="mt-4 space-y-4">
<Search placeholder={$t('course.navItem.people.invite_modal.existing_students_search')} bind:value={searchValue} />
{#if selectedCount > 0}
<p class="ui:text-muted-foreground text-sm">{$t('audience.selected_count', { count: selectedCount })}</p>
{/if}
<div class="max-h-72 space-y-2 overflow-y-auto rounded-md border p-3">
{#if isLoading}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.loading_existing_students')}
</p>
{:else if students.length === 0}
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.people.invite_modal.no_existing_students')}</p>
{:else if filteredStudents.length === 0}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.no_matching_existing_students')}
</p>
{:else}
{#each filteredStudents as student (student.profileId)}
<div
class="ui:hover:bg-muted/40 rounded-md px-2 py-1"
role="button"
tabindex="0"
onclick={() => student.profileId && toggleStudent(student.profileId)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (student.profileId) toggleStudent(student.profileId);
}
}}
>
<CheckboxField
name={`existing-student-${student.profileId}`}
label={student.name || student.email}
checked={student.profileId ? selectedProfileIds.has(student.profileId) : false}
>
<span class="ui:text-muted-foreground text-sm">{student.email}</span>
</CheckboxField>
</div>
{/each}
{/if}
</div>
<CheckboxField
name="notify-existing-students"
label={$t('course.navItem.people.invite_modal.notify_existing_students')}
bind:checked={sendEmail}
/>
<div class="flex justify-end">
<Button variant="secondary" onclick={assignStudents} loading={isSubmitting} disabled={isLoading || isSubmitting}>
{$t('course.navItem.people.invite_modal.grant_access')}
</Button>
</div>
</div>
</div>
@@ -13,19 +13,11 @@
import { peopleApi } from '$features/course/api'; import { peopleApi } from '$features/course/api';
import { orgApi } from '$features/org/api/org.svelte'; import { orgApi } from '$features/org/api/org.svelte';
import type { OrgTeamMember } from '$lib/utils/types/org'; import type { OrgTeamMember } from '$lib/utils/types/org';
import { import type { OrgStudent, Tutor } from './types';
DEFAULT_INVITE_SETTINGS_STATE,
DEFAULT_STUDENT_INVITE_LINK_STATE,
inviteSettingsStore,
studentInviteLinkStore
} from './store';
import type { Tutor } from './types';
import TutorSelectSection from './tutor-select-section.svelte'; import TutorSelectSection from './tutor-select-section.svelte';
import InviteSettingsSection from './invite-settings-section.svelte'; import ExistingStudentsSection from './existing-students-section.svelte';
import BulkEmailSection from './bulk-email-section.svelte'; import BulkEmailSection from './bulk-email-section.svelte';
import InviteListSection from './invite-list-section.svelte';
import type { InviteListItem } from './types';
let tutors = $state<Tutor[]>([]); let tutors = $state<Tutor[]>([]);
let selectedIds = $state<string[]>([]); let selectedIds = $state<string[]>([]);
@@ -36,21 +28,9 @@
const selectedTutors = $derived(tutors.filter((t) => selectedIds.includes(t.id.toString()))); const selectedTutors = $derived(tutors.filter((t) => selectedIds.includes(t.id.toString())));
const INVITE_MODAL = 'course.navItem.people.invite_modal'; const INVITE_MODAL = 'course.navItem.people.invite_modal';
let existingInvites = $state<InviteListItem[]>([]); let isLoadingStudents = $state(false);
let isLoadingInvites = $state(false); let activeTab = $state<'tutors' | 'students'>('students');
let activeTab = $state<'tutors' | 'students'>('tutors'); const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, courseApi.group.people));
let activeStudentTab = $state<'email' | 'history'>('email');
async function loadInvites() {
if (!courseId) return;
isLoadingInvites = true;
try {
const invites = await peopleApi.listStudentInvites(courseId);
existingInvites = (invites || []) as InviteListItem[];
} finally {
isLoadingInvites = false;
}
}
function getTutors(team: OrgTeamMember[]) { function getTutors(team: OrgTeamMember[]) {
const existingTutors = courseApi?.group?.tutors || []; const existingTutors = courseApi?.group?.tutors || [];
@@ -65,6 +45,22 @@
})); }));
} }
function getAvailableStudents(students: OrgStudent[], courseMembers: typeof courseApi.group.people) {
const enrolledStudentIds = new Set(
courseMembers
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
.map((member) => member.profileId)
.filter((profileId): profileId is string => Boolean(profileId))
);
return students.filter((student) => {
if (!student.profileId) {
return false;
}
return !enrolledStudentIds.has(student.profileId);
});
}
function setTutors(orgId: string | undefined) { function setTutors(orgId: string | undefined) {
if (!orgId) return; if (!orgId) return;
untrack(async () => { untrack(async () => {
@@ -77,6 +73,18 @@
}); });
} }
function loadStudents(orgId: string | undefined) {
if (!orgId) return;
untrack(async () => {
isLoadingStudents = true;
try {
await orgApi.getOrgAudience(orgId);
} finally {
isLoadingStudents = false;
}
});
}
async function onSubmit() { async function onSubmit() {
if (!selectedTutors.length) { if (!selectedTutors.length) {
goto(resolve(page.url.pathname, {})); goto(resolve(page.url.pathname, {}));
@@ -105,11 +113,8 @@
$effect(() => { $effect(() => {
if (!isOpen || !courseId) return; if (!isOpen || !courseId) return;
untrack(() => { untrack(() => {
activeTab = 'tutors'; activeTab = 'students';
activeStudentTab = 'email'; void loadStudents($currentOrg.id);
inviteSettingsStore.set({ ...DEFAULT_INVITE_SETTINGS_STATE });
studentInviteLinkStore.set({ ...DEFAULT_STUDENT_INVITE_LINK_STATE });
void loadInvites();
}); });
}); });
</script> </script>
@@ -149,35 +154,8 @@
<UnderlineTabs.Content value="students"> <UnderlineTabs.Content value="students">
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<InviteSettingsSection /> <ExistingStudentsSection {courseId} students={availableStudents} isLoading={isLoadingStudents} />
<BulkEmailSection {courseId} />
<UnderlineTabs.Root bind:value={activeStudentTab}>
<UnderlineTabs.List class="flex flex-wrap">
<UnderlineTabs.Trigger value="email">
{$t(`${INVITE_MODAL}.direct_email_bulk`)}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="history">
{$t(`${INVITE_MODAL}.existing_invites`)}
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="email">
<div class="mt-6">
<BulkEmailSection {courseId} onInviteCreated={loadInvites} />
</div>
</UnderlineTabs.Content>
<UnderlineTabs.Content value="history">
<div class="mt-6">
<InviteListSection
{courseId}
invites={existingInvites}
isLoading={isLoadingInvites}
onRefresh={loadInvites}
/>
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
</div> </div>
</UnderlineTabs.Content> </UnderlineTabs.Content>
</UnderlineTabs.Root> </UnderlineTabs.Root>
@@ -1,5 +1,6 @@
import type { StudentInviteListItem, StudentInviteAuditItem } from '../../utils/types'; import type { StudentInviteListItem, StudentInviteAuditItem } from '../../utils/types';
import type { OrganizationTeamMembers } from '$features/org/utils/types'; import type { OrganizationTeamMembers } from '$features/org/utils/types';
import type { OrganizationAudience } from '$features/org/utils/types';
import type { TCourseInvitePreset } from '@cio/utils/validation/course/invite'; import type { TCourseInvitePreset } from '@cio/utils/validation/course/invite';
export interface Profile { export interface Profile {
@@ -30,6 +31,9 @@ export type Tutor = Pick<OrgTeamMemberItem, 'id' | 'profileId' | 'email'> & {
text: OrgTeamMemberItem['fullname']; text: OrgTeamMemberItem['fullname'];
}; };
type OrgAudienceMemberItem = OrganizationAudience[number];
export type OrgStudent = OrgAudienceMemberItem;
/** Re-export API-driven invite types from course utils */ /** Re-export API-driven invite types from course utils */
export type InviteListItem = StudentInviteListItem; export type InviteListItem = StudentInviteListItem;
export type InviteAuditItem = StudentInviteAuditItem; export type InviteAuditItem = StudentInviteAuditItem;
@@ -4,8 +4,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import * as Sidebar from '@cio/ui/base/sidebar'; import * as Sidebar from '@cio/ui/base/sidebar';
import { Button } from '@cio/ui/base/button'; import { BackButton } from '@cio/ui';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents'; import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
import { import {
@@ -231,10 +230,7 @@
</script> </script>
<Sidebar.Group class="pt-0!"> <Sidebar.Group class="pt-0!">
<Button variant="link" class="h-fit! justify-start! px-2! py-2!" href={resolve(coursesListPath, {})}> <BackButton href={resolve(coursesListPath, {})} label={$t('org_navigation.courses')} class="px-2! py-2!" />
<ArrowLeftIcon class="custom" />
<span class="text-xs">{$t('org_navigation.courses')}</span>
</Button>
<Sidebar.Menu> <Sidebar.Menu>
{#each navItems as item (item.id)} {#each navItems as item (item.id)}
@@ -21,6 +21,7 @@
import { DeleteModal } from '$features/ui'; import { DeleteModal } from '$features/ui';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api'; import { courseApi } from '$features/course/api';
import { COURSE_SORT_OPTIONS, DEFAULT_COURSE_SORT, type CourseSortBy } from '../utils/constants';
import { deleteCourseModal, deleteCourseModalInitialState, courseMetaDeta } from '../utils/store'; import { deleteCourseModal, deleteCourseModalInitialState, courseMetaDeta } from '../utils/store';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types'; import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
@@ -33,7 +34,7 @@
isExplore?: boolean; isExplore?: boolean;
isLMS?: boolean; isLMS?: boolean;
searchValue?: string; searchValue?: string;
selectedId?: string; sortKey?: CourseSortBy;
isLoading?: boolean; isLoading?: boolean;
showSortSelect?: boolean; showSortSelect?: boolean;
filterControls?: Snippet; filterControls?: Snippet;
@@ -46,22 +47,21 @@
isExplore = false, isExplore = false,
isLMS = false, isLMS = false,
searchValue = $bindable(''), searchValue = $bindable(''),
selectedId = $bindable('0'), sortKey = $bindable(DEFAULT_COURSE_SORT),
isLoading = false, isLoading = false,
showSortSelect = true, showSortSelect = true,
filterControls filterControls
}: Props = $props(); }: Props = $props();
const filterOptions = $derived([ const filterOptions = $derived(
{ value: '0', label: $t('courses.course_filter.date_created') }, COURSE_SORT_OPTIONS.map((option) => ({
{ value: '1', label: $t('courses.course_filter.published') }, value: option.value,
{ value: '2', label: $t('courses.course_filter.lessons') } label: $t(option.label)
]); }))
const selectedLabel = $derived(
filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label
); );
const selectedLabel = $derived(filterOptions.find((opt) => opt.value === sortKey)?.label || filterOptions[0].label);
const setViewPreference = (preference: 'grid' | 'list') => { const setViewPreference = (preference: 'grid' | 'list') => {
$courseMetaDeta.view = preference; $courseMetaDeta.view = preference;
if (browser) { if (browser) {
@@ -94,12 +94,12 @@
isLoading={$deleteCourseModal.isDeleting} isLoading={$deleteCourseModal.isDeleting}
/> />
<Page.BodyHeader align="right"> <Page.BodyHeader align="right" class="p-0!">
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} /> <Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
{#if showSortSelect} {#if showSortSelect}
<Select.Root type="single" bind:value={selectedId}> <Select.Root type="single" bind:value={sortKey}>
<Select.Trigger class="min-w-[150px]"> <Select.Trigger class="min-w-[150px]">
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p> <p>{sortKey ? selectedLabel : filterOptions[0].label}</p>
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
{#each filterOptions as option (option.label)} {#each filterOptions as option (option.label)}
@@ -142,9 +142,9 @@
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.title')}</Table.Head> <Table.Head class="w-[20%]">{$t('courses.course_card.list_view.title')}</Table.Head>
<Table.Head class="w-[24%]">{$t('courses.course_card.list_view.description')}</Table.Head> <Table.Head class="w-[20%]">{$t('courses.course_card.list_view.description')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head> <Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
<Table.Head class="w-[14%]">{$t('courses.course_card.list_view.tags')}</Table.Head> <Table.Head class="w-3/12 min-w-0">{$t('courses.course_card.list_view.tags')}</Table.Head>
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head> <Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head> <Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head> <Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
@@ -18,7 +18,7 @@
import { globalStore } from '$lib/utils/store/app'; import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import { RefreshPageData } from '$features/ui'; import { RefreshPageData, UnsavedChanges } from '$features/ui';
import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte'; import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte';
import { courseApi, lessonApi } from '$features/course/api'; import { courseApi, lessonApi } from '$features/course/api';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml'; import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
@@ -50,6 +50,7 @@
import type { TLocale } from '@cio/db/types'; import type { TLocale } from '@cio/db/types';
import { orderedTabs, tabs as materialTabs } from '$features/course/components/lesson/constants'; import { orderedTabs, tabs as materialTabs } from '$features/course/components/lesson/constants';
import { getViewModeComponents } from '$features/course/components/lesson/utils'; import { getViewModeComponents } from '$features/course/components/lesson/utils';
import { loadDraft, clearDraft } from '$features/course/utils/lesson-draft';
interface Props { interface Props {
courseId: string; courseId: string;
@@ -61,9 +62,14 @@
const mode = $derived($page.url.searchParams.get('mode') === 'edit' ? MODES.edit : MODES.view); const mode = $derived($page.url.searchParams.get('mode') === 'edit' ? MODES.edit : MODES.view);
let prevModeParam = $state<string | null>(null); let prevModeParam = $state<string | null>(null);
let isSaving = $state(false);
let isDeletingLesson = $state(false); let isDeletingLesson = $state(false);
let isVersionDrawerOpen = $state(false); let isVersionDrawerOpen = $state(false);
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
let hasUnsavedChanges = $state(false);
$effect(() => {
hasUnsavedChanges = lessonApi.isDirty && mode === MODES.edit;
});
const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson'); const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson');
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false); const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
@@ -80,6 +86,12 @@
} }
function toggleMode() { function toggleMode() {
if (mode === MODES.edit && lessonApi.isDirty) {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = undefined;
saveLesson();
}
hasUnsavedChanges = false;
setModeQueryParam(mode === MODES.edit ? MODES.view : MODES.edit); setModeQueryParam(mode === MODES.edit ? MODES.view : MODES.edit);
} }
@@ -139,7 +151,7 @@
const isMaterialsEmpty = $derived(tabs.every((tab) => tab.badgeValue === 0)); const isMaterialsEmpty = $derived(tabs.every((tab) => tab.badgeValue === 0));
let timeoutId: NodeJS.Timeout; let timeoutId: NodeJS.Timeout | undefined;
let isLoading = writable(false); let isLoading = writable(false);
@@ -184,9 +196,6 @@
async function saveLesson() { async function saveLesson() {
if (!lessonApi.lesson) return false; if (!lessonApi.lesson) return false;
// Prevent autosave loops: we only set this back to `true` when the user edits again.
lessonApi.isDirty = false;
const [isLessonUpdated] = await Promise.all([ const [isLessonUpdated] = await Promise.all([
lessonApi.update(courseApi.course?.id || '', lessonId, { lessonApi.update(courseApi.course?.id || '', lessonId, {
title: lessonApi.lesson.title || undefined, title: lessonApi.lesson.title || undefined,
@@ -200,6 +209,8 @@
if (isLessonUpdated) { if (isLessonUpdated) {
patchLessonListItemLocally(); patchLessonListItemLocally();
clearDraft(lessonId, lessonApi.currentLocale);
lessonApi.isDirty = false;
} }
} }
@@ -219,6 +230,7 @@
await lessonApi.delete(courseId, lessonId); await lessonApi.delete(courseId, lessonId);
if (lessonApi.success) { if (lessonApi.success) {
hasUnsavedChanges = false;
await goto(resolve(`/courses/${courseId}/lessons`, {})); await goto(resolve(`/courses/${courseId}/lessons`, {}));
} }
@@ -228,6 +240,8 @@
function handleSave(prevMode: string) { function handleSave(prevMode: string) {
untrack(() => { untrack(() => {
if (prevMode === MODES.edit) { if (prevMode === MODES.edit) {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = undefined;
saveLesson(); saveLesson();
} }
}); });
@@ -240,11 +254,8 @@
if (timeoutId) clearTimeout(timeoutId); if (timeoutId) clearTimeout(timeoutId);
untrack(() => { untrack(() => {
isSaving = true;
timeoutId = setTimeout(async () => { timeoutId = setTimeout(async () => {
await saveLesson(); await saveLesson();
isSaving = false;
}, 2000); }, 2000);
}); });
} }
@@ -255,13 +266,41 @@
autoSave(); autoSave();
}; };
// Check for an unsaved localStorage draft when the lesson first loads.
let draftCheckedForLesson = '';
$effect(() => {
if (!browser) return;
if (!lessonApi.lesson || !lessonId) return;
if (draftCheckedForLesson === lessonId) return;
draftCheckedForLesson = lessonId;
const draft = loadDraft(lessonId, lessonApi.currentLocale);
if (!draft) return;
const serverContent = lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '';
if (draft.content === serverContent) {
clearDraft(lessonId, lessonApi.currentLocale);
return;
}
const shouldRestore = window.confirm(t.get('course.navItem.lessons.draft_recovery'));
if (shouldRestore) {
if (!lessonApi.translations[lessonId]) {
lessonApi.translations[lessonId] = {} as Record<TLocale, string>;
}
lessonApi.translations[lessonId][lessonApi.currentLocale] = draft.content;
lessonApi.isDirty = true;
} else {
clearDraft(lessonId, lessonApi.currentLocale);
}
});
// Only autosave after real edits, not on every reactive update. // Only autosave after real edits, not on every reactive update.
$effect(() => { $effect(() => {
if (mode !== MODES.edit) return; if (mode !== MODES.edit) return;
if (!lessonApi.lesson || !lessonId) return; if (!lessonApi.lesson || !lessonId) return;
if (!lessonApi.isDirty) return; if (!lessonApi.isDirty) return;
if ($isLoading) return; if ($isLoading) return;
console.log('autoSave');
autoSave(); autoSave();
}); });
@@ -270,7 +309,6 @@
let didHandleExitEdit = false; let didHandleExitEdit = false;
$effect(() => { $effect(() => {
if (!lessonId) return; if (!lessonId) return;
console.log('didHandleExitEdit');
const currentParam = $page.url.searchParams.get('mode'); const currentParam = $page.url.searchParams.get('mode');
const prev = prevModeParam; const prev = prevModeParam;
@@ -338,8 +376,17 @@
</IconButton> </IconButton>
{/if} {/if}
<div class="flex-row items-center lg:flex"> <div class="flex flex-row items-center gap-2 lg:flex">
<IconButton onclick={toggleMode} disabled={isSaving}> {#if mode === MODES.edit}
<span class="ui:text-muted-foreground text-sm" aria-live="polite">
{#if lessonApi.isSaving}
{$t('course.navItem.lessons.saving')}
{:else if !lessonApi.isDirty}
{$t('course.navItem.lessons.saved')}
{/if}
</span>
{/if}
<IconButton onclick={toggleMode} disabled={lessonApi.isSaving}>
{#if mode === MODES.edit} {#if mode === MODES.edit}
<Save size={20} /> <Save size={20} />
{:else} {:else}
@@ -452,6 +499,8 @@
{/snippet} {/snippet}
</Page.Body> </Page.Body>
<UnsavedChanges bind:hasUnsavedChanges />
{#if isVersionDrawerOpen && window.innerWidth >= 1024} {#if isVersionDrawerOpen && window.innerWidth >= 1024}
<LessonVersionHistory <LessonVersionHistory
open={isVersionDrawerOpen} open={isVersionDrawerOpen}
@@ -17,6 +17,7 @@
import type { CourseMembers, CourseMember } from '$features/course/utils/types'; import type { CourseMembers, CourseMember } from '$features/course/utils/types';
import { courseApi } from '$features/course/api'; import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import UserIcon from '@lucide/svelte/icons/user';
import { shortenName } from '$lib/utils/functions/string'; import { shortenName } from '$lib/utils/functions/string';
import * as Select from '@cio/ui/base/select'; import * as Select from '@cio/ui/base/select';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
@@ -138,11 +139,15 @@
{#if person.profile} {#if person.profile}
<div class="flex items-start lg:items-center"> <div class="flex items-start lg:items-center">
<Avatar.Root class="mr-3"> <Avatar.Root class="mr-3">
<Avatar.Image {#if person.profile.avatar_url}
src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'} <Avatar.Image
alt={person.profile.fullname ? person.profile.fullname : 'User'} src={person.profile.avatar_url}
/> alt={person.profile.fullname ? person.profile.fullname : 'User'}
<Avatar.Fallback>{shortenName(person.profile.fullname) || 'U'}</Avatar.Fallback> />
{/if}
<Avatar.Fallback>
<UserIcon class="ui:size-4 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div class="flex flex-col items-start lg:flex-row lg:items-center"> <div class="flex flex-col items-start lg:flex-row lg:items-center">
<div class="mr-2"> <div class="mr-2">
@@ -152,7 +152,7 @@
await submissionApi.updateGrades(courseId, subId, { await submissionApi.updateGrades(courseId, subId, {
answers, answers,
total, total,
feedback, feedback: feedback || undefined,
statusId: STATUS.GRADED statusId: STATUS.GRADED
}); });
@@ -195,6 +195,8 @@
} }
}; };
handleModalClose();
isSaving = false; isSaving = false;
} }
</script> </script>
@@ -16,8 +16,51 @@ export const EXERCISE_TEMPLATE_TAGS = {
GIT: 'GIT' GIT: 'GIT'
}; };
/** Named sort keys for course lists (URL `sort`, localStorage, filter UI). */
export const CourseSortBy = {
DateCreated: 'date_created',
Published: 'published',
Lessons: 'lessons'
} as const;
export type CourseSortBy = (typeof CourseSortBy)[keyof typeof CourseSortBy];
export const DEFAULT_COURSE_SORT: CourseSortBy = CourseSortBy.DateCreated;
/** URL `order` param and filter toggle (ascending vs descending). */
export const CourseSortOrder = {
Asc: 'asc',
Desc: 'desc'
} as const;
export type CourseSortOrder = (typeof CourseSortOrder)[keyof typeof CourseSortOrder];
export const DEFAULT_SORT_ORDER: CourseSortOrder = CourseSortOrder.Desc;
const COURSE_SORT_ORDER_VALUES = new Set<string>(Object.values(CourseSortOrder));
export function parseCourseSortOrder(value: string | null | undefined): CourseSortOrder {
if (value != null && COURSE_SORT_ORDER_VALUES.has(value)) {
return value as CourseSortOrder;
}
return DEFAULT_SORT_ORDER;
}
export const COURSE_SORT_OPTIONS = [ export const COURSE_SORT_OPTIONS = [
{ value: '0', label: 'courses.course_filter.date_created' }, { value: CourseSortBy.DateCreated, label: 'courses.course_filter.date_created' },
{ value: '1', label: 'courses.course_filter.published' }, { value: CourseSortBy.Published, label: 'courses.course_filter.published' },
{ value: '2', label: 'courses.course_filter.lessons' } { value: CourseSortBy.Lessons, label: 'courses.course_filter.lessons' }
] as const; ] as const;
const COURSE_SORT_VALUES = new Set<string>(Object.values(CourseSortBy));
/** Parses URL `sort` / stored sort strings into {@link CourseSortBy}; unknown values fall back to default. */
export function parseCourseSortValue(value: string | null | undefined): CourseSortBy {
if (value == null || value === '') {
return DEFAULT_COURSE_SORT;
}
if (COURSE_SORT_VALUES.has(value)) {
return value as CourseSortBy;
}
return DEFAULT_COURSE_SORT;
}
@@ -0,0 +1,37 @@
import type { TLocale } from '@cio/db/types';
interface LessonDraft {
content: string;
timestamp: number;
}
function draftKey(lessonId: string, locale: TLocale): string {
return `lesson-draft-${lessonId}-${locale}`;
}
export function saveDraft(lessonId: string, locale: TLocale, content: string) {
try {
const draft: LessonDraft = { content, timestamp: Date.now() };
localStorage.setItem(draftKey(lessonId, locale), JSON.stringify(draft));
} catch {
// localStorage may be full or unavailable
}
}
export function loadDraft(lessonId: string, locale: TLocale): LessonDraft | null {
try {
const raw = localStorage.getItem(draftKey(lessonId, locale));
if (!raw) return null;
return JSON.parse(raw) as LessonDraft;
} catch {
return null;
}
}
export function clearDraft(lessonId: string, locale: TLocale) {
try {
localStorage.removeItem(draftKey(lessonId, locale));
} catch {
// ignore
}
}
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
@@ -7,15 +8,18 @@
import { courseMetaDeta } from '$features/course/utils/store'; import { courseMetaDeta } from '$features/course/utils/store';
import { coursesApi } from '$features/course/api'; import { coursesApi } from '$features/course/api';
import type { RecommendedCourses } from '$features/course/types'; import type { RecommendedCourses } from '$features/course/types';
import { CourseSortBy, DEFAULT_COURSE_SORT, parseCourseSortValue } from '$features/course/utils/constants';
let searchValue = $state(''); let searchValue = $state('');
let selectedId: string | undefined = $state(''); let sortKey = $state<CourseSortBy>(DEFAULT_COURSE_SORT);
$effect(() => {
if (!browser) return;
void sortKey;
localStorage.setItem('classroomio_filter_course_key', sortKey);
});
const filteredExploreCourses: RecommendedCourses = $derived.by(() => { const filteredExploreCourses: RecommendedCourses = $derived.by(() => {
if (selectedId) {
localStorage.setItem('classroomio_filter_course_key', selectedId);
}
const coursesFiltered = coursesApi.recommendedCourses.filter((course) => { const coursesFiltered = coursesApi.recommendedCourses.filter((course) => {
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) { if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
return true; return true;
@@ -24,11 +28,13 @@
return false; return false;
}); });
if (selectedId === '0') { if (sortKey === CourseSortBy.DateCreated) {
return coursesFiltered.sort( return coursesFiltered.sort(
(a, b) => new Date(a.createdAt ?? '').getTime() - new Date(b.createdAt ?? '').getTime() (a, b) => new Date(a.createdAt ?? '').getTime() - new Date(b.createdAt ?? '').getTime()
); );
} else if (selectedId === '1') { } else if (sortKey === CourseSortBy.Published) {
return coursesFiltered.sort((a, b) => Number(b.isPublished) - Number(a.isPublished));
} else if (sortKey === CourseSortBy.Lessons) {
return coursesFiltered.sort((a, b) => (b.lessonCount ?? 0) - (a.lessonCount ?? 0)); return coursesFiltered.sort((a, b) => (b.lessonCount ?? 0) - (a.lessonCount ?? 0));
} }
@@ -42,7 +48,7 @@
$courseMetaDeta.view = courseView; $courseMetaDeta.view = courseView;
} }
selectedId = localStorage.getItem('classroomio_filter_course_key') || '0'; sortKey = parseCourseSortValue(localStorage.getItem('classroomio_filter_course_key'));
}); });
$effect(() => { $effect(() => {
@@ -60,5 +66,5 @@
isLMS={true} isLMS={true}
isLoading={coursesApi.isLoading} isLoading={coursesApi.isLoading}
bind:searchValue bind:searchValue
bind:selectedId bind:sortKey
/> />
@@ -1,15 +1,26 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type { import type {
AssignAudienceCoursesRequest,
DeleteTeamRequest, DeleteTeamRequest,
DomainRequestRequest, DomainRequestRequest,
GetAudienceRequest, GetAudienceRequest,
GetOrgPublicCoursesRequest, GetOrgPublicCoursesRequest,
ImportAudienceRequest,
InviteTeamRequest, InviteTeamRequest,
OrgPublicCourses, OrgPublicCourses,
OrganizationAudience, OrganizationAudience,
OrganizationTeamMembers OrganizationTeamMembers,
ResendAudienceInviteRequest,
RevokeAudienceInviteRequest
} from '../utils/types'; } from '../utils/types';
import type { TCreateOrganization, TGetOrganizations, TUpdateOrganization } from '@cio/utils/validation/organization'; import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
TAssignAudienceCourses,
TAudienceInviteByEmail,
TCreateOrganization,
TGetOrganizations,
TImportAudienceMembers,
TUpdateOrganization
} from '@cio/utils/validation/organization';
import { ZCreateOrganization, ZUpdateOrganization } from '@cio/utils/validation/organization'; import { ZCreateOrganization, ZUpdateOrganization } from '@cio/utils/validation/organization';
import { currentOrg, orgs } from '$lib/utils/store/org'; import { currentOrg, orgs } from '$lib/utils/store/org';
@@ -370,6 +381,117 @@ class OrgApi extends BaseApiWithErrors {
} }
}); });
} }
/**
* Imports users into the organization as students
*/
async importAudienceMembers(data: TImportAudienceMembers) {
console.log('importAudienceMembers', data);
return this.execute<ImportAudienceRequest>({
requestFn: () =>
classroomio.organization.audience.import.$post({
json: data
}),
logContext: 'importing audience members',
onSuccess: (response) => {
const d = response.data;
snackbar.success(
t.get('audience.import.snackbar_success', {
imported: d.imported,
skipped: d.skipped,
emailsSent: d.emailsSent
})
);
this.success = true;
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result && 'field' in result) {
this.errors[result.field as string] = result.error;
}
}
});
}
/**
* Assigns existing audience members to courses
*/
async assignAudienceToCourses(data: TAssignAudienceCourses) {
return this.execute<AssignAudienceCoursesRequest>({
requestFn: () =>
classroomio.organization.audience['assign-courses'].$post({
json: data
}),
logContext: 'assigning audience to courses',
onSuccess: (response) => {
const d = response.data;
snackbar.success(
t.get('audience.assign.snackbar_success', {
assigned: d.assigned,
alreadyEnrolled: d.alreadyEnrolled,
emailsSent: d.emailsSent
})
);
this.success = true;
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result && 'field' in result) {
this.errors[result.field as string] = result.error;
}
}
});
}
async resendAudienceInvite(data: TAudienceInviteByEmail) {
return this.execute<ResendAudienceInviteRequest>({
requestFn: () =>
classroomio.organization.audience['resend-invite'].$post({
json: data
}),
logContext: 'resending audience invite',
onSuccess: (response) => {
if (response.data.emailSent) {
snackbar.success('audience.invite.resend_snackbar_success');
} else {
snackbar.error('audience.invite.resend_snackbar_email_failed');
}
const orgId = get(currentOrg).id;
if (orgId) this.getOrgAudience(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
}
}
});
}
async revokeAudienceInvite(data: TAudienceInviteByEmail) {
return this.execute<RevokeAudienceInviteRequest>({
requestFn: () =>
classroomio.organization.audience['revoke-invite'].$post({
json: data
}),
logContext: 'revoking audience invite',
onSuccess: () => {
snackbar.success('audience.invite.revoke_snackbar_success');
const orgId = get(currentOrg).id;
if (orgId) this.getOrgAudience(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
}
}
});
}
} }
export const orgApi = new OrgApi(); export const orgApi = new OrgApi();
@@ -23,6 +23,8 @@ export type OrganizationAudienceSuccess = Extract<InferResponseType<GetAudienceR
export type OrganizationAudience = OrganizationAudienceSuccess['data']; export type OrganizationAudience = OrganizationAudienceSuccess['data'];
export type OrganizationAudienceMember = OrganizationAudience[number];
// Org Public Courses types // Org Public Courses types
export type GetOrgPublicCoursesRequest = typeof classroomio.organization.courses.public.$get; export type GetOrgPublicCoursesRequest = typeof classroomio.organization.courses.public.$get;
export type OrgPublicCoursesResponse = InferResponseType<GetOrgPublicCoursesRequest> | null; export type OrgPublicCoursesResponse = InferResponseType<GetOrgPublicCoursesRequest> | null;
@@ -83,6 +85,20 @@ export type SsoDiscoveryResult = GetSsoDiscoverySuccess['data'];
export type GetOrgSsoInfoSuccess = Extract<InferResponseType<GetOrgSsoInfoRequest>, { success: true }>; export type GetOrgSsoInfoSuccess = Extract<InferResponseType<GetOrgSsoInfoRequest>, { success: true }>;
export type OrgSsoInfo = GetOrgSsoInfoSuccess['data']; export type OrgSsoInfo = GetOrgSsoInfoSuccess['data'];
// Audience import types
export type ImportAudienceRequest = (typeof classroomio.organization)['audience']['import']['$post'];
export type ImportAudienceSuccess = Extract<InferResponseType<ImportAudienceRequest>, { success: true }>;
export type ImportAudienceData = ImportAudienceSuccess['data'];
// Audience assign courses types
export type AssignAudienceCoursesRequest = (typeof classroomio.organization)['audience']['assign-courses']['$post'];
export type AssignAudienceCoursesSuccess = Extract<InferResponseType<AssignAudienceCoursesRequest>, { success: true }>;
export type AssignAudienceCoursesData = AssignAudienceCoursesSuccess['data'];
// Audience invite actions
export type ResendAudienceInviteRequest = (typeof classroomio.organization)['audience']['resend-invite']['$post'];
export type RevokeAudienceInviteRequest = (typeof classroomio.organization)['audience']['revoke-invite']['$post'];
// Token auth types // Token auth types
export type CreateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$post']; export type CreateTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$post'];
export type GetTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$get']; export type GetTokenAuthRequest = (typeof classroomio.organization)['token-auth']['$get'];
@@ -5,6 +5,7 @@
import type { UserAnalytics } from '$lib/utils/types/analytics'; import type { UserAnalytics } from '$lib/utils/types/analytics';
import AlarmClockIcon from '@lucide/svelte/icons/alarm-clock'; import AlarmClockIcon from '@lucide/svelte/icons/alarm-clock';
import MailIcon from '@lucide/svelte/icons/mail'; import MailIcon from '@lucide/svelte/icons/mail';
import UserIcon from '@lucide/svelte/icons/user';
interface Props { interface Props {
user: UserAnalytics['user']; user: UserAnalytics['user'];
@@ -16,11 +17,12 @@
<div class="rounded-md border p-5"> <div class="rounded-md border p-5">
<div class="flex w-full flex-col items-center justify-start gap-4 text-start md:flex-row"> <div class="flex w-full flex-col items-center justify-start gap-4 text-start md:flex-row">
<Avatar.Root class="h-16 w-16"> <Avatar.Root class="h-16 w-16">
<Avatar.Image {#if user.avatarUrl}
src={user.avatarUrl ? user.avatarUrl : '/logo-192.png'} <Avatar.Image src={user.avatarUrl} alt={user.fullName ? user.fullName : 'User'} />
alt={user.fullName ? user.fullName : 'User'} {/if}
/> <Avatar.Fallback>
<Avatar.Fallback>{user.fullName ? user.fullName : 'User'}</Avatar.Fallback> <UserIcon class="ui:size-8 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<p class="text-center text-2xl md:text-left dark:text-white"> <p class="text-center text-2xl md:text-left dark:text-white">
@@ -2,12 +2,12 @@
import get from 'lodash/get'; import get from 'lodash/get';
import z from 'zod'; import z from 'zod';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import UserIcon from '@lucide/svelte/icons/user';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import ReviewFormEditor from './review-form-editor.svelte'; import ReviewFormEditor from './review-form-editor.svelte';
import * as Avatar from '@cio/ui/base/avatar'; import * as Avatar from '@cio/ui/base/avatar';
import { processErrors } from '$lib/utils/functions/validator'; import { processErrors } from '$lib/utils/functions/validator';
import { shortenName } from '$lib/utils/functions/string';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import type { Course } from '$features/course/utils/types'; import type { Course } from '$features/course/utils/types';
@@ -98,11 +98,12 @@
<!-- the headers --> <!-- the headers -->
<div class="flex w-full items-center justify-between"> <div class="flex w-full items-center justify-between">
<Avatar.Root class="mt-1 h-10 w-10"> <Avatar.Root class="mt-1 h-10 w-10">
<Avatar.Image {#if review.avatar_url}
src={review.avatar_url ? review.avatar_url : '/logo-192.png'} <Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Reviewer'} />
alt={review.name ? review.name : 'Reviewer'} {/if}
/> <Avatar.Fallback>
<Avatar.Fallback>{shortenName(review.name) || 'R'}</Avatar.Fallback> <UserIcon class="ui:size-5 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<p class="text-sm">{review.name}</p> <p class="text-sm">{review.name}</p>
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { cn } from '@cio/ui/tools';
interface Props { interface Props {
id: string; id: string;
@@ -7,8 +9,14 @@
} }
let { id, children }: Props = $props(); let { id, children }: Props = $props();
let includesHash = $derived(page.url.hash?.includes(id) ?? false);
</script> </script>
<section {id} transition:fade={{ delay: 250, duration: 300 }} class="border-b border-gray-300 pt-12 pb-10"> <section
{id}
transition:fade={{ delay: 250, duration: 300 }}
class={cn('border-b border-gray-300 pt-5 pb-5', includesHash && 'pt-22')}
>
{@render children()} {@render children()}
</section> </section>
@@ -6,6 +6,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import PlayIcon from '@lucide/svelte/icons/play'; import PlayIcon from '@lucide/svelte/icons/play';
import UserIcon from '@lucide/svelte/icons/user';
import { getLectureNo } from '$features/course/utils/functions'; import { getLectureNo } from '$features/course/utils/functions';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
@@ -31,7 +32,6 @@
import * as Avatar from '@cio/ui/base/avatar'; import * as Avatar from '@cio/ui/base/avatar';
import PricingSection from './components/pricing-section.svelte'; import PricingSection from './components/pricing-section.svelte';
import NavSection from './components/nav-section.svelte'; import NavSection from './components/nav-section.svelte';
import { shortenName } from '$lib/utils/functions/string';
import SectionsDisplay from './components/sections-display.svelte'; import SectionsDisplay from './components/sections-display.svelte';
import { observeIntersection } from './components/intersection-observer'; import { observeIntersection } from './components/intersection-observer';
@@ -194,7 +194,7 @@
<!-- Course Details --> <!-- Course Details -->
<div class="course-content w-full p-3 lg:mr-10 lg:w-10/12"> <div class="course-content w-full p-3 lg:mr-10 lg:w-10/12">
<!-- Navigation --> <!-- Navigation -->
<UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-0 py-2"> <UnderlineTabs.Root bind:value={activeNav} class="ui:bg-background sticky top-10 py-2">
<UnderlineTabs.List> <UnderlineTabs.List>
{#each navItems as navItem (navItem.key)} {#each navItems as navItem (navItem.key)}
<UnderlineTabs.Trigger value={navItem.key}> <UnderlineTabs.Trigger value={navItem.key}>
@@ -316,15 +316,14 @@
<!-- review --> <!-- review -->
<div class="item-start my-2 flex w-2/4 flex-row"> <div class="item-start my-2 flex w-2/4 flex-row">
<!-- image container --> <!-- image container -->
{#if review.avatar_url} <Avatar.Root class="mt-1 size-10">
<Avatar.Root class="mt-1 size-10"> {#if review.avatar_url}
<Avatar.Image <Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
src={review.avatar_url ? review.avatar_url : '/logo-192.png'} {/if}
alt={review.name ? review.name : 'Avatar'} <Avatar.Fallback>
/> <UserIcon class="ui:size-5 ui:text-muted-foreground" />
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
{/if}
<!-- profile content --> <!-- profile content -->
<div class="w-11/12 pl-2.5"> <div class="w-11/12 pl-2.5">
@@ -385,15 +384,14 @@
<!-- review --> <!-- review -->
<div class="item-start my-2 flex w-full flex-row"> <div class="item-start my-2 flex w-full flex-row">
<!-- image container --> <!-- image container -->
{#if review.avatar_url} <Avatar.Root class="mt-1 h-10 w-10">
<Avatar.Root class="mt-1 h-10 w-10"> {#if review.avatar_url}
<Avatar.Image <Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
src={review.avatar_url ? review.avatar_url : '/logo-192.png'} {/if}
alt={review.name ? review.name : 'Avatar'} <Avatar.Fallback>
/> <UserIcon class="ui:size-5 ui:text-muted-foreground" />
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
{/if}
<!-- profile content --> <!-- profile content -->
<div class="w-11/12 pl-2.5"> <div class="w-11/12 pl-2.5">
@@ -48,7 +48,7 @@ export const landingPageStore = writable({
id: 1, id: 1,
show: true, show: true,
name: 'Adeyemi Adetunji', name: 'Adeyemi Adetunji',
avatar_url: '/logo-192.png', avatar_url: '',
rating: 1, rating: 1,
created_at: new Date(), created_at: new Date(),
description: 'rice ventore officiis maxime laudantium recusandae sit dolor.' description: 'rice ventore officiis maxime laudantium recusandae sit dolor.'
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { basePath, globalStore } from '$lib/utils/store/app'; import { basePath, globalStore } from '$lib/utils/store/app';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu'; import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import RocketIcon from '@lucide/svelte/icons/rocket'; import RocketIcon from '@lucide/svelte/icons/rocket';
@@ -12,7 +14,6 @@
import ThemeToggle from './theme-toggle.svelte'; import ThemeToggle from './theme-toggle.svelte';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { logout } from '$lib/utils/functions/logout';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
const sidebar = useSidebar(); const sidebar = useSidebar();
@@ -36,7 +37,7 @@
{#snippet usertrigger()} {#snippet usertrigger()}
<DropdownMenu.Label class="p-0"> <DropdownMenu.Label class="p-0">
<a href={`${$basePath}/settings`} class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <a href={resolve(`${$basePath}/settings`, {})} class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
{@render avatarblock()} {@render avatarblock()}
</a> </a>
</DropdownMenu.Label> </DropdownMenu.Label>
@@ -122,7 +123,7 @@
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => logout()}> <DropdownMenu.Item onclick={() => goto(resolve(`/logout`, {}))}>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<LogOutIcon /> <LogOutIcon />
<p class="text-sm">{$t('settings.profile.logout')}</p> <p class="text-sm">{$t('settings.profile.logout')}</p>
@@ -8,7 +8,7 @@ export const ROUTE = {
COURSE: '/course', COURSE: '/course',
LOGIN: '/login', LOGIN: '/login',
SIGN_UP: '/signup', SIGN_UP: '/signup',
INVITE_TEACHER: '/invite/t', INVITE: '/invite',
PROFILE: '/profile', PROFILE: '/profile',
PEOPLE: '/people', PEOPLE: '/people',
DISCUSSIONS: '/discussions', DISCUSSIONS: '/discussions',
@@ -25,7 +25,7 @@ export const PUBLIC_ROUTES = [
ROUTE.LOGIN, ROUTE.LOGIN,
ROUTE.LOGOUT, ROUTE.LOGOUT,
ROUTE.SIGN_UP, ROUTE.SIGN_UP,
`^${ROUTE.INVITE_TEACHER}/.*`, `^${ROUTE.INVITE}/.*`,
ROUTE.FORGOT, ROUTE.FORGOT,
ROUTE.RESET, ROUTE.RESET,
`^${ROUTE.PAGES}/.*`, `^${ROUTE.PAGES}/.*`,
@@ -42,7 +42,7 @@ export const ROUTES_TO_HIDE_NAV = [
`^${ROUTE.LOGIN}$`, `^${ROUTE.LOGIN}$`,
`^${ROUTE.SIGN_UP}$`, `^${ROUTE.SIGN_UP}$`,
ROUTE.LMS_HOME, ROUTE.LMS_HOME,
`^${ROUTE.INVITE_TEACHER}/.*`, `^${ROUTE.INVITE}/.*`,
`^/course/.*/enroll$`, `^/course/.*/enroll$`,
`^${ROUTE.FORGOT}$`, `^${ROUTE.FORGOT}$`,
`^${ROUTE.RESET}$`, `^${ROUTE.RESET}$`,
@@ -11,3 +11,14 @@ export function getCioCookieString(cookies: Cookies): string {
.map((c) => `${c.name}=${c.value}`) .map((c) => `${c.name}=${c.value}`)
.join('; '); .join('; ');
} }
export function getHasCioCookies(cookies: Cookies): boolean {
const cioCookies = cookies.getAll().filter((c) => c.name.includes('classroomio'));
if (cioCookies.length === 0) return false;
// check if the cookies doesn't includ ONLY classroomio_locale
const onlyLocaleCookie = cioCookies.every((c) => c.name === 'classroomio_locale');
return !onlyLocaleCookie;
}
@@ -475,7 +475,54 @@
"name": "Navn", "name": "Navn",
"email": "E-mail", "email": "E-mail",
"date_joined": "Dato tilmeldt", "date_joined": "Dato tilmeldt",
"upgrade": "Opgrader din plan for at invitere flere elever" "upgrade": "Opgrader din plan for at invitere flere elever",
"import_users": "Importer brugere",
"search_placeholder": "Søg...",
"status": "Status",
"status_active": "Aktiv",
"status_pending": "Afventer",
"status_expired": "Udløbet",
"status_revoked": "Tilbagekaldt",
"select_all": "Vælg alle",
"selected_count": "{count} valgt",
"assign_courses": "Tildel til kurser",
"invite": {
"row_actions_aria": "Åbne handlinger",
"resend": "Send invitationen igen",
"revoke": "Tilbagekald afventende invitation",
"resend_snackbar_success": "Invitationsmail sendt",
"resend_snackbar_email_failed": "Invitationen blev fornyet, men e-mailen kunne ikke sendes",
"revoke_snackbar_success": "Afventende invitation tilbagekaldt"
},
"assign": {
"title": "Tildel til kurser",
"description": "Giv de valgte brugere adgang til kurser og giv dem eventuelt besked via e-mail.",
"select_courses": "Vælg kurser",
"select_courses_placeholder": "Vælg kurser...",
"send_email": "Send notifikations-e-mail",
"submit": "Tildel",
"cancel": "Annuller",
"snackbar_success": "Tildelt {assigned} brugere til kurser ({alreadyEnrolled} allerede tilmeldt, {emailsSent} e-mails sendt)",
"snackbar_no_courses": "Vælg venligst mindst ét kursus",
"snackbar_failed": "Kunne ikke tildele kurser"
},
"import": {
"title": "Importer brugere",
"description": "Tilføj elever til din organisation ved at indtaste deres e-mailadresser. Tildel dem eventuelt til kurser og send invitationsmails.",
"emails_label": "E-mail-adresser",
"emails_placeholder": "Indtast e-mails adskilt af kommaer, nye linjer eller semikolon\nf.eks. [email protected], [email protected]",
"course_access": "Kursusadgang",
"no_courses": "Ingen kursusopgave",
"all_courses": "Alle kurser",
"select_courses": "Vælg specifikke kurser",
"select_courses_placeholder": "Vælg kurser...",
"send_email": "Send invitationsmail til importerede brugere",
"submit": "Importer brugere",
"back": "Tilbage til Publikum",
"snackbar_success": "Importerede {importerede} brugere ({skipped} eksisterede allerede, {emailsSent} e-mails sendt)",
"snackbar_no_emails": "Indtast venligst mindst én e-mailadresse",
"snackbar_failed": "Kunne ikke importere brugere"
}
}, },
"community": { "community": {
"title": "Fællesskab", "title": "Fællesskab",
@@ -940,6 +987,8 @@
"complete": "Komplet", "complete": "Komplet",
"next": "Næste", "next": "Næste",
"prev": "Prev", "prev": "Prev",
"prev_shortcut": "Forrige (←)",
"next_shortcut": "Næste (→)",
"disabled": "Funktionen er deaktiveret", "disabled": "Funktionen er deaktiveret",
"version_history": { "version_history": {
"title": "Versionshistorik", "title": "Versionshistorik",
@@ -1200,6 +1249,9 @@
}, },
"edit": { "edit": {
"accepted_answers_placeholder": "Accepterede svar (kommasepareret)" "accepted_answers_placeholder": "Accepterede svar (kommasepareret)"
},
"review": {
"your_answer_label": "Indsendt:"
} }
}, },
"numeric": { "numeric": {
@@ -1217,6 +1269,11 @@
"tolerance_info": "Tilladt forskel fra den korrekte værdi (f.eks. accepterer 0,5 +/-0,5).", "tolerance_info": "Tilladt forskel fra den korrekte værdi (f.eks. accepterer 0,5 +/-0,5).",
"correct_value_placeholder": "Korrekt værdi", "correct_value_placeholder": "Korrekt værdi",
"tolerance_placeholder": "Tolerance" "tolerance_placeholder": "Tolerance"
},
"review": {
"your_answer_label": "Indsendt:",
"in_range": "Inden for tolerance",
"out_of_range": "Ydre tolerance"
} }
}, },
"short_answer": { "short_answer": {
@@ -1228,6 +1285,10 @@
}, },
"edit": { "edit": {
"instructions_placeholder": "Valgfri instruktioner" "instructions_placeholder": "Valgfri instruktioner"
},
"review": {
"your_answer_label": "Indsendt:",
"accepted_label": "Accepterede svar"
} }
}, },
"true_false": { "true_false": {
@@ -1291,8 +1352,9 @@
"option_prefix": "Mulighed", "option_prefix": "Mulighed",
"remove": "Fjern", "remove": "Fjern",
"add_option": "Tilføj mulighed", "add_option": "Tilføj mulighed",
"correct_badge": "(korrekt)", "correct_badge": "korrekt",
"not_set": "Ikke indstillet" "not_set": "Ikke indstillet",
"incorrect_badge": "Forkert"
} }
} }
}, },
@@ -1473,7 +1535,10 @@
"cancel": "Annuller" "cancel": "Annuller"
}, },
"content_locked_title": "Dette indhold er låst", "content_locked_title": "Dette indhold er låst",
"content_locked_description": "Din instruktør har ikke låst dette indhold op endnu. Fuldfør tidligere indhold, eller tjek tilbage senere." "content_locked_description": "Din instruktør har ikke låst dette indhold op endnu. Fuldfør tidligere indhold, eller tjek tilbage senere.",
"saving": "Gemmer...",
"saved": "Gemt",
"draft_recovery": "Der blev fundet et ikke-gemt udkast til denne lektion. Vil du gendanne den?"
}, },
"news_feed": { "news_feed": {
"heading": "Nyhedsfeed", "heading": "Nyhedsfeed",
@@ -1582,6 +1647,7 @@
"unpublished": "Upubliceret", "unpublished": "Upubliceret",
"continue_course": "Fortsæt", "continue_course": "Fortsæt",
"learn_more": "Få flere oplysninger", "learn_more": "Få flere oplysninger",
"tags_overflow_aria": "{count} flere tags",
"list_view": { "list_view": {
"title": "Titel", "title": "Titel",
"description": "Beskrivelse", "description": "Beskrivelse",
@@ -2245,7 +2311,8 @@
"email_password_disabled_message": "E-mail/adgangskodelogin er deaktiveret for denne organisation.", "email_password_disabled_message": "E-mail/adgangskodelogin er deaktiveret for denne organisation.",
"google_auth_disabled": "Google-godkendelse er deaktiveret.", "google_auth_disabled": "Google-godkendelse er deaktiveret.",
"signup_disabled_error": "Tilmelding er deaktiveret for denne organisation. Kontakt venligst din administrator.", "signup_disabled_error": "Tilmelding er deaktiveret for denne organisation. Kontakt venligst din administrator.",
"or_continue_with": "Eller fortsæt med e-mail og adgangskode" "or_continue_with": "Eller fortsæt med e-mail og adgangskode",
"sso_enabled": "{provider} er aktiveret for denne organisation"
}, },
"tabs": { "tabs": {
"general": "Generelt", "general": "Generelt",
@@ -2471,7 +2538,8 @@
"edit_group": "Rediger gruppe", "edit_group": "Rediger gruppe",
"edit_tag": "Rediger tag", "edit_tag": "Rediger tag",
"save": "Gem", "save": "Gem",
"cancel": "Annuller" "cancel": "Annuller",
"actions_menu": "Tag handlinger"
}, },
"common": { "common": {
"not_available": "N/A" "not_available": "N/A"
@@ -2480,9 +2548,13 @@
"invite": { "invite": {
"organization": { "organization": {
"page_title": "Tilmeld dig {orgName} på ClassroomIO", "page_title": "Tilmeld dig {orgName} på ClassroomIO",
"role_label": "Rolle: {role}", "invitation_student": "Du er blevet inviteret til {orgName} som elev.",
"email_label": "Inviteret e-mail: {email}", "invitation_teacher": "Du er blevet inviteret til {orgName} som lærer.",
"join_button": "Deltag i organisationen", "invitation_admin": "Du er blevet inviteret til {orgName} som administrator.",
"invitation_fallback": "Du er blevet inviteret til {orgName} som {role}.",
"email_line": "E-mail: {email}",
"accept_button": "Acceptér invitation",
"go_to_dashboard": "Gå til dashboard",
"messages": { "messages": {
"email_mismatch": "Du er logget ind med en anden e-mail end denne invitation.", "email_mismatch": "Du er logget ind med en anden e-mail end denne invitation.",
"expired": "Dette invitationslink er udløbet.", "expired": "Dette invitationslink er udløbet.",
@@ -2520,6 +2592,17 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Create key" "save": "Create key"
} }
},
"usage": {
"title": "Anvendelse og grænser",
"description": "MCP er tilgængelig på alle abonnementer, men brugen styres med månedlige automatiseringskreditter og nøglegrænser.",
"credits_title": "Kreditter tilbage",
"credits_description": "Muterende MCP-handlinger bruger kreditter. Læsehandlinger er gratis.",
"keys_title": "Aktive nøgler",
"keys_description": "Brug én MCP-nøgle pr. integration, så adgang kan roteres eller tilbagekaldes rent.",
"rate_limits_title": "Satsgrænser",
"rate_limits_description": "Læs/skriv/publicér anmodninger tilladt pr. minut.",
"used_label": "Brugt:"
} }
}, },
"keys": { "keys": {
@@ -2534,7 +2617,11 @@
"never_used": "Aldrig", "never_used": "Aldrig",
"scopes_label": "Omfang:", "scopes_label": "Omfang:",
"revoke_confirm": "Vil du tilbagekalde denne automatiseringsnøgle?", "revoke_confirm": "Vil du tilbagekalde denne automatiseringsnøgle?",
"rotate_confirm": "Drej denne automatiseringsnøgle?" "rotate_confirm": "Drej denne automatiseringsnøgle?",
"table_key": "Nøgle",
"table_status": "Status",
"table_created": "Oprettet",
"table_last_used": "Sidst brugt"
}, },
"clients": { "clients": {
"claude_code": "Claude kode", "claude_code": "Claude kode",
@@ -475,7 +475,54 @@
"name": "Name", "name": "Name",
"email": "E-Mail", "email": "E-Mail",
"date_joined": "Datum des Beitritts", "date_joined": "Datum des Beitritts",
"upgrade": "Aktualisieren Sie Ihren Plan, um mehr Studenten einzuladen" "upgrade": "Aktualisieren Sie Ihren Plan, um mehr Studenten einzuladen",
"import_users": "Benutzer importieren",
"search_placeholder": "Suchen…",
"status": "Status",
"status_active": "Aktiv",
"status_pending": "Ausstehend",
"status_expired": "Abgelaufen",
"status_revoked": "Widerrufen",
"select_all": "Alles auswählen",
"selected_count": "{count} ausgewählt",
"assign_courses": "Zu Kursen zuweisen",
"invite": {
"row_actions_aria": "Offene Aktionen",
"resend": "Einladung erneut senden",
"revoke": "Ausstehende Einladung widerrufen",
"resend_snackbar_success": "Einladungs-E-Mail gesendet",
"resend_snackbar_email_failed": "Die Einladung wurde erneuert, die E-Mail konnte jedoch nicht gesendet werden",
"revoke_snackbar_success": "Ausstehende Einladung widerrufen"
},
"assign": {
"title": "Zu Kursen zuweisen",
"description": "Gewähren Sie den ausgewählten Benutzern Zugriff auf Kurse und benachrichtigen Sie sie optional per E-Mail.",
"select_courses": "Kurse auswählen",
"select_courses_placeholder": "Kurse auswählen...",
"send_email": "Benachrichtigungs-E-Mail senden",
"submit": "Zuweisen",
"cancel": "Abbrechen",
"snackbar_success": "{assigned} Benutzer den Kursen zugewiesen ({alreadyEnrolled} bereits angemeldet, {emailsSent} E-Mails gesendet)",
"snackbar_no_courses": "Bitte wählen Sie mindestens einen Kurs aus",
"snackbar_failed": "Kurse konnten nicht zugewiesen werden"
},
"import": {
"title": "Benutzer importieren",
"description": "Fügen Sie Ihrer Organisation Studenten hinzu, indem Sie deren E-Mail-Adressen eingeben. Ordnen Sie sie optional Kursen zu und versenden Sie Einladungs-E-Mails.",
"emails_label": "E-Mail-Adressen",
"emails_placeholder": "Geben Sie E-Mails durch Kommas, Zeilenumbrüche oder Semikolons getrennt ein\nz.B. [email protected], [email protected]",
"course_access": "Kurszugang",
"no_courses": "Keine Kurszuordnung",
"all_courses": "Alle Kurse",
"select_courses": "Wählen Sie bestimmte Kurse aus",
"select_courses_placeholder": "Kurse auswählen...",
"send_email": "Senden Sie Einladungs-E-Mails an importierte Benutzer",
"submit": "Benutzer importieren",
"back": "Zurück zum Publikum",
"snackbar_success": "Importierte {imported} Benutzer ({skipped} existierte bereits, {emailsSent} E-Mails wurden gesendet)",
"snackbar_no_emails": "Bitte geben Sie mindestens eine E-Mail-Adresse ein",
"snackbar_failed": "Benutzer konnten nicht importiert werden"
}
}, },
"community": { "community": {
"title": "Gemeinschaft", "title": "Gemeinschaft",
@@ -940,6 +987,8 @@
"complete": "Komplett", "complete": "Komplett",
"next": "Als nächstes", "next": "Als nächstes",
"prev": "Vorher", "prev": "Vorher",
"prev_shortcut": "Zurück (←)",
"next_shortcut": "Weiter (→)",
"disabled": "Funktion ist deaktiviert", "disabled": "Funktion ist deaktiviert",
"version_history": { "version_history": {
"title": "Versionsgeschichte", "title": "Versionsgeschichte",
@@ -1200,6 +1249,9 @@
}, },
"edit": { "edit": {
"accepted_answers_placeholder": "Akzeptierte Antworten (durch Kommas getrennt)" "accepted_answers_placeholder": "Akzeptierte Antworten (durch Kommas getrennt)"
},
"review": {
"your_answer_label": "Eingereicht:"
} }
}, },
"numeric": { "numeric": {
@@ -1217,6 +1269,11 @@
"tolerance_info": "Zulässige Abweichung vom korrekten Wert (Beispiel: 0,5 akzeptiert +/-0,5).", "tolerance_info": "Zulässige Abweichung vom korrekten Wert (Beispiel: 0,5 akzeptiert +/-0,5).",
"correct_value_placeholder": "Korrekter Wert", "correct_value_placeholder": "Korrekter Wert",
"tolerance_placeholder": "Toleranz" "tolerance_placeholder": "Toleranz"
},
"review": {
"your_answer_label": "Eingereicht:",
"in_range": "Innerhalb der Toleranz",
"out_of_range": "Außerhalb der Toleranz"
} }
}, },
"short_answer": { "short_answer": {
@@ -1228,6 +1285,10 @@
}, },
"edit": { "edit": {
"instructions_placeholder": "Optionale Anweisungen" "instructions_placeholder": "Optionale Anweisungen"
},
"review": {
"your_answer_label": "Eingereicht:",
"accepted_label": "Akzeptierte Antworten"
} }
}, },
"true_false": { "true_false": {
@@ -1291,8 +1352,9 @@
"option_prefix": "Option", "option_prefix": "Option",
"remove": "Entfernen", "remove": "Entfernen",
"add_option": "Option hinzufügen", "add_option": "Option hinzufügen",
"correct_badge": "(Richtig)", "correct_badge": "Richtig",
"not_set": "Nicht festgelegt" "not_set": "Nicht festgelegt",
"incorrect_badge": "Falsch"
} }
} }
}, },
@@ -1473,7 +1535,10 @@
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"content_locked_title": "Dieser Inhalt ist gesperrt", "content_locked_title": "Dieser Inhalt ist gesperrt",
"content_locked_description": "Ihr Kursleiter hat diesen Inhalt noch nicht freigeschaltet. Vervollständigen Sie den vorherigen Inhalt oder schauen Sie später noch einmal vorbei." "content_locked_description": "Ihr Kursleiter hat diesen Inhalt noch nicht freigeschaltet. Vervollständigen Sie den vorherigen Inhalt oder schauen Sie später noch einmal vorbei.",
"saving": "Sparen…",
"saved": "Gespeichert",
"draft_recovery": "Für diese Lektion wurde ein nicht gespeicherter Entwurf gefunden. Möchten Sie es wiederherstellen?"
}, },
"news_feed": { "news_feed": {
"heading": "Newsfeed", "heading": "Newsfeed",
@@ -1582,6 +1647,7 @@
"unpublished": "Unveröffentlicht", "unpublished": "Unveröffentlicht",
"continue_course": "Weiter", "continue_course": "Weiter",
"learn_more": "Erfahren Sie mehr", "learn_more": "Erfahren Sie mehr",
"tags_overflow_aria": "{count} weitere Tags",
"list_view": { "list_view": {
"title": "Titel", "title": "Titel",
"description": "Beschreibung", "description": "Beschreibung",
@@ -2245,7 +2311,8 @@
"email_password_disabled_message": "Die E-Mail-/Passwort-Anmeldung ist für diese Organisation deaktiviert.", "email_password_disabled_message": "Die E-Mail-/Passwort-Anmeldung ist für diese Organisation deaktiviert.",
"google_auth_disabled": "Die Google-Authentifizierung ist deaktiviert.", "google_auth_disabled": "Die Google-Authentifizierung ist deaktiviert.",
"signup_disabled_error": "Die Anmeldung ist für diese Organisation deaktiviert. Bitte wenden Sie sich an Ihren Administrator.", "signup_disabled_error": "Die Anmeldung ist für diese Organisation deaktiviert. Bitte wenden Sie sich an Ihren Administrator.",
"or_continue_with": "Oder fahren Sie mit E-Mail und Passwort fort" "or_continue_with": "Oder fahren Sie mit E-Mail und Passwort fort",
"sso_enabled": "{provider} ist für diese Organisation aktiviert"
}, },
"tabs": { "tabs": {
"general": "Allgemein", "general": "Allgemein",
@@ -2471,7 +2538,8 @@
"edit_group": "Gruppe bearbeiten", "edit_group": "Gruppe bearbeiten",
"edit_tag": "Tag bearbeiten", "edit_tag": "Tag bearbeiten",
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen" "cancel": "Abbrechen",
"actions_menu": "Markieren Sie Aktionen"
}, },
"common": { "common": {
"not_available": "N/A" "not_available": "N/A"
@@ -2480,9 +2548,13 @@
"invite": { "invite": {
"organization": { "organization": {
"page_title": "Treten Sie {orgName} auf ClassroomIO bei", "page_title": "Treten Sie {orgName} auf ClassroomIO bei",
"role_label": "Rolle: {role}", "invitation_student": "Sie wurden zu {orgName} als Schüler/in eingeladen.",
"email_label": "Eingeladene E-Mail: {email}", "invitation_teacher": "Sie wurden zu {orgName} als Lehrer/in eingeladen.",
"join_button": "Treten Sie der Organisation bei", "invitation_admin": "Sie wurden zu {orgName} als Administrator/in eingeladen.",
"invitation_fallback": "Sie wurden zu {orgName} als {role} eingeladen.",
"email_line": "E-Mail: {email}",
"accept_button": "Einladung annehmen",
"go_to_dashboard": "Zum Dashboard",
"messages": { "messages": {
"email_mismatch": "Sie sind mit einer anderen E-Mail-Adresse als dieser Einladung angemeldet.", "email_mismatch": "Sie sind mit einer anderen E-Mail-Adresse als dieser Einladung angemeldet.",
"expired": "Dieser Einladungslink ist abgelaufen.", "expired": "Dieser Einladungslink ist abgelaufen.",
@@ -2520,6 +2592,17 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Create key" "save": "Create key"
} }
},
"usage": {
"title": "Nutzung und Grenzen",
"description": "MCP ist in jedem Plan verfügbar, die Nutzung wird jedoch durch monatliche Automatisierungsguthaben und Schlüssellimits gesteuert.",
"credits_title": "Verbleibende Credits",
"credits_description": "Mutierende MCP-Aktionen verbrauchen Credits. Leseaktionen sind kostenlos.",
"keys_title": "Aktive Schlüssel",
"keys_description": "Verwenden Sie einen MCP-Schlüssel pro Integration, damit der Zugriff sauber rotiert oder widerrufen werden kann.",
"rate_limits_title": "Tarifbegrenzungen",
"rate_limits_description": "Zulässige Lese-/Schreib-/Veröffentlichungsanfragen pro Minute.",
"used_label": "Verwendet:"
} }
}, },
"keys": { "keys": {
@@ -2534,7 +2617,11 @@
"never_used": "Niemals", "never_used": "Niemals",
"scopes_label": "Geltungsbereiche:", "scopes_label": "Geltungsbereiche:",
"revoke_confirm": "Diesen Automatisierungsschlüssel widerrufen?", "revoke_confirm": "Diesen Automatisierungsschlüssel widerrufen?",
"rotate_confirm": "Diesen Automatisierungsschlüssel drehen?" "rotate_confirm": "Diesen Automatisierungsschlüssel drehen?",
"table_key": "Schlüssel",
"table_status": "Status",
"table_created": "Erstellt",
"table_last_used": "Zuletzt verwendet"
}, },
"clients": { "clients": {
"claude_code": "Claude Code", "claude_code": "Claude Code",
@@ -902,6 +902,7 @@
"unpublished": "Unpublished", "unpublished": "Unpublished",
"continue_course": "Continue", "continue_course": "Continue",
"learn_more": "Learn more", "learn_more": "Learn more",
"tags_overflow_aria": "{count} more tags",
"list_view": { "list_view": {
"title": "Title", "title": "Title",
"description": "Description", "description": "Description",
@@ -1084,6 +1085,8 @@
"complete": "Complete", "complete": "Complete",
"next": "Next", "next": "Next",
"prev": "Prev", "prev": "Prev",
"prev_shortcut": "Previous (←)",
"next_shortcut": "Next (→)",
"materials": { "materials": {
"body_heading": "No note, video or slide added for this lesson yet", "body_heading": "No note, video or slide added for this lesson yet",
"body_content": "Share your knowledge with your students by creating engaging content. Start by clicking on", "body_content": "Share your knowledge with your students by creating engaging content. Start by clicking on",
@@ -1345,7 +1348,8 @@
"option_prefix": "Option", "option_prefix": "Option",
"remove": "Remove", "remove": "Remove",
"add_option": "Add option", "add_option": "Add option",
"correct_badge": "(Correct)", "correct_badge": "Correct",
"incorrect_badge": "Incorrect",
"not_set": "Not set" "not_set": "Not set"
}, },
"navigation": { "navigation": {
@@ -1414,6 +1418,10 @@
}, },
"preview": { "preview": {
"helper": "Free-text answer question." "helper": "Free-text answer question."
},
"review": {
"your_answer_label": "Submitted:",
"accepted_label": "Accepted answers"
} }
}, },
"numeric": { "numeric": {
@@ -1431,6 +1439,11 @@
"preview": { "preview": {
"correct_value_label": "Correct value", "correct_value_label": "Correct value",
"tolerance_label": "Tolerance" "tolerance_label": "Tolerance"
},
"review": {
"your_answer_label": "Submitted:",
"in_range": "Within tolerance",
"out_of_range": "Outside tolerance"
} }
}, },
"fill_blank": { "fill_blank": {
@@ -1442,6 +1455,9 @@
}, },
"preview": { "preview": {
"accepted_answers_label": "Accepted answers" "accepted_answers_label": "Accepted answers"
},
"review": {
"your_answer_label": "Submitted:"
} }
}, },
"file_upload": { "file_upload": {
@@ -2015,6 +2031,12 @@
"manage": "Manage all your students here and stay connected with them", "manage": "Manage all your students here and stay connected with them",
"name": "Name", "name": "Name",
"email": "Email", "email": "Email",
"search_placeholder": "Search…",
"status": "Status",
"status_active": "Active",
"status_pending": "Pending",
"status_expired": "Expired",
"status_revoked": "Revoked",
"date_joined": "Date Joined", "date_joined": "Date Joined",
"upgrade": "Upgrade your plan to invite more students", "upgrade": "Upgrade your plan to invite more students",
"select_all": "Select all", "select_all": "Select all",
@@ -2048,6 +2070,14 @@
"snackbar_success": "Assigned {assigned} users to courses ({alreadyEnrolled} already enrolled, {emailsSent} emails sent)", "snackbar_success": "Assigned {assigned} users to courses ({alreadyEnrolled} already enrolled, {emailsSent} emails sent)",
"snackbar_no_courses": "Please select at least one course", "snackbar_no_courses": "Please select at least one course",
"snackbar_failed": "Failed to assign courses" "snackbar_failed": "Failed to assign courses"
},
"invite": {
"row_actions_aria": "Open actions",
"resend": "Resend invite",
"revoke": "Revoke pending invite",
"resend_snackbar_success": "Invitation email sent",
"resend_snackbar_email_failed": "Invite was renewed but the email could not be sent",
"revoke_snackbar_success": "Pending invitation revoked"
} }
}, },
"analytics": { "analytics": {
@@ -2453,9 +2483,13 @@
"invite": { "invite": {
"organization": { "organization": {
"page_title": "Join {orgName} on ClassroomIO", "page_title": "Join {orgName} on ClassroomIO",
"role_label": "Role: {role}", "invitation_student": "You've been invited into {orgName} as a student.",
"email_label": "Invited email: {email}", "invitation_teacher": "You've been invited into {orgName} as a teacher.",
"join_button": "Join Organization", "invitation_admin": "You've been invited into {orgName} as an admin.",
"invitation_fallback": "You've been invited into {orgName} as {role}.",
"email_line": "Email: {email}",
"accept_button": "Accept invitation",
"go_to_dashboard": "Go to Dashboard",
"messages": { "messages": {
"email_mismatch": "You are logged in with a different email from this invite.", "email_mismatch": "You are logged in with a different email from this invite.",
"expired": "This invite link has expired.", "expired": "This invite link has expired.",
@@ -475,7 +475,54 @@
"name": "Nombre", "name": "Nombre",
"email": "Correo electrónico", "email": "Correo electrónico",
"date_joined": "Fecha de incorporación", "date_joined": "Fecha de incorporación",
"upgrade": "Actualice su plan para invitar a más estudiantes" "upgrade": "Actualice su plan para invitar a más estudiantes",
"import_users": "Importar usuarios",
"search_placeholder": "Buscar…",
"status": "Estado",
"status_active": "Activo",
"status_pending": "Pendiente",
"status_expired": "Caducado",
"status_revoked": "Revocado",
"select_all": "Seleccionar todo",
"selected_count": "{count} seleccionado",
"assign_courses": "Asignar a cursos",
"invite": {
"row_actions_aria": "Acciones abiertas",
"resend": "Reenviar invitación",
"revoke": "Revocar invitación pendiente",
"resend_snackbar_success": "Correo electrónico de invitación enviado",
"resend_snackbar_email_failed": "La invitación se renovó pero no se pudo enviar el correo electrónico.",
"revoke_snackbar_success": "Invitación pendiente revocada"
},
"assign": {
"title": "Asignar a cursos",
"description": "Dar acceso a los cursos a los usuarios seleccionados y, opcionalmente, notificarles por correo electrónico.",
"select_courses": "Seleccionar cursos",
"select_courses_placeholder": "Elige cursos...",
"send_email": "Enviar correo electrónico de notificación",
"submit": "Asignar",
"cancel": "Cancelar",
"snackbar_success": "Usuarios asignados {assigned} a cursos ({alreadyEnrolled} ya inscritos, {emailsSent} correos electrónicos enviados)",
"snackbar_no_courses": "Por favor seleccione al menos un curso",
"snackbar_failed": "No se pudieron asignar cursos"
},
"import": {
"title": "Importar usuarios",
"description": "Agregue estudiantes a su organización ingresando sus direcciones de correo electrónico. Opcionalmente, asígnalos a cursos y envía correos electrónicos de invitación.",
"emails_label": "Direcciones de correo electrónico",
"emails_placeholder": "Ingrese correos electrónicos separados por comas, nuevas líneas o punto y coma\npor ej. [email protected], [email protected]",
"course_access": "Acceso al curso",
"no_courses": "Sin tarea de curso",
"all_courses": "Todos los cursos",
"select_courses": "Seleccionar cursos específicos",
"select_courses_placeholder": "Elige cursos...",
"send_email": "Enviar correo electrónico de invitación a usuarios importados",
"submit": "Importar usuarios",
"back": "Volver a la audiencia",
"snackbar_success": "Usuarios importados {importados} ({skipped} ya existía, {emailsSent} correos electrónicos enviados)",
"snackbar_no_emails": "Por favor ingrese al menos una dirección de correo electrónico",
"snackbar_failed": "No se pudieron importar usuarios"
}
}, },
"community": { "community": {
"title": "Comunidad", "title": "Comunidad",
@@ -940,6 +987,8 @@
"complete": "completo", "complete": "completo",
"next": "Siguiente", "next": "Siguiente",
"prev": "Anterior", "prev": "Anterior",
"prev_shortcut": "Anterior (←)",
"next_shortcut": "Siguiente (→)",
"disabled": "La característica está deshabilitada", "disabled": "La característica está deshabilitada",
"version_history": { "version_history": {
"title": "Historial de versiones", "title": "Historial de versiones",
@@ -1200,6 +1249,9 @@
}, },
"edit": { "edit": {
"accepted_answers_placeholder": "Respuestas aceptadas (separadas por comas)" "accepted_answers_placeholder": "Respuestas aceptadas (separadas por comas)"
},
"review": {
"your_answer_label": "Enviado:"
} }
}, },
"numeric": { "numeric": {
@@ -1217,6 +1269,11 @@
"tolerance_info": "Diferencia permitida con respecto al valor correcto (por ejemplo, 0,5 acepta +/-0,5).", "tolerance_info": "Diferencia permitida con respecto al valor correcto (por ejemplo, 0,5 acepta +/-0,5).",
"correct_value_placeholder": "Valor correcto", "correct_value_placeholder": "Valor correcto",
"tolerance_placeholder": "Tolerancia" "tolerance_placeholder": "Tolerancia"
},
"review": {
"your_answer_label": "Enviado:",
"in_range": "Dentro de la tolerancia",
"out_of_range": "Tolerancia exterior"
} }
}, },
"short_answer": { "short_answer": {
@@ -1228,6 +1285,10 @@
}, },
"edit": { "edit": {
"instructions_placeholder": "Instrucciones opcionales" "instructions_placeholder": "Instrucciones opcionales"
},
"review": {
"your_answer_label": "Enviado:",
"accepted_label": "Respuestas aceptadas"
} }
}, },
"true_false": { "true_false": {
@@ -1291,8 +1352,9 @@
"option_prefix": "Opción", "option_prefix": "Opción",
"remove": "Quitar", "remove": "Quitar",
"add_option": "Agregar opción", "add_option": "Agregar opción",
"correct_badge": "(Correcto)", "correct_badge": "Correcto",
"not_set": "No establecido" "not_set": "No establecido",
"incorrect_badge": "Incorrecto"
} }
} }
}, },
@@ -1473,7 +1535,10 @@
"cancel": "Cancelar" "cancel": "Cancelar"
}, },
"content_locked_title": "Este contenido está bloqueado.", "content_locked_title": "Este contenido está bloqueado.",
"content_locked_description": "Su instructor aún no ha desbloqueado este contenido. Complete el contenido anterior o vuelva a consultarlo más tarde." "content_locked_description": "Su instructor aún no ha desbloqueado este contenido. Complete el contenido anterior o vuelva a consultarlo más tarde.",
"saving": "Guardando…",
"saved": "Guardado",
"draft_recovery": "Se encontró un borrador no guardado para esta lección. ¿Quieres restaurarlo?"
}, },
"news_feed": { "news_feed": {
"heading": "Noticias", "heading": "Noticias",
@@ -1582,6 +1647,7 @@
"unpublished": "Inédito", "unpublished": "Inédito",
"continue_course": "Continuar", "continue_course": "Continuar",
"learn_more": "Más información", "learn_more": "Más información",
"tags_overflow_aria": "{count} etiquetas más",
"list_view": { "list_view": {
"title": "Título", "title": "Título",
"description": "Descripción", "description": "Descripción",
@@ -2245,7 +2311,8 @@
"email_password_disabled_message": "El inicio de sesión con correo electrónico/contraseña está deshabilitado para esta organización.", "email_password_disabled_message": "El inicio de sesión con correo electrónico/contraseña está deshabilitado para esta organización.",
"google_auth_disabled": "La autenticación de Google está deshabilitada.", "google_auth_disabled": "La autenticación de Google está deshabilitada.",
"signup_disabled_error": "El registro está deshabilitado para esta organización. Por favor contacte a su administrador.", "signup_disabled_error": "El registro está deshabilitado para esta organización. Por favor contacte a su administrador.",
"or_continue_with": "O continuar con correo electrónico y contraseña" "or_continue_with": "O continuar con correo electrónico y contraseña",
"sso_enabled": "{provider} está habilitado para esta organización"
}, },
"tabs": { "tabs": {
"general": "generales", "general": "generales",
@@ -2471,7 +2538,8 @@
"edit_group": "Editar grupo", "edit_group": "Editar grupo",
"edit_tag": "Editar etiqueta", "edit_tag": "Editar etiqueta",
"save": "Guardar", "save": "Guardar",
"cancel": "Cancelar" "cancel": "Cancelar",
"actions_menu": "Acciones de etiqueta"
}, },
"common": { "common": {
"not_available": "N/A" "not_available": "N/A"
@@ -2480,9 +2548,13 @@
"invite": { "invite": {
"organization": { "organization": {
"page_title": "Únase a {orgName} en ClassroomIO", "page_title": "Únase a {orgName} en ClassroomIO",
"role_label": "Rol: {rol}", "invitation_student": "Has sido invitado a {orgName} como estudiante.",
"email_label": "Correo electrónico invitado: {email}", "invitation_teacher": "Has sido invitado a {orgName} como profesor.",
"join_button": "Unirse a la organización", "invitation_admin": "Has sido invitado a {orgName} como administrador.",
"invitation_fallback": "Has sido invitado a {orgName} como {role}.",
"email_line": "Correo electrónico: {email}",
"accept_button": "Aceptar invitación",
"go_to_dashboard": "Ir al panel",
"messages": { "messages": {
"email_mismatch": "Has iniciado sesión con un correo electrónico diferente al de esta invitación.", "email_mismatch": "Has iniciado sesión con un correo electrónico diferente al de esta invitación.",
"expired": "Este enlace de invitación ha caducado.", "expired": "Este enlace de invitación ha caducado.",
@@ -2520,6 +2592,17 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Create key" "save": "Create key"
} }
},
"usage": {
"title": "Uso y límites",
"description": "MCP está disponible en todos los planes, pero el uso se controla con créditos de automatización mensuales y límites clave.",
"credits_title": "Créditos restantes",
"credits_description": "Las acciones mutantes de MCP consumen créditos. Las acciones de lectura son gratuitas.",
"keys_title": "Claves activas",
"keys_description": "Utilice una clave MCP por integración para que el acceso se pueda rotar o revocar limpiamente.",
"rate_limits_title": "Límites de tarifas",
"rate_limits_description": "Solicitudes de lectura/escritura/publicación permitidas por minuto.",
"used_label": "Usado:"
} }
}, },
"keys": { "keys": {
@@ -2534,7 +2617,11 @@
"never_used": "nunca", "never_used": "nunca",
"scopes_label": "Alcances:", "scopes_label": "Alcances:",
"revoke_confirm": "¿Revocar esta clave de automatización?", "revoke_confirm": "¿Revocar esta clave de automatización?",
"rotate_confirm": "¿Girar esta clave de automatización?" "rotate_confirm": "¿Girar esta clave de automatización?",
"table_key": "clave",
"table_status": "Estado",
"table_created": "Creado",
"table_last_used": "último usado"
}, },
"clients": { "clients": {
"claude_code": "Código Claude", "claude_code": "Código Claude",
@@ -475,7 +475,54 @@
"name": "Nom", "name": "Nom",
"email": "Courriel", "email": "Courriel",
"date_joined": "Date d'adhésion", "date_joined": "Date d'adhésion",
"upgrade": "Améliorez votre forfait pour inviter plus d'étudiants" "upgrade": "Améliorez votre forfait pour inviter plus d'étudiants",
"import_users": "Importer des utilisateurs",
"search_placeholder": "Rechercher…",
"status": "Statut",
"status_active": "Actif",
"status_pending": "En attente",
"status_expired": "Expiré",
"status_revoked": "Révoqué",
"select_all": "Tout sélectionner",
"selected_count": "{count} sélectionnés",
"assign_courses": "Attribuer aux cours",
"invite": {
"row_actions_aria": "Actions ouvertes",
"resend": "Renvoyer l'invitation",
"revoke": "Révoquer l'invitation en attente",
"resend_snackbar_success": "E-mail d'invitation envoyé",
"resend_snackbar_email_failed": "L'invitation a été renouvelée mais l'e-mail n'a pas pu être envoyé",
"revoke_snackbar_success": "Invitation en attente révoquée"
},
"assign": {
"title": "Attribuer aux cours",
"description": "Donnez aux utilisateurs sélectionnés l'accès aux cours et éventuellement les avertir par e-mail.",
"select_courses": "Sélectionner des cours",
"select_courses_placeholder": "Choisissez des cours...",
"send_email": "Envoyer un e-mail de notification",
"submit": "Attribuer",
"cancel": "Annuler",
"snackbar_success": "{assigned} utilisateurs affectés aux cours ({alreadyEnrolled} déjà inscrits, {emailsSent} e-mails envoyés)",
"snackbar_no_courses": "Veuillez sélectionner au moins un cours",
"snackbar_failed": "Échec de l'attribution des cours"
},
"import": {
"title": "Importer des utilisateurs",
"description": "Ajoutez des étudiants à votre organisation en saisissant leurs adresses e-mail. Vous pouvez éventuellement les attribuer à des cours et envoyer des e-mails d'invitation.",
"emails_label": "Adresses e-mail",
"emails_placeholder": "Saisissez les e-mails séparés par des virgules, des nouvelles lignes ou des points-virgules\npar ex. [email protected], [email protected]",
"course_access": "Accès aux cours",
"no_courses": "Aucun devoir de cours",
"all_courses": "Tous les cours",
"select_courses": "Sélectionnez des cours spécifiques",
"select_courses_placeholder": "Choisissez des cours...",
"send_email": "Envoyer un e-mail d'invitation aux utilisateurs importés",
"submit": "Importer des utilisateurs",
"back": "Retour au public",
"snackbar_success": "Utilisateurs {importés} importés ({skipped} existait déjà, {emailsSent} e-mails envoyés)",
"snackbar_no_emails": "Veuillez saisir au moins une adresse e-mail",
"snackbar_failed": "Échec de l'importation des utilisateurs"
}
}, },
"community": { "community": {
"title": "Communauté", "title": "Communauté",
@@ -940,6 +987,8 @@
"complete": "Terminé", "complete": "Terminé",
"next": "Suivant", "next": "Suivant",
"prev": "Précédent", "prev": "Précédent",
"prev_shortcut": "Précédent (←)",
"next_shortcut": "Suivant (→)",
"disabled": "La fonctionnalité est désactivée", "disabled": "La fonctionnalité est désactivée",
"version_history": { "version_history": {
"title": "Historique des versions", "title": "Historique des versions",
@@ -1200,6 +1249,9 @@
}, },
"edit": { "edit": {
"accepted_answers_placeholder": "Réponses acceptées (séparées par des virgules)" "accepted_answers_placeholder": "Réponses acceptées (séparées par des virgules)"
},
"review": {
"your_answer_label": "Soumis :"
} }
}, },
"numeric": { "numeric": {
@@ -1217,6 +1269,11 @@
"tolerance_info": "Différence autorisée par rapport à la valeur correcte (par exemple, 0,5 accepte +/-0,5).", "tolerance_info": "Différence autorisée par rapport à la valeur correcte (par exemple, 0,5 accepte +/-0,5).",
"correct_value_placeholder": "Valeur correcte", "correct_value_placeholder": "Valeur correcte",
"tolerance_placeholder": "Tolérance" "tolerance_placeholder": "Tolérance"
},
"review": {
"your_answer_label": "Soumis :",
"in_range": "Dans les limites de tolérance",
"out_of_range": "Hors tolérance"
} }
}, },
"short_answer": { "short_answer": {
@@ -1228,6 +1285,10 @@
}, },
"edit": { "edit": {
"instructions_placeholder": "Instructions facultatives" "instructions_placeholder": "Instructions facultatives"
},
"review": {
"your_answer_label": "Soumis :",
"accepted_label": "Réponses acceptées"
} }
}, },
"true_false": { "true_false": {
@@ -1291,8 +1352,9 @@
"option_prefix": "Options", "option_prefix": "Options",
"remove": "Supprimer", "remove": "Supprimer",
"add_option": "Ajouter une option", "add_option": "Ajouter une option",
"correct_badge": "(C'est vrai)", "correct_badge": "C'est vrai",
"not_set": "Non défini" "not_set": "Non défini",
"incorrect_badge": "Incorrect"
} }
} }
}, },
@@ -1473,7 +1535,10 @@
"cancel": "Annuler" "cancel": "Annuler"
}, },
"content_locked_title": "Ce contenu est verrouillé", "content_locked_title": "Ce contenu est verrouillé",
"content_locked_description": "Votre instructeur n'a pas encore débloqué ce contenu. Complétez le contenu précédent ou revenez plus tard." "content_locked_description": "Votre instructeur n'a pas encore débloqué ce contenu. Complétez le contenu précédent ou revenez plus tard.",
"saving": "Sauvegarde…",
"saved": "Enregistré",
"draft_recovery": "Un brouillon non enregistré a été trouvé pour cette leçon. Souhaitez-vous le restaurer ?"
}, },
"news_feed": { "news_feed": {
"heading": "Fil d'actualité", "heading": "Fil d'actualité",
@@ -1582,6 +1647,7 @@
"unpublished": "Inédit", "unpublished": "Inédit",
"continue_course": "Continuer", "continue_course": "Continuer",
"learn_more": "En savoir plus", "learn_more": "En savoir plus",
"tags_overflow_aria": "{count} autres tags",
"list_view": { "list_view": {
"title": "Titre", "title": "Titre",
"description": "Descriptif", "description": "Descriptif",
@@ -2245,7 +2311,8 @@
"email_password_disabled_message": "La connexion par e-mail/mot de passe est désactivée pour cette organisation.", "email_password_disabled_message": "La connexion par e-mail/mot de passe est désactivée pour cette organisation.",
"google_auth_disabled": "L'authentification Google est désactivée.", "google_auth_disabled": "L'authentification Google est désactivée.",
"signup_disabled_error": "L'inscription est désactivée pour cette organisation. Veuillez contacter votre administrateur.", "signup_disabled_error": "L'inscription est désactivée pour cette organisation. Veuillez contacter votre administrateur.",
"or_continue_with": "Ou continuez avec email et mot de passe" "or_continue_with": "Ou continuez avec email et mot de passe",
"sso_enabled": "{provider} est activé pour cette organisation"
}, },
"tabs": { "tabs": {
"general": "Général", "general": "Général",
@@ -2471,7 +2538,8 @@
"edit_group": "Modifier le groupe", "edit_group": "Modifier le groupe",
"edit_tag": "Modifier la balise", "edit_tag": "Modifier la balise",
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler" "cancel": "Annuler",
"actions_menu": "Actions de balise"
}, },
"common": { "common": {
"not_available": "N/D" "not_available": "N/D"
@@ -2480,9 +2548,13 @@
"invite": { "invite": {
"organization": { "organization": {
"page_title": "Rejoignez {orgName} sur ClassroomIO", "page_title": "Rejoignez {orgName} sur ClassroomIO",
"role_label": "Rôle : {rôle}", "invitation_student": "Vous avez été invité(e) dans {orgName} en tant qu'étudiant.",
"email_label": "E-mail invité : {email}", "invitation_teacher": "Vous avez été invité(e) dans {orgName} en tant qu'enseignant.",
"join_button": "Rejoindre l'organisation", "invitation_admin": "Vous avez été invité(e) dans {orgName} en tant qu'administrateur.",
"invitation_fallback": "Vous avez été invité(e) dans {orgName} en tant que {role}.",
"email_line": "E-mail : {email}",
"accept_button": "Accepter l'invitation",
"go_to_dashboard": "Aller au tableau de bord",
"messages": { "messages": {
"email_mismatch": "Vous êtes connecté avec un e-mail différent de celui de cette invitation.", "email_mismatch": "Vous êtes connecté avec un e-mail différent de celui de cette invitation.",
"expired": "Ce lien d'invitation a expiré.", "expired": "Ce lien d'invitation a expiré.",
@@ -2520,6 +2592,17 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Create key" "save": "Create key"
} }
},
"usage": {
"title": "Utilisation et limites",
"description": "MCP est disponible sur chaque forfait, mais l'utilisation est contrôlée par des crédits d'automatisation mensuels et des limites clés.",
"credits_title": "Crédits restants",
"credits_description": "Les actions MCP en mutation consomment des crédits. Les actions de lecture sont gratuites.",
"keys_title": "Clés actives",
"keys_description": "Utilisez une clé MCP par intégration afin que l'accès puisse être alterné ou révoqué proprement.",
"rate_limits_title": "Limites de taux",
"rate_limits_description": "Requêtes de lecture/écriture/publication autorisées par minute.",
"used_label": "Utilisé :"
} }
}, },
"keys": { "keys": {
@@ -2534,7 +2617,11 @@
"never_used": "Jamais", "never_used": "Jamais",
"scopes_label": "Portées :", "scopes_label": "Portées :",
"revoke_confirm": "Révoquer cette clé d'automatisation ?", "revoke_confirm": "Révoquer cette clé d'automatisation ?",
"rotate_confirm": "Faire pivoter cette clé d'automatisation ?" "rotate_confirm": "Faire pivoter cette clé d'automatisation ?",
"table_key": "Clé",
"table_status": "Statut",
"table_created": "Créé",
"table_last_used": "Dernière utilisation"
}, },
"clients": { "clients": {
"claude_code": "Claude Code", "claude_code": "Claude Code",
@@ -475,7 +475,54 @@
"name": "नाम", "name": "नाम",
"email": "ईमेल", "email": "ईमेल",
"date_joined": "शामिल होने की तिथि", "date_joined": "शामिल होने की तिथि",
"upgrade": "अधिक छात्रों को आमंत्रित करने के लिए अपनी योजना को अपग्रेड करें" "upgrade": "अधिक छात्रों को आमंत्रित करने के लिए अपनी योजना को अपग्रेड करें",
"import_users": "उपयोगकर्ता आयात करें",
"search_placeholder": "खोजें…",
"status": "स्थिति",
"status_active": "सक्रिय",
"status_pending": "लंबित",
"status_expired": "समाप्त हो गया",
"status_revoked": "निरस्त किया गया",
"select_all": "सभी का चयन करें",
"selected_count": "{गिनती} चयनित",
"assign_courses": "पाठ्यक्रमों को असाइन करें",
"invite": {
"row_actions_aria": "खुली कार्रवाइयां",
"resend": "आमंत्रण पुनः भेजें",
"revoke": "लंबित आमंत्रण निरस्त करें",
"resend_snackbar_success": "आमंत्रण ईमेल भेजा गया",
"resend_snackbar_email_failed": "आमंत्रण नवीनीकृत किया गया लेकिन ईमेल नहीं भेजा जा सका",
"revoke_snackbar_success": "लंबित आमंत्रण निरस्त कर दिया गया"
},
"assign": {
"title": "पाठ्यक्रमों को असाइन करें",
"description": "चयनित उपयोगकर्ताओं को पाठ्यक्रमों तक पहुंच प्रदान करें और वैकल्पिक रूप से उन्हें ईमेल द्वारा सूचित करें।",
"select_courses": "पाठ्यक्रम चुनें",
"select_courses_placeholder": "पाठ्यक्रम चुनें...",
"send_email": "अधिसूचना ईमेल भेजें",
"submit": "नियुक्‍त करें",
"cancel": "रद्द करें",
"snackbar_success": "पाठ्यक्रमों के लिए {assigned} उपयोगकर्ताओं को असाइन किया गया ({alreadyEnrolled} पहले से ही नामांकित, {emailsSent} ईमेल भेजे गए)",
"snackbar_no_courses": "कृपया कम से कम एक पाठ्यक्रम चुनें",
"snackbar_failed": "पाठ्यक्रम आवंटित करने में विफल"
},
"import": {
"title": "उपयोगकर्ता आयात करें",
"description": "छात्रों के ईमेल पते दर्ज करके उन्हें अपने संगठन में जोड़ें। वैकल्पिक रूप से उन्हें पाठ्यक्रमों में नियुक्त करें और आमंत्रण ईमेल भेजें।",
"emails_label": "ईमेल पते",
"emails_placeholder": "ईमेल को अल्पविराम, न्यूलाइन या अर्धविराम से अलग करके दर्ज करें\nजैसे [email protected], [email protected]",
"course_access": "पाठ्यक्रम प्रवेश",
"no_courses": "कोई पाठ्यक्रम असाइनमेंट नहीं",
"all_courses": "सभी पाठ्यक्रम",
"select_courses": "विशिष्ट पाठ्यक्रम चुनें",
"select_courses_placeholder": "पाठ्यक्रम चुनें...",
"send_email": "आयातित उपयोगकर्ताओं को आमंत्रण ईमेल भेजें",
"submit": "उपयोगकर्ता आयात करें",
"back": "दर्शकों के पास वापस जाएँ",
"snackbar_success": "आयातित {आयातित} उपयोगकर्ता ({स्किप्ड} पहले से मौजूद थे, {ईमेल्ससेंट} ईमेल भेजे गए)",
"snackbar_no_emails": "कृपया कम से कम एक ईमेल पता दर्ज करें",
"snackbar_failed": "उपयोगकर्ताओं को आयात करने में विफल"
}
}, },
"community": { "community": {
"title": "समुदाय", "title": "समुदाय",
@@ -940,6 +987,8 @@
"complete": "पूर्ण", "complete": "पूर्ण",
"next": "अगला", "next": "अगला",
"prev": "पिछला", "prev": "पिछला",
"prev_shortcut": "पिछला (←)",
"next_shortcut": "अगला (→)",
"disabled": "सुविधा अक्षम है", "disabled": "सुविधा अक्षम है",
"version_history": { "version_history": {
"title": "संस्करण इतिहास", "title": "संस्करण इतिहास",
@@ -1200,6 +1249,9 @@
}, },
"edit": { "edit": {
"accepted_answers_placeholder": "स्वीकृत उत्तर (अल्पविराम से अलग)" "accepted_answers_placeholder": "स्वीकृत उत्तर (अल्पविराम से अलग)"
},
"review": {
"your_answer_label": "प्रस्तुत:"
} }
}, },
"numeric": { "numeric": {
@@ -1217,6 +1269,11 @@
"tolerance_info": "सही मान से स्वीकार्य अंतर (उदाहरण के लिए, 0.5 +/-0.5 स्वीकार करता है)।", "tolerance_info": "सही मान से स्वीकार्य अंतर (उदाहरण के लिए, 0.5 +/-0.5 स्वीकार करता है)।",
"correct_value_placeholder": "सही मूल्य", "correct_value_placeholder": "सही मूल्य",
"tolerance_placeholder": "सहनशीलता" "tolerance_placeholder": "सहनशीलता"
},
"review": {
"your_answer_label": "प्रस्तुत:",
"in_range": "सहनशीलता के भीतर",
"out_of_range": "बाहर की सहनशीलता"
} }
}, },
"short_answer": { "short_answer": {
@@ -1228,6 +1285,10 @@
}, },
"edit": { "edit": {
"instructions_placeholder": "वैकल्पिक निर्देश" "instructions_placeholder": "वैकल्पिक निर्देश"
},
"review": {
"your_answer_label": "प्रस्तुत:",
"accepted_label": "स्वीकृत उत्तर"
} }
}, },
"true_false": { "true_false": {
@@ -1291,8 +1352,9 @@
"option_prefix": "विकल्प", "option_prefix": "विकल्प",
"remove": "हटाओ", "remove": "हटाओ",
"add_option": "विकल्प जोड़ें", "add_option": "विकल्प जोड़ें",
"correct_badge": "(सही)", "correct_badge": "सही",
"not_set": "सेट नहीं" "not_set": "सेट नहीं",
"incorrect_badge": "गलत"
} }
} }
}, },
@@ -1473,7 +1535,10 @@
"cancel": "रद्द करें" "cancel": "रद्द करें"
}, },
"content_locked_title": "यह सामग्री लॉक है", "content_locked_title": "यह सामग्री लॉक है",
"content_locked_description": "आपके प्रशिक्षक ने अभी तक इस सामग्री को अनलॉक नहीं किया है। पिछली सामग्री पूरी करें या बाद में दोबारा जाँचें।" "content_locked_description": "आपके प्रशिक्षक ने अभी तक इस सामग्री को अनलॉक नहीं किया है। पिछली सामग्री पूरी करें या बाद में दोबारा जाँचें।",
"saving": "सहेजा जा रहा है...",
"saved": "सहेजा गया",
"draft_recovery": "इस पाठ के लिए एक सहेजा न गया ड्राफ्ट मिला। क्या आप इसे पुनर्स्थापित करना चाहेंगे?"
}, },
"news_feed": { "news_feed": {
"heading": "समाचार फ़ीड", "heading": "समाचार फ़ीड",
@@ -1582,6 +1647,7 @@
"unpublished": "अप्रकाशित", "unpublished": "अप्रकाशित",
"continue_course": "जारी रखें", "continue_course": "जारी रखें",
"learn_more": "और जानें", "learn_more": "और जानें",
"tags_overflow_aria": "{count} और टैग",
"list_view": { "list_view": {
"title": "शीर्षक", "title": "शीर्षक",
"description": "विवरण", "description": "विवरण",
@@ -2245,7 +2311,8 @@
"email_password_disabled_message": "इस संगठन के लिए ईमेल/पासवर्ड लॉगिन अक्षम है।", "email_password_disabled_message": "इस संगठन के लिए ईमेल/पासवर्ड लॉगिन अक्षम है।",
"google_auth_disabled": "Google प्रमाणीकरण अक्षम है.", "google_auth_disabled": "Google प्रमाणीकरण अक्षम है.",
"signup_disabled_error": "इस संगठन के लिए साइनअप अक्षम है. कृपया अपने व्यवस्थापक से संपर्क करें.", "signup_disabled_error": "इस संगठन के लिए साइनअप अक्षम है. कृपया अपने व्यवस्थापक से संपर्क करें.",
"or_continue_with": "या ईमेल और पासवर्ड के साथ जारी रखें" "or_continue_with": "या ईमेल और पासवर्ड के साथ जारी रखें",
"sso_enabled": "इस संगठन के लिए {प्रदाता} सक्षम है"
}, },
"tabs": { "tabs": {
"general": "सामान्य", "general": "सामान्य",
@@ -2471,7 +2538,8 @@
"edit_group": "समूह संपादित करें", "edit_group": "समूह संपादित करें",
"edit_tag": "टैग संपादित करें", "edit_tag": "टैग संपादित करें",
"save": "सहेजें", "save": "सहेजें",
"cancel": "रद्द करें" "cancel": "रद्द करें",
"actions_menu": "क्रियाएँ टैग करें"
}, },
"common": { "common": {
"not_available": "एन/ए" "not_available": "एन/ए"
@@ -2480,9 +2548,13 @@
"invite": { "invite": {
"organization": { "organization": {
"page_title": "ClassroomIO पर {orgName} से जुड़ें", "page_title": "ClassroomIO पर {orgName} से जुड़ें",
"role_label": "भूमिका: {भूमिका}", "invitation_student": "आपको {orgName} में छात्र के रूप में आमंत्रित किया गया है।",
"email_label": "आमंत्रित ईमेल: {ईमेल}", "invitation_teacher": "आपको {orgName} में शिक्षक के रूप में आमंत्रित किया गया है।",
"join_button": "संगठन से जुड़ें", "invitation_admin": "आपको {orgName} में व्यवस्थापक के रूप में आमंत्रित किया गया है।",
"invitation_fallback": "आपको {orgName} में {role} के रूप में आमंत्रित किया गया है।",
"email_line": "ईमेल: {email}",
"accept_button": "निमंत्रण स्वीकार करें",
"go_to_dashboard": "डैशबोर्ड पर जाएं",
"messages": { "messages": {
"email_mismatch": "आप इस आमंत्रण से भिन्न ईमेल से लॉग इन हैं।", "email_mismatch": "आप इस आमंत्रण से भिन्न ईमेल से लॉग इन हैं।",
"expired": "यह आमंत्रण लिंक समाप्त हो गया है.", "expired": "यह आमंत्रण लिंक समाप्त हो गया है.",
@@ -2520,6 +2592,17 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Create key" "save": "Create key"
} }
},
"usage": {
"title": "उपयोग और सीमाएँ",
"description": "एमसीपी हर योजना पर उपलब्ध है, लेकिन उपयोग को मासिक स्वचालन क्रेडिट और प्रमुख सीमाओं के साथ नियंत्रित किया जाता है।",
"credits_title": "क्रेडिट शेष",
"credits_description": "MCP क्रियाओं को बदलने से क्रेडिट की खपत होती है। पढ़ें क्रियाएँ निःशुल्क हैं।",
"keys_title": "सक्रिय कुंजियाँ",
"keys_description": "प्रति एकीकरण एक एमसीपी कुंजी का उपयोग करें ताकि पहुंच को घुमाया जा सके या सफाई से रद्द किया जा सके।",
"rate_limits_title": "दर सीमा",
"rate_limits_description": "प्रति मिनट पढ़ने/लिखने/प्रकाशित करने के अनुरोधों की अनुमति है।",
"used_label": "प्रयुक्त:"
} }
}, },
"keys": { "keys": {
@@ -2534,7 +2617,11 @@
"never_used": "कभी नहीं", "never_used": "कभी नहीं",
"scopes_label": "दायरा:", "scopes_label": "दायरा:",
"revoke_confirm": "इस स्वचालन कुंजी को निरस्त करें?", "revoke_confirm": "इस स्वचालन कुंजी को निरस्त करें?",
"rotate_confirm": "इस स्वचालन कुंजी को घुमाएँ?" "rotate_confirm": "इस स्वचालन कुंजी को घुमाएँ?",
"table_key": "कुंजी",
"table_status": "स्थिति",
"table_created": "बनाया गया",
"table_last_used": "अंतिम बार उपयोग किया गया"
}, },
"clients": { "clients": {
"claude_code": "क्लाउड कोड", "claude_code": "क्लाउड कोड",
@@ -475,7 +475,54 @@
"name": "Imię", "name": "Imię",
"email": "E-mail", "email": "E-mail",
"date_joined": "Data dołączenia", "date_joined": "Data dołączenia",
"upgrade": "Uaktualnij swój plan, aby zaprosić więcej uczniów" "upgrade": "Uaktualnij swój plan, aby zaprosić więcej uczniów",
"import_users": "Importuj użytkowników",
"search_placeholder": "Szukaj…",
"status": "Stan",
"status_active": "Aktywny",
"status_pending": "Oczekujące",
"status_expired": "Wygasło",
"status_revoked": "Unieważnione",
"select_all": "Zaznacz wszystko",
"selected_count": "Wybrano {count}",
"assign_courses": "Przypisz do kursów",
"invite": {
"row_actions_aria": "Otwórz działania",
"resend": "Wyślij zaproszenie ponownie",
"revoke": "Odwołaj oczekujące zaproszenie",
"resend_snackbar_success": "E-mail z zaproszeniem wysłany",
"resend_snackbar_email_failed": "Zaproszenie zostało odnowione, ale e-mail nie mógł zostać wysłany",
"revoke_snackbar_success": "Oczekujące zaproszenie zostało odwołane"
},
"assign": {
"title": "Przypisz do kursów",
"description": "Nadaj wybranym użytkownikom dostęp do kursów i opcjonalnie powiadom ich e-mailem.",
"select_courses": "Wybierz kursy",
"select_courses_placeholder": "Wybierz kursy...",
"send_email": "Wyślij e-mail z powiadomieniem",
"submit": "Przypisz",
"cancel": "Anuluj",
"snackbar_success": "Przypisano {przydzielonych} użytkowników do kursów ({alreadyEnrolled} jest już zapisanych, wysłano e-maile: {emailsSent})",
"snackbar_no_courses": "Proszę wybrać przynajmniej jeden kurs",
"snackbar_failed": "Nie udało się przypisać kursów"
},
"import": {
"title": "Importuj użytkowników",
"description": "Dodaj uczniów do swojej organizacji, wprowadzając ich adresy e-mail. Opcjonalnie przypisz je do kursów i wyślij e-maile z zaproszeniami.",
"emails_label": "Adresy e-mail",
"emails_placeholder": "Wpisz adresy e-mail oddzielone przecinkami, znakami nowej linii lub średnikami\nnp. [email protected], [email protected]",
"course_access": "Dostęp do kursu",
"no_courses": "Brak przypisanego kursu",
"all_courses": "Wszystkie kursy",
"select_courses": "Wybierz konkretne kursy",
"select_courses_placeholder": "Wybierz kursy...",
"send_email": "Wyślij wiadomość e-mail z zaproszeniem do zaimportowanych użytkowników",
"submit": "Importuj użytkowników",
"back": "Powrót do Publiczności",
"snackbar_success": "Zaimportowani użytkownicy: {imported} ({skipped} już istniało, wysłano e-maile: {emailsSent})",
"snackbar_no_emails": "Proszę podać przynajmniej jeden adres e-mail",
"snackbar_failed": "Nie udało się zaimportować użytkowników"
}
}, },
"community": { "community": {
"title": "Społeczność", "title": "Społeczność",
@@ -940,6 +987,8 @@
"complete": "Kompletny", "complete": "Kompletny",
"next": "Następny", "next": "Następny",
"prev": "Poprzednia", "prev": "Poprzednia",
"prev_shortcut": "Poprzedni (←)",
"next_shortcut": "Następny (→)",
"disabled": "Funkcja jest wyłączona", "disabled": "Funkcja jest wyłączona",
"version_history": { "version_history": {
"title": "Historia wersji", "title": "Historia wersji",
@@ -1200,6 +1249,9 @@
}, },
"edit": { "edit": {
"accepted_answers_placeholder": "Zaakceptowane odpowiedzi (oddzielone przecinkami)" "accepted_answers_placeholder": "Zaakceptowane odpowiedzi (oddzielone przecinkami)"
},
"review": {
"your_answer_label": "Przesłane:"
} }
}, },
"numeric": { "numeric": {
@@ -1217,6 +1269,11 @@
"tolerance_info": "Dopuszczalna różnica od prawidłowej wartości (na przykład 0,5 akceptuje +/-0,5).", "tolerance_info": "Dopuszczalna różnica od prawidłowej wartości (na przykład 0,5 akceptuje +/-0,5).",
"correct_value_placeholder": "Prawidłowa wartość", "correct_value_placeholder": "Prawidłowa wartość",
"tolerance_placeholder": "Tolerancja" "tolerance_placeholder": "Tolerancja"
},
"review": {
"your_answer_label": "Przesłane:",
"in_range": "W ramach tolerancji",
"out_of_range": "Tolerancja zewnętrzna"
} }
}, },
"short_answer": { "short_answer": {
@@ -1228,6 +1285,10 @@
}, },
"edit": { "edit": {
"instructions_placeholder": "Instrukcje opcjonalne" "instructions_placeholder": "Instrukcje opcjonalne"
},
"review": {
"your_answer_label": "Przesłane:",
"accepted_label": "Zaakceptowane odpowiedzi"
} }
}, },
"true_false": { "true_false": {
@@ -1291,8 +1352,9 @@
"option_prefix": "Opcja", "option_prefix": "Opcja",
"remove": "Usuń", "remove": "Usuń",
"add_option": "Dodaj opcję", "add_option": "Dodaj opcję",
"correct_badge": "(Poprawnie)", "correct_badge": "Poprawnie",
"not_set": "Nie ustawiono" "not_set": "Nie ustawiono",
"incorrect_badge": "Niepoprawne"
} }
} }
}, },
@@ -1473,7 +1535,10 @@
"cancel": "Anuluj" "cancel": "Anuluj"
}, },
"content_locked_title": "Ta zawartość jest zablokowana", "content_locked_title": "Ta zawartość jest zablokowana",
"content_locked_description": "Twój instruktor nie odblokował jeszcze tej zawartości. Uzupełnij poprzednią zawartość lub sprawdź później." "content_locked_description": "Twój instruktor nie odblokował jeszcze tej zawartości. Uzupełnij poprzednią zawartość lub sprawdź później.",
"saving": "Zapisuję…",
"saved": "Zapisano",
"draft_recovery": "Znaleziono niezapisaną wersję roboczą tej lekcji. Czy chcesz go przywrócić?"
}, },
"news_feed": { "news_feed": {
"heading": "Kanał wiadomości", "heading": "Kanał wiadomości",
@@ -1582,6 +1647,7 @@
"unpublished": "Niepublikowane", "unpublished": "Niepublikowane",
"continue_course": "Kontynuuj", "continue_course": "Kontynuuj",
"learn_more": "Dowiedz się więcej", "learn_more": "Dowiedz się więcej",
"tags_overflow_aria": "Jeszcze {count} tagów",
"list_view": { "list_view": {
"title": "Tytuł", "title": "Tytuł",
"description": "Opis", "description": "Opis",
@@ -2245,7 +2311,8 @@
"email_password_disabled_message": "Logowanie za pomocą adresu e-mail/hasła jest wyłączone w tej organizacji.", "email_password_disabled_message": "Logowanie za pomocą adresu e-mail/hasła jest wyłączone w tej organizacji.",
"google_auth_disabled": "Uwierzytelnianie Google jest wyłączone.", "google_auth_disabled": "Uwierzytelnianie Google jest wyłączone.",
"signup_disabled_error": "Rejestracja w tej organizacji jest wyłączona. Skontaktuj się ze swoim administratorem.", "signup_disabled_error": "Rejestracja w tej organizacji jest wyłączona. Skontaktuj się ze swoim administratorem.",
"or_continue_with": "Możesz też kontynuować, podając adres e-mail i hasło" "or_continue_with": "Możesz też kontynuować, podając adres e-mail i hasło",
"sso_enabled": "Usługa {provider} jest włączona dla tej organizacji"
}, },
"tabs": { "tabs": {
"general": "Generał", "general": "Generał",
@@ -2471,7 +2538,8 @@
"edit_group": "Edytuj grupę", "edit_group": "Edytuj grupę",
"edit_tag": "Edytuj znacznik", "edit_tag": "Edytuj znacznik",
"save": "Zapisz", "save": "Zapisz",
"cancel": "Anuluj" "cancel": "Anuluj",
"actions_menu": "Oznacz działania"
}, },
"common": { "common": {
"not_available": "Nie dotyczy" "not_available": "Nie dotyczy"
@@ -2480,9 +2548,13 @@
"invite": { "invite": {
"organization": { "organization": {
"page_title": "Dołącz do organizacji {orgName} w ClassroomIO", "page_title": "Dołącz do organizacji {orgName} w ClassroomIO",
"role_label": "Rola: {rola}", "invitation_student": "Zostałeś zaproszony do {orgName} jako uczeń.",
"email_label": "Zaproszony e-mail: {email}", "invitation_teacher": "Zostałeś zaproszony do {orgName} jako nauczyciel.",
"join_button": "Dołącz do organizacji", "invitation_admin": "Zostałeś zaproszony do {orgName} jako administrator.",
"invitation_fallback": "Zostałeś zaproszony do {orgName} jako {role}.",
"email_line": "E-mail: {email}",
"accept_button": "Zaakceptuj zaproszenie",
"go_to_dashboard": "Przejdź do panelu",
"messages": { "messages": {
"email_mismatch": "Jesteś zalogowany przy użyciu innego adresu e-mail niż to zaproszenie.", "email_mismatch": "Jesteś zalogowany przy użyciu innego adresu e-mail niż to zaproszenie.",
"expired": "Ten link z zaproszeniem wygasł.", "expired": "Ten link z zaproszeniem wygasł.",
@@ -2520,6 +2592,17 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Create key" "save": "Create key"
} }
},
"usage": {
"title": "Zastosowanie i ograniczenia",
"description": "MCP jest dostępne w każdym planie, ale wykorzystanie jest kontrolowane za pomocą miesięcznych kredytów automatyzacji i limitów kluczowych.",
"credits_title": "Pozostały kredyty",
"credits_description": "Mutowanie działań MCP zużywa kredyty. Czytanie akcji jest bezpłatne.",
"keys_title": "Aktywne klucze",
"keys_description": "Użyj jednego klucza MCP na każdą integrację, aby umożliwić łatwą rotację lub cofnięcie dostępu.",
"rate_limits_title": "Limity stawek",
"rate_limits_description": "Żądania odczytu/zapisu/publikowania dozwolone na minutę.",
"used_label": "Używane:"
} }
}, },
"keys": { "keys": {
@@ -2534,7 +2617,11 @@
"never_used": "Nigdy", "never_used": "Nigdy",
"scopes_label": "Zakresy:", "scopes_label": "Zakresy:",
"revoke_confirm": "Unieważnić ten klucz automatyzacji?", "revoke_confirm": "Unieważnić ten klucz automatyzacji?",
"rotate_confirm": "Obrócić ten klucz automatyzacji?" "rotate_confirm": "Obrócić ten klucz automatyzacji?",
"table_key": "Klucz",
"table_status": "Stan",
"table_created": "Utworzono",
"table_last_used": "Ostatnio używany"
}, },
"clients": { "clients": {
"claude_code": "Claude'a Koda", "claude_code": "Claude'a Koda",
@@ -475,7 +475,54 @@
"name": "Nome", "name": "Nome",
"email": "E-mail", "email": "E-mail",
"date_joined": "Data de adesão", "date_joined": "Data de adesão",
"upgrade": "Atualize seu plano para convidar mais alunos" "upgrade": "Atualize seu plano para convidar mais alunos",
"import_users": "Importar usuários",
"search_placeholder": "Pesquisar…",
"status": "Estado",
"status_active": "Ativo",
"status_pending": "Pendente",
"status_expired": "Expirado",
"status_revoked": "Revogado",
"select_all": "Selecionar tudo",
"selected_count": "{contagem} selecionados",
"assign_courses": "Atribuir a cursos",
"invite": {
"row_actions_aria": "Ações abertas",
"resend": "Reenviar convite",
"revoke": "Revogar convite pendente",
"resend_snackbar_success": "E-mail de convite enviado",
"resend_snackbar_email_failed": "O convite foi renovado, mas não foi possível enviar o e-mail",
"revoke_snackbar_success": "Convite pendente revogado"
},
"assign": {
"title": "Atribuir a cursos",
"description": "Conceda aos usuários selecionados acesso aos cursos e, opcionalmente, notifique-os por e-mail.",
"select_courses": "Selecione os cursos",
"select_courses_placeholder": "Escolha cursos...",
"send_email": "Enviar e-mail de notificação",
"submit": "Atribuir",
"cancel": "Cancelar",
"snackbar_success": "{assigned} usuários atribuídos aos cursos ({alreadyEnrolled} já inscrito, {emailsSent} e-mails enviados)",
"snackbar_no_courses": "Selecione pelo menos um curso",
"snackbar_failed": "Falha ao atribuir cursos"
},
"import": {
"title": "Importar usuários",
"description": "Adicione alunos à sua organização inserindo seus endereços de e-mail. Opcionalmente, atribua-os a cursos e envie e-mails de convite.",
"emails_label": "Endereços de e-mail",
"emails_placeholder": "Insira e-mails separados por vírgulas, novas linhas ou ponto e vírgula\npor exemplo [email protected], [email protected]",
"course_access": "Acesso ao curso",
"no_courses": "Nenhuma tarefa de curso",
"all_courses": "Todos os cursos",
"select_courses": "Selecione cursos específicos",
"select_courses_placeholder": "Escolha cursos...",
"send_email": "Enviar e-mail de convite para usuários importados",
"submit": "Importar usuários",
"back": "Voltar ao público",
"snackbar_success": "Usuários {importados} importados ({skipped} já existia, {emailsSent} emails enviados)",
"snackbar_no_emails": "Insira pelo menos um endereço de e-mail",
"snackbar_failed": "Falha ao importar usuários"
}
}, },
"community": { "community": {
"title": "Comunidade", "title": "Comunidade",
@@ -940,6 +987,8 @@
"complete": "Completo", "complete": "Completo",
"next": "Próximo", "next": "Próximo",
"prev": "Anterior", "prev": "Anterior",
"prev_shortcut": "Anterior (←)",
"next_shortcut": "Próximo (→)",
"disabled": "O recurso está desativado", "disabled": "O recurso está desativado",
"version_history": { "version_history": {
"title": "Histórico de versões", "title": "Histórico de versões",
@@ -1200,6 +1249,9 @@
}, },
"edit": { "edit": {
"accepted_answers_placeholder": "Respostas aceitas (separadas por vírgula)" "accepted_answers_placeholder": "Respostas aceitas (separadas por vírgula)"
},
"review": {
"your_answer_label": "Enviado:"
} }
}, },
"numeric": { "numeric": {
@@ -1217,6 +1269,11 @@
"tolerance_info": "Diferença permitida do valor correto (por exemplo, 0,5 aceita +/-0,5).", "tolerance_info": "Diferença permitida do valor correto (por exemplo, 0,5 aceita +/-0,5).",
"correct_value_placeholder": "Valor correto", "correct_value_placeholder": "Valor correto",
"tolerance_placeholder": "Tolerância" "tolerance_placeholder": "Tolerância"
},
"review": {
"your_answer_label": "Enviado:",
"in_range": "Dentro da tolerância",
"out_of_range": "Tolerância externa"
} }
}, },
"short_answer": { "short_answer": {
@@ -1228,6 +1285,10 @@
}, },
"edit": { "edit": {
"instructions_placeholder": "Instruções opcionais" "instructions_placeholder": "Instruções opcionais"
},
"review": {
"your_answer_label": "Enviado:",
"accepted_label": "Respostas aceitas"
} }
}, },
"true_false": { "true_false": {
@@ -1291,8 +1352,9 @@
"option_prefix": "Opção", "option_prefix": "Opção",
"remove": "Remover", "remove": "Remover",
"add_option": "Adicionar opção", "add_option": "Adicionar opção",
"correct_badge": "(Correto)", "correct_badge": "Correto",
"not_set": "Não definido" "not_set": "Não definido",
"incorrect_badge": "Incorreto"
} }
} }
}, },
@@ -1473,7 +1535,10 @@
"cancel": "Cancelar" "cancel": "Cancelar"
}, },
"content_locked_title": "Este conteúdo está bloqueado", "content_locked_title": "Este conteúdo está bloqueado",
"content_locked_description": "Seu instrutor ainda não desbloqueou este conteúdo. Conclua o conteúdo anterior ou volte mais tarde." "content_locked_description": "Seu instrutor ainda não desbloqueou este conteúdo. Conclua o conteúdo anterior ou volte mais tarde.",
"saving": "Salvando…",
"saved": "Salvo",
"draft_recovery": "Um rascunho não salvo foi encontrado para esta lição. Você gostaria de restaurá-lo?"
}, },
"news_feed": { "news_feed": {
"heading": "Feed de notícias", "heading": "Feed de notícias",
@@ -1582,6 +1647,7 @@
"unpublished": "Não publicado", "unpublished": "Não publicado",
"continue_course": "Continuar", "continue_course": "Continuar",
"learn_more": "Saiba mais", "learn_more": "Saiba mais",
"tags_overflow_aria": "Mais {count} tags",
"list_view": { "list_view": {
"title": "Título", "title": "Título",
"description": "Descrição", "description": "Descrição",
@@ -2245,7 +2311,8 @@
"email_password_disabled_message": "O login por e-mail/senha está desativado para esta organização.", "email_password_disabled_message": "O login por e-mail/senha está desativado para esta organização.",
"google_auth_disabled": "A autenticação do Google está desativada.", "google_auth_disabled": "A autenticação do Google está desativada.",
"signup_disabled_error": "A inscrição está desativada para esta organização. Entre em contato com seu administrador.", "signup_disabled_error": "A inscrição está desativada para esta organização. Entre em contato com seu administrador.",
"or_continue_with": "Ou continue com e-mail e senha" "or_continue_with": "Ou continue com e-mail e senha",
"sso_enabled": "{provider} está ativado para esta organização"
}, },
"tabs": { "tabs": {
"general": "Geral", "general": "Geral",
@@ -2471,7 +2538,8 @@
"edit_group": "Editar grupo", "edit_group": "Editar grupo",
"edit_tag": "Editar etiqueta", "edit_tag": "Editar etiqueta",
"save": "Salvar", "save": "Salvar",
"cancel": "Cancelar" "cancel": "Cancelar",
"actions_menu": "Ações de tags"
}, },
"common": { "common": {
"not_available": "N/A" "not_available": "N/A"
@@ -2480,9 +2548,13 @@
"invite": { "invite": {
"organization": { "organization": {
"page_title": "Junte-se a {orgName} no ClassroomIO", "page_title": "Junte-se a {orgName} no ClassroomIO",
"role_label": "Função: {função}", "invitation_student": "Você foi convidado para {orgName} como aluno.",
"email_label": "E-mail convidado: {email}", "invitation_teacher": "Você foi convidado para {orgName} como professor.",
"join_button": "Junte-se à organização", "invitation_admin": "Você foi convidado para {orgName} como administrador.",
"invitation_fallback": "Você foi convidado para {orgName} como {role}.",
"email_line": "E-mail: {email}",
"accept_button": "Aceitar convite",
"go_to_dashboard": "Ir para o painel",
"messages": { "messages": {
"email_mismatch": "Você está conectado com um e-mail diferente deste convite.", "email_mismatch": "Você está conectado com um e-mail diferente deste convite.",
"expired": "Este link de convite expirou.", "expired": "Este link de convite expirou.",
@@ -2520,6 +2592,17 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Create key" "save": "Create key"
} }
},
"usage": {
"title": "Uso e limites",
"description": "O MCP está disponível em todos os planos, mas o uso é controlado com créditos mensais de automação e limites principais.",
"credits_title": "Créditos restantes",
"credits_description": "As ações mutantes do MCP consomem créditos. As ações de leitura são gratuitas.",
"keys_title": "Chaves ativas",
"keys_description": "Use uma chave MCP por integração para que o acesso possa ser alternado ou revogado de forma limpa.",
"rate_limits_title": "Limites de taxa",
"rate_limits_description": "Solicitações de leitura/gravação/publicação permitidas por minuto.",
"used_label": "Usado:"
} }
}, },
"keys": { "keys": {
@@ -2534,7 +2617,11 @@
"never_used": "Nunca", "never_used": "Nunca",
"scopes_label": "Escopos:", "scopes_label": "Escopos:",
"revoke_confirm": "Revogar esta chave de automação?", "revoke_confirm": "Revogar esta chave de automação?",
"rotate_confirm": "Girar esta chave de automação?" "rotate_confirm": "Girar esta chave de automação?",
"table_key": "Chave",
"table_status": "Estado",
"table_created": "Criado",
"table_last_used": "Usado pela última vez"
}, },
"clients": { "clients": {
"claude_code": "Código Claude", "claude_code": "Código Claude",
@@ -475,7 +475,54 @@
"name": "Имя", "name": "Имя",
"email": "электронная почта", "email": "электронная почта",
"date_joined": "Дата присоединения", "date_joined": "Дата присоединения",
"upgrade": "Обновите свой план, чтобы пригласить больше студентов" "upgrade": "Обновите свой план, чтобы пригласить больше студентов",
"import_users": "Импортировать пользователей",
"search_placeholder": "Искать…",
"status": "Статус",
"status_active": "Активный",
"status_pending": "Ожидается",
"status_expired": "Срок действия истек",
"status_revoked": "Отозван",
"select_all": "Выбрать все",
"selected_count": "{count} выбрано",
"assign_courses": "Назначение курсам",
"invite": {
"row_actions_aria": "Открытые действия",
"resend": "Отправить приглашение повторно",
"revoke": "Отозвать ожидающее приглашение",
"resend_snackbar_success": "Письмо с приглашением отправлено",
"resend_snackbar_email_failed": "Приглашение было продлено, но письмо не удалось отправить.",
"revoke_snackbar_success": "Ожидаемое приглашение отозвано"
},
"assign": {
"title": "Назначение курсам",
"description": "Предоставьте выбранным пользователям доступ к курсам и, при необходимости, уведомите их по электронной почте.",
"select_courses": "Выберите курсы",
"select_courses_placeholder": "Выбирайте курсы...",
"send_email": "Отправить уведомление по электронной почте",
"submit": "Назначить",
"cancel": "Отмена",
"snackbar_success": "Назначены пользователи {assigned} для курсов ({alreadyEnrolled} уже зачислены, письма {emailsSent} отправлены)",
"snackbar_no_courses": "Пожалуйста, выберите хотя бы один курс",
"snackbar_failed": "Не удалось назначить курсы."
},
"import": {
"title": "Импортировать пользователей",
"description": "Добавьте студентов в свою организацию, введя их адреса электронной почты. При желании назначьте их на курсы и отправьте приглашения по электронной почте.",
"emails_label": "Адреса электронной почты",
"emails_placeholder": "Введите адреса электронной почты, разделенные запятыми, новой строкой или точкой с запятой.\nнапример [email protected], [email protected]",
"course_access": "Доступ к курсу",
"no_courses": "Нет задания по курсу",
"all_courses": "Все курсы",
"select_courses": "Выберите конкретные курсы",
"select_courses_placeholder": "Выбирайте курсы...",
"send_email": "Отправить электронное письмо с приглашением импортированным пользователям",
"submit": "Импортировать пользователей",
"back": "Вернуться к аудитории",
"snackbar_success": "Импортированные пользователи: {imported} ({skiped} уже существует, писем отправлено: {emailsSent})",
"snackbar_no_emails": "Пожалуйста, введите хотя бы один адрес электронной почты",
"snackbar_failed": "Не удалось импортировать пользователей."
}
}, },
"community": { "community": {
"title": "Сообщество", "title": "Сообщество",
@@ -940,6 +987,8 @@
"complete": "Завершить", "complete": "Завершить",
"next": "Далее", "next": "Далее",
"prev": "Предыдущий", "prev": "Предыдущий",
"prev_shortcut": "Назад (←)",
"next_shortcut": "Далее (→)",
"disabled": "Функция отключена", "disabled": "Функция отключена",
"version_history": { "version_history": {
"title": "История версий", "title": "История версий",
@@ -1200,6 +1249,9 @@
}, },
"edit": { "edit": {
"accepted_answers_placeholder": "Принятые ответы (через запятую)" "accepted_answers_placeholder": "Принятые ответы (через запятую)"
},
"review": {
"your_answer_label": "Отправлено:"
} }
}, },
"numeric": { "numeric": {
@@ -1217,6 +1269,11 @@
"tolerance_info": "Допустимое отклонение от правильного значения (например, 0,5 принимает +/-0,5).", "tolerance_info": "Допустимое отклонение от правильного значения (например, 0,5 принимает +/-0,5).",
"correct_value_placeholder": "Правильное значение", "correct_value_placeholder": "Правильное значение",
"tolerance_placeholder": "Толерантность" "tolerance_placeholder": "Толерантность"
},
"review": {
"your_answer_label": "Отправлено:",
"in_range": "В пределах допуска",
"out_of_range": "Внешняя толерантность"
} }
}, },
"short_answer": { "short_answer": {
@@ -1228,6 +1285,10 @@
}, },
"edit": { "edit": {
"instructions_placeholder": "Дополнительные инструкции" "instructions_placeholder": "Дополнительные инструкции"
},
"review": {
"your_answer_label": "Отправлено:",
"accepted_label": "Принятые ответы"
} }
}, },
"true_false": { "true_false": {
@@ -1291,8 +1352,9 @@
"option_prefix": "Вариант", "option_prefix": "Вариант",
"remove": "Удалить", "remove": "Удалить",
"add_option": "Добавить вариант", "add_option": "Добавить вариант",
"correct_badge": "(Правильно)", "correct_badge": "Правильно",
"not_set": "Не установлено" "not_set": "Не установлено",
"incorrect_badge": "Неправильный"
} }
} }
}, },
@@ -1473,7 +1535,10 @@
"cancel": "Отмена" "cancel": "Отмена"
}, },
"content_locked_title": "Этот контент заблокирован", "content_locked_title": "Этот контент заблокирован",
"content_locked_description": "Ваш преподаватель еще не разблокировал этот контент. Завершите предыдущий контент или зайдите позже." "content_locked_description": "Ваш преподаватель еще не разблокировал этот контент. Завершите предыдущий контент или зайдите позже.",
"saving": "Сохранение…",
"saved": "Сохранено",
"draft_recovery": "Для этого урока найден несохраненный черновик. Хотели бы вы восстановить его?"
}, },
"news_feed": { "news_feed": {
"heading": "Лента новостей", "heading": "Лента новостей",
@@ -1582,6 +1647,7 @@
"unpublished": "Неопубликовано", "unpublished": "Неопубликовано",
"continue_course": "Продолжить", "continue_course": "Продолжить",
"learn_more": "Узнать больше", "learn_more": "Узнать больше",
"tags_overflow_aria": "Ещё {count} тегов",
"list_view": { "list_view": {
"title": "Название", "title": "Название",
"description": "Описание", "description": "Описание",
@@ -2245,7 +2311,8 @@
"email_password_disabled_message": "Для этой организации отключен вход по электронной почте и паролю.", "email_password_disabled_message": "Для этой организации отключен вход по электронной почте и паролю.",
"google_auth_disabled": "Аутентификация Google отключена.", "google_auth_disabled": "Аутентификация Google отключена.",
"signup_disabled_error": "Регистрация для этой организации отключена. Пожалуйста, свяжитесь с вашим администратором.", "signup_disabled_error": "Регистрация для этой организации отключена. Пожалуйста, свяжитесь с вашим администратором.",
"or_continue_with": "Или продолжите, используя адрес электронной почты и пароль." "or_continue_with": "Или продолжите, используя адрес электронной почты и пароль.",
"sso_enabled": "{provider} включен для этой организации"
}, },
"tabs": { "tabs": {
"general": "Общий", "general": "Общий",
@@ -2471,7 +2538,8 @@
"edit_group": "Редактировать группу", "edit_group": "Редактировать группу",
"edit_tag": "Изменить тег", "edit_tag": "Изменить тег",
"save": "Сохранить", "save": "Сохранить",
"cancel": "Отмена" "cancel": "Отмена",
"actions_menu": "Действия с тегами"
}, },
"common": { "common": {
"not_available": "Н/Д" "not_available": "Н/Д"
@@ -2480,9 +2548,13 @@
"invite": { "invite": {
"organization": { "organization": {
"page_title": "Присоединяйтесь к {orgName} в ClassroomIO.", "page_title": "Присоединяйтесь к {orgName} в ClassroomIO.",
"role_label": "Роль: {роль}", "invitation_student": "Вас пригласили в {orgName} в качестве ученика.",
"email_label": "Приглашенный адрес электронной почты: {email}", "invitation_teacher": "Вас пригласили в {orgName} в качестве преподавателя.",
"join_button": "Присоединиться к организации", "invitation_admin": "Вас пригласили в {orgName} в качестве администратора.",
"invitation_fallback": "Вас пригласили в {orgName} в качестве {role}.",
"email_line": "Электронная почта: {email}",
"accept_button": "Принять приглашение",
"go_to_dashboard": "Перейти в панель управления",
"messages": { "messages": {
"email_mismatch": "Вы вошли в систему с другим адресом электронной почты, указанным в этом приглашении.", "email_mismatch": "Вы вошли в систему с другим адресом электронной почты, указанным в этом приглашении.",
"expired": "Срок действия этой ссылки для приглашения истек.", "expired": "Срок действия этой ссылки для приглашения истек.",
@@ -2520,6 +2592,17 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Create key" "save": "Create key"
} }
},
"usage": {
"title": "Использование и ограничения",
"description": "MCP доступен в каждом плане, но использование контролируется с помощью ежемесячных кредитов на автоматизацию и ключевых лимитов.",
"credits_title": "Оставшиеся кредиты",
"credits_description": "Мутирующие действия MCP потребляют кредиты. Действия чтения бесплатны.",
"keys_title": "Активные ключи",
"keys_description": "Используйте один ключ MCP для каждой интеграции, чтобы можно было без проблем менять или отзывать доступ.",
"rate_limits_title": "Ограничения ставок",
"rate_limits_description": "Запросы на чтение/запись/публикацию разрешены в минуту.",
"used_label": "Использовано:"
} }
}, },
"keys": { "keys": {
@@ -2534,7 +2617,11 @@
"never_used": "Никогда", "never_used": "Никогда",
"scopes_label": "Области применения:", "scopes_label": "Области применения:",
"revoke_confirm": "Отозвать этот ключ автоматизации?", "revoke_confirm": "Отозвать этот ключ автоматизации?",
"rotate_confirm": "Повернуть этот ключ автоматизации?" "rotate_confirm": "Повернуть этот ключ автоматизации?",
"table_key": "Ключ",
"table_status": "Статус",
"table_created": "Создано",
"table_last_used": "Последнее использование"
}, },
"clients": { "clients": {
"claude_code": "Клод Код", "claude_code": "Клод Код",
@@ -475,7 +475,54 @@
"name": "Tên", "name": "Tên",
"email": "Email", "email": "Email",
"date_joined": "Ngày tham gia", "date_joined": "Ngày tham gia",
"upgrade": "Nâng cấp kế hoạch của bạn để mời thêm sinh viên" "upgrade": "Nâng cấp kế hoạch của bạn để mời thêm sinh viên",
"import_users": "Nhập người dùng",
"search_placeholder": "Tìm kiếm…",
"status": "Trạng thái",
"status_active": "Đang hoạt động",
"status_pending": "Đang chờ xử lý",
"status_expired": "Đã hết hạn",
"status_revoked": "Đã thu hồi",
"select_all": "Chọn tất cả",
"selected_count": "đã chọn {count}",
"assign_courses": "Chỉ định cho các khóa học",
"invite": {
"row_actions_aria": "Mở hành động",
"resend": "Gửi lại lời mời",
"revoke": "Thu hồi lời mời đang chờ xử lý",
"resend_snackbar_success": "Đã gửi email mời",
"resend_snackbar_email_failed": "Lời mời đã được gia hạn nhưng không thể gửi email",
"revoke_snackbar_success": "Đã thu hồi lời mời đang chờ xử lý"
},
"assign": {
"title": "Chỉ định cho các khóa học",
"description": "Cấp cho người dùng đã chọn quyền truy cập vào các khóa học và tùy ý thông báo cho họ qua email.",
"select_courses": "Chọn khóa học",
"select_courses_placeholder": "Chọn khóa học...",
"send_email": "Gửi email thông báo",
"submit": "Chỉ định",
"cancel": "Hủy bỏ",
"snackbar_success": "Đã chỉ định người dùng {được chỉ định} cho các khóa học ({alreadyEnrolled} đã đăng ký, đã gửi {emailsSent} email)",
"snackbar_no_courses": "Vui lòng chọn ít nhất một khóa học",
"snackbar_failed": "Không thể chỉ định khóa học"
},
"import": {
"title": "Nhập người dùng",
"description": "Thêm sinh viên vào tổ chức của bạn bằng cách nhập địa chỉ email của họ. Tùy chọn chỉ định chúng cho các khóa học và gửi email mời.",
"emails_label": "Địa chỉ email",
"emails_placeholder": "Nhập các email được phân tách bằng dấu phẩy, dòng mới hoặc dấu chấm phẩy\nví dụ: [email protected], [email protected]",
"course_access": "Truy cập khóa học",
"no_courses": "Không có bài tập khóa học",
"all_courses": "Tất cả các khóa học",
"select_courses": "Chọn các khóa học cụ thể",
"select_courses_placeholder": "Chọn khóa học...",
"send_email": "Gửi email lời mời tới người dùng đã nhập",
"submit": "Nhập người dùng",
"back": "Quay lại Đối tượng",
"snackbar_success": "Đã nhập {imported} người dùng ({skipped} đã tồn tại, đã gửi email {emailsSent})",
"snackbar_no_emails": "Vui lòng nhập ít nhất một địa chỉ email",
"snackbar_failed": "Không thể nhập người dùng"
}
}, },
"community": { "community": {
"title": "cộng đồng", "title": "cộng đồng",
@@ -940,6 +987,8 @@
"complete": "Hoàn thành", "complete": "Hoàn thành",
"next": "Tiếp theo", "next": "Tiếp theo",
"prev": "Trước đó", "prev": "Trước đó",
"prev_shortcut": "Trước (←)",
"next_shortcut": "Tiếp (→)",
"disabled": "Tính năng bị vô hiệu hóa", "disabled": "Tính năng bị vô hiệu hóa",
"version_history": { "version_history": {
"title": "Lịch sử phiên bản", "title": "Lịch sử phiên bản",
@@ -1200,6 +1249,9 @@
}, },
"edit": { "edit": {
"accepted_answers_placeholder": "Câu trả lời được chấp nhận (được phân tách bằng dấu phẩy)" "accepted_answers_placeholder": "Câu trả lời được chấp nhận (được phân tách bằng dấu phẩy)"
},
"review": {
"your_answer_label": "Đã gửi:"
} }
}, },
"numeric": { "numeric": {
@@ -1217,6 +1269,11 @@
"tolerance_info": "Chênh lệch được phép so với giá trị chính xác (ví dụ: 0,5 chấp nhận +/- 0,5).", "tolerance_info": "Chênh lệch được phép so với giá trị chính xác (ví dụ: 0,5 chấp nhận +/- 0,5).",
"correct_value_placeholder": "Giá trị đúng", "correct_value_placeholder": "Giá trị đúng",
"tolerance_placeholder": "Dung sai" "tolerance_placeholder": "Dung sai"
},
"review": {
"your_answer_label": "Đã gửi:",
"in_range": "Trong phạm vi dung sai",
"out_of_range": "Dung sai bên ngoài"
} }
}, },
"short_answer": { "short_answer": {
@@ -1228,6 +1285,10 @@
}, },
"edit": { "edit": {
"instructions_placeholder": "Hướng dẫn tùy chọn" "instructions_placeholder": "Hướng dẫn tùy chọn"
},
"review": {
"your_answer_label": "Đã gửi:",
"accepted_label": "Câu trả lời được chấp nhận"
} }
}, },
"true_false": { "true_false": {
@@ -1291,8 +1352,9 @@
"option_prefix": "Tùy chọn", "option_prefix": "Tùy chọn",
"remove": "Xóa", "remove": "Xóa",
"add_option": "Thêm tùy chọn", "add_option": "Thêm tùy chọn",
"correct_badge": "(Đúng)", "correct_badge": "Đúng",
"not_set": "Chưa đặt" "not_set": "Chưa đặt",
"incorrect_badge": "sai"
} }
} }
}, },
@@ -1473,7 +1535,10 @@
"cancel": "Hủy bỏ" "cancel": "Hủy bỏ"
}, },
"content_locked_title": "Nội dung này đã bị khóa", "content_locked_title": "Nội dung này đã bị khóa",
"content_locked_description": "Người hướng dẫn của bạn chưa mở khóa nội dung này. Hoàn thành nội dung trước đó hoặc kiểm tra lại sau." "content_locked_description": "Người hướng dẫn của bạn chưa mở khóa nội dung này. Hoàn thành nội dung trước đó hoặc kiểm tra lại sau.",
"saving": "Đang lưu…",
"saved": "Đã lưu",
"draft_recovery": "Đã tìm thấy một bản nháp chưa được lưu cho bài học này. Bạn có muốn khôi phục nó không?"
}, },
"news_feed": { "news_feed": {
"heading": "Nguồn cấp tin tức", "heading": "Nguồn cấp tin tức",
@@ -1582,6 +1647,7 @@
"unpublished": "Chưa được xuất bản", "unpublished": "Chưa được xuất bản",
"continue_course": "Tiếp tục", "continue_course": "Tiếp tục",
"learn_more": "Tìm hiểu thêm", "learn_more": "Tìm hiểu thêm",
"tags_overflow_aria": "Thêm {count} thẻ",
"list_view": { "list_view": {
"title": "Tiêu đề", "title": "Tiêu đề",
"description": "Mô tả", "description": "Mô tả",
@@ -2245,7 +2311,8 @@
"email_password_disabled_message": "Đăng nhập email/mật khẩu bị vô hiệu hóa đối với tổ chức này.", "email_password_disabled_message": "Đăng nhập email/mật khẩu bị vô hiệu hóa đối với tổ chức này.",
"google_auth_disabled": "Xác thực Google bị vô hiệu hóa.", "google_auth_disabled": "Xác thực Google bị vô hiệu hóa.",
"signup_disabled_error": "Đăng ký bị vô hiệu hóa cho tổ chức này. Vui lòng liên hệ với quản trị viên của bạn.", "signup_disabled_error": "Đăng ký bị vô hiệu hóa cho tổ chức này. Vui lòng liên hệ với quản trị viên của bạn.",
"or_continue_with": "Hoặc tiếp tục với email và mật khẩu" "or_continue_with": "Hoặc tiếp tục với email và mật khẩu",
"sso_enabled": "{provider} đã được bật cho tổ chức này"
}, },
"tabs": { "tabs": {
"general": "chung", "general": "chung",
@@ -2471,7 +2538,8 @@
"edit_group": "Chỉnh sửa nhóm", "edit_group": "Chỉnh sửa nhóm",
"edit_tag": "Chỉnh sửa thẻ", "edit_tag": "Chỉnh sửa thẻ",
"save": "Lưu", "save": "Lưu",
"cancel": "Hủy bỏ" "cancel": "Hủy bỏ",
"actions_menu": "Hành động gắn thẻ"
}, },
"common": { "common": {
"not_available": "không áp dụng" "not_available": "không áp dụng"
@@ -2480,9 +2548,13 @@
"invite": { "invite": {
"organization": { "organization": {
"page_title": "Tham gia cùng {orgName} trên ClassroomIO", "page_title": "Tham gia cùng {orgName} trên ClassroomIO",
"role_label": "Vai trò: {vai trò}", "invitation_student": "Bạn được mời vào {orgName} với vai trò học viên.",
"email_label": "Email được mời: {email}", "invitation_teacher": "Bạn được mời vào {orgName} với vai trò giáo viên.",
"join_button": "Tham gia tổ chức", "invitation_admin": "Bạn được mời vào {orgName} với vai trò quản trị viên.",
"invitation_fallback": "Bạn được mời vào {orgName} với vai trò {role}.",
"email_line": "Email: {email}",
"accept_button": "Chấp nhận lời mời",
"go_to_dashboard": "Đi tới bảng điều khiển",
"messages": { "messages": {
"email_mismatch": "Bạn đã đăng nhập bằng một email khác với lời mời này.", "email_mismatch": "Bạn đã đăng nhập bằng một email khác với lời mời này.",
"expired": "Liên kết mời này đã hết hạn.", "expired": "Liên kết mời này đã hết hạn.",
@@ -2520,6 +2592,17 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Create key" "save": "Create key"
} }
},
"usage": {
"title": "Cách sử dụng và giới hạn",
"description": "MCP có sẵn trên mọi gói, nhưng việc sử dụng được kiểm soát bằng tín dụng tự động hóa hàng tháng và các giới hạn chính.",
"credits_title": "Tín dụng còn lại",
"credits_description": "Các hành động MCP đột biến sẽ tiêu tốn tín dụng. Hành động đọc là miễn phí.",
"keys_title": "Phím hoạt động",
"keys_description": "Sử dụng một khóa MCP cho mỗi lần tích hợp để có thể luân chuyển hoặc thu hồi quyền truy cập một cách dễ dàng.",
"rate_limits_title": "Giới hạn tỷ lệ",
"rate_limits_description": "Yêu cầu đọc/ghi/xuất bản được phép mỗi phút.",
"used_label": "Đã sử dụng:"
} }
}, },
"keys": { "keys": {
@@ -2534,7 +2617,11 @@
"never_used": "không bao giờ", "never_used": "không bao giờ",
"scopes_label": "Phạm vi:", "scopes_label": "Phạm vi:",
"revoke_confirm": "Thu hồi khóa tự động hóa này?", "revoke_confirm": "Thu hồi khóa tự động hóa này?",
"rotate_confirm": "Xoay phím tự động hóa này?" "rotate_confirm": "Xoay phím tự động hóa này?",
"table_key": "Chìa khóa",
"table_status": "Trạng thái",
"table_created": "Đã tạo",
"table_last_used": "Lần sử dụng cuối cùng"
}, },
"clients": { "clients": {
"claude_code": "Mã Claude", "claude_code": "Mã Claude",
@@ -48,7 +48,7 @@
// Invite context: allow signup when invite_token present or redirect contains invite info // Invite context: allow signup when invite_token present or redirect contains invite info
const hasInviteContext = $derived( const hasInviteContext = $derived(
!!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token'))) !!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/') || redirectUrl.includes('invite_token')))
); );
const inviteOnly = $derived(!!org?.settings?.signup?.inviteOnly); const inviteOnly = $derived(!!org?.settings?.signup?.inviteOnly);
+8 -1
View File
@@ -86,7 +86,14 @@
return; return;
} }
// No need to require login for public routes // Skip /login redirect below when the route is already public.
// [NO_REDIRECT] public + path !== '/' — e.g. /login, /signup, /courses, … (not the main-app home edge case)
// [NO_REDIRECT] public + '/' + isOrgSite — org landing page on its own domain
// [MAY_REDIRECT_TO_LOGIN] public + '/' + !isOrgSite — main app home is public so we can show loading/errors while
// auth resolves; if there is still no session after that, we send to /login (this effect). Logged-in users
// return earlier (session branch above); post-login navigation from '/' is in init.svelte.ts routeUserToNextPage.
// [MAY_REDIRECT_TO_LOGIN] not public — private route; unauthenticated users go to /login
if (isPublicRoute(path) && (path !== '/' || data.isOrgSite)) { if (isPublicRoute(path) && (path !== '/' || data.isOrgSite)) {
return; return;
} }
@@ -11,6 +11,7 @@
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import { page } from '$app/state'; import { page } from '$app/state';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { ROLE } from '@cio/utils/constants';
let { data } = $props(); let { data } = $props();
@@ -25,6 +26,9 @@
) )
); );
const canJoinOrganization = $derived(inviteStatus === 'ACTIVE' && !isInviteEmailMismatch); const canJoinOrganization = $derived(inviteStatus === 'ACTIVE' && !isInviteEmailMismatch);
const isAlreadyJoined = $derived(inviteStatus === 'ACCEPTED');
const orgSlug = $derived(data.currentOrg?.siteName ?? data.invite.organization.siteName ?? '');
function buildInviteAuthParams(pathname: string, email: string): string { function buildInviteAuthParams(pathname: string, email: string): string {
const parts: string[] = []; const parts: string[] = [];
@@ -35,6 +39,9 @@
const loginParams = $derived(buildInviteAuthParams(page.url?.pathname || '', data.invite?.invite?.email || '')); const loginParams = $derived(buildInviteAuthParams(page.url?.pathname || '', data.invite?.invite?.email || ''));
const orgName = $derived(data.invite?.organization?.name ?? '');
const inviteRoleId = $derived(data.invite?.invite?.roleId ?? 0);
function getBlockedInviteMessage(): string { function getBlockedInviteMessage(): string {
if (isInviteEmailMismatch) { if (isInviteEmailMismatch) {
return t.get('invite.organization.messages.email_mismatch'); return t.get('invite.organization.messages.email_mismatch');
@@ -96,6 +103,13 @@
} }
} }
function handleNavigateAfterJoined() {
if (inviteRoleId === ROLE.STUDENT) {
return goto(resolve('/lms', {}));
}
return goto(resolve(orgSlug ? `/org/${orgSlug}` : '/org', {}));
}
onMount(async () => { onMount(async () => {
if (!data.currentOrg) return; if (!data.currentOrg) return;
@@ -110,23 +124,43 @@
<AuthUI isLogin={false} {handleSubmit} isLoading={loading} showOnlyContent={true} showLogo={true}> <AuthUI isLogin={false} {handleSubmit} isLoading={loading} showOnlyContent={true} showLogo={true}>
<div class="mt-0 w-full"> <div class="mt-0 w-full">
<h3 class="mt-0 mb-4 text-center text-lg font-medium dark:text-white">{data.invite.organization.name}</h3>
<p class="text-center text-sm font-light dark:text-white"> <p class="text-center text-sm font-light dark:text-white">
{$t('invite.organization.role_label', { role: data.invite.invite.roleLabel })} {#if inviteRoleId === ROLE.STUDENT}
{$t('invite.organization.invitation_student', { orgName })}
{:else if inviteRoleId === ROLE.TUTOR}
{$t('invite.organization.invitation_teacher', { orgName })}
{:else if inviteRoleId === ROLE.ADMIN}
{$t('invite.organization.invitation_admin', { orgName })}
{:else}
{$t('invite.organization.invitation_fallback', {
orgName,
role: data.invite.invite.roleLabel
})}
{/if}
</p> </p>
<p class="mt-1 text-center text-sm font-light dark:text-white"> <p class="mt-3 text-center text-sm font-light dark:text-white">
{$t('invite.organization.email_label', { email: data.invite.invite.email })} {$t('invite.organization.email_line', { email: data.invite.invite.email })}
</p> </p>
{#if !canJoinOrganization} {#if !canJoinOrganization && !isAlreadyJoined}
<p class="mt-3 text-center text-sm text-red-500">{getBlockedInviteMessage()}</p> <p class="mt-3 text-center text-sm text-red-500">{getBlockedInviteMessage()}</p>
{/if} {/if}
</div> </div>
<div class="my-4 flex w-full flex-col items-center justify-center gap-3"> <div class="my-4 flex w-full flex-col items-center justify-center gap-3">
<Button type="submit" disabled={!canJoinOrganization || loading} {loading}> {#if isAlreadyJoined}
{$t('invite.organization.join_button')} <Button type="button" onclick={handleNavigateAfterJoined}>
</Button> {#if inviteRoleId === ROLE.STUDENT}
{$t('navigation.goto_lms')}
{:else}
{$t('invite.organization.go_to_dashboard')}
{/if}
</Button>
{:else}
<Button type="submit" disabled={!canJoinOrganization || loading} {loading}>
{$t('invite.organization.accept_button')}
</Button>
{/if}
<p class="ui:text-muted-foreground text-center text-sm"> <p class="ui:text-muted-foreground text-center text-sm">
{$t('login.already_have_account')} {$t('login.already_have_account')}
<a class="ui:text-primary hover:underline" href={resolve(`/login?${loginParams}`, {})}> {$t('login.login')}</a> <a class="ui:text-primary hover:underline" href={resolve(`/login?${loginParams}`, {})}> {$t('login.login')}</a>
@@ -1,11 +1,15 @@
<script> <script lang="ts">
import { CommunityQuestionPage } from '$features/community/pages'; import { CommunityQuestionPage } from '$features/community/pages';
import { resolve } from '$app/paths';
import { page } from '$app/state';
let { data } = $props(); let { data } = $props();
const communityPath = $derived(page.url.pathname.replace(/\/[^/]+$/, ''));
</script> </script>
<svelte:head> <svelte:head>
<title>Question - ClassroomIO</title> <title>Question - ClassroomIO</title>
</svelte:head> </svelte:head>
<CommunityQuestionPage slug={data.slug} /> <CommunityQuestionPage slug={data.slug} backHref={resolve(communityPath, {})} />
@@ -1,7 +1,12 @@
<script> <script lang="ts">
import { AskCommunityPage } from '$features/community/pages'; import { AskCommunityPage } from '$features/community/pages';
import { t } from '$lib/utils/functions/translations'; import { BackButton } from '@cio/ui';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { resolve } from '$app/paths';
import { page } from '$app/state';
const communityPath = $derived(page.url.pathname.replace(/\/ask$/, ''));
</script> </script>
<svelte:head> <svelte:head>
@@ -11,6 +16,7 @@
<Page.Root class="w-full"> <Page.Root class="w-full">
<Page.Header> <Page.Header>
<Page.HeaderContent> <Page.HeaderContent>
<BackButton href={resolve(communityPath, {})} label={$t('community.ask.go_back')} />
<Page.Title>{$t('community.ask.ask_the')}</Page.Title> <Page.Title>{$t('community.ask.ask_the')}</Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
</Page.Header> </Page.Header>
@@ -14,7 +14,6 @@
import { CreateCourseButton } from '$features/course/components'; import { CreateCourseButton } from '$features/course/components';
import * as Avatar from '@cio/ui/base/avatar'; import * as Avatar from '@cio/ui/base/avatar';
import { shortenName } from '$lib/utils/functions/string';
import { Progress } from '@cio/ui/base/progress'; import { Progress } from '@cio/ui/base/progress';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { WelcomeModal } from '$features/onboarding/components'; import { WelcomeModal } from '$features/onboarding/components';
@@ -148,11 +147,12 @@
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Avatar.Root class="h-6 w-6"> <Avatar.Root class="h-6 w-6">
<Avatar.Image {#if enrollment.avatarUrl}
src={enrollment.avatarUrl ? enrollment.avatarUrl : '/logo-192.png'} <Avatar.Image src={enrollment.avatarUrl} alt={enrollment.name ? enrollment.name : 'User'} />
alt={enrollment.name ? enrollment.name : 'User'} {/if}
/> <Avatar.Fallback>
<Avatar.Fallback>{shortenName(enrollment.name) || 'U'}</Avatar.Fallback> <UserIcon class="ui:size-3 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div class="min-h-[45px] space-y-1"> <div class="min-h-[45px] space-y-1">
@@ -5,17 +5,26 @@ export const load = async ({ parent, cookies }) => {
if (!orgId) { if (!orgId) {
return { return {
audience: [] audience: [],
courses: []
}; };
} }
// Fetch organization audience const headers = getApiHeaders(cookies, orgId);
const audienceResponse = await classroomio.organization.audience.$get({}, getApiHeaders(cookies, orgId));
const [audienceResponse, coursesResponse] = await Promise.all([
classroomio.organization.audience.$get({}, headers),
classroomio.organization.courses.$get({ query: { tags: undefined } }, headers)
]);
const audienceData = await audienceResponse.json(); const audienceData = await audienceResponse.json();
const audience = audienceData.success ? audienceData.data : []; const audience = audienceData.success ? audienceData.data : [];
const coursesData = await coursesResponse.json();
const courses = coursesData.success ? coursesData.data : [];
return { return {
audience audience,
courses
}; };
}; };
@@ -5,6 +5,8 @@
import { currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org'; import { currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
import { PLAN } from '@cio/utils/plans'; import { PLAN } from '@cio/utils/plans';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { page } from '$app/state';
import { resolve } from '$app/paths';
let { data } = $props(); let { data } = $props();
let isLoading = $state(false); let isLoading = $state(false);
@@ -35,14 +37,17 @@
</Page.Title> </Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action> <Page.Action>
<Button onclick={exportAudience} disabled={isLoading} loading={isLoading}> <Button variant="outline" onclick={exportAudience} disabled={isLoading} loading={isLoading}>
{$t('audience.export')} {$t('audience.export')}
</Button> </Button>
<Button variant="secondary" href={resolve(`${page.url.pathname}/import`, {})}>
{$t('audience.import_users')}
</Button>
</Page.Action> </Page.Action>
</Page.Header> </Page.Header>
<Page.Body> <Page.Body>
{#snippet child()} {#snippet child()}
<AudiencePage audience={data.audience} /> <AudiencePage audience={data.audience} courses={data.courses} />
{/snippet} {/snippet}
</Page.Body> </Page.Body>
</Page.Root> </Page.Root>
@@ -0,0 +1,19 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ parent, cookies }) => {
const { orgId } = await parent();
if (!orgId) {
return {
courses: []
};
}
const coursesResponse = await classroomio.organization.courses.$get({}, getApiHeaders(cookies, orgId));
const coursesData = await coursesResponse.json();
const courses = coursesData.success ? coursesData.data : [];
return {
courses
};
};
@@ -0,0 +1,30 @@
<script lang="ts">
import { ImportAudiencePage } from '$features/audience/pages';
import { BackButton } from '@cio/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { resolve } from '$app/paths';
import { page } from '$app/state';
let { data } = $props();
const audiencePath = $derived(page.url.pathname.replace(/\/import$/, ''));
</script>
<svelte:head>
<title>{$t('audience.import.title')} - ClassroomIO</title>
</svelte:head>
<Page.Root class="mx-auto w-full max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<BackButton href={resolve(audiencePath, {})} label={$t('audience.import.back')} class="p-0!" />
<Page.Title>{$t('audience.import.title')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<ImportAudiencePage courses={data.courses} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,11 +1,15 @@
<script> <script lang="ts">
import { CommunityQuestionPage } from '$features/community/pages'; import { CommunityQuestionPage } from '$features/community/pages';
import { resolve } from '$app/paths';
import { page } from '$app/state';
let { data } = $props(); let { data } = $props();
const communityPath = $derived(page.url.pathname.replace(/\/[^/]+$/, ''));
</script> </script>
<svelte:head> <svelte:head>
<title>Question - ClassroomIO</title> <title>Question - ClassroomIO</title>
</svelte:head> </svelte:head>
<CommunityQuestionPage slug={data.slug} question={data.question} /> <CommunityQuestionPage slug={data.slug} question={data.question} backHref={resolve(communityPath, {})} />
@@ -1,7 +1,12 @@
<script> <script lang="ts">
import { AskCommunityPage } from '$features/community/pages'; import { AskCommunityPage } from '$features/community/pages';
import { t } from '$lib/utils/functions/translations'; import { BackButton } from '@cio/ui';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { resolve } from '$app/paths';
import { page } from '$app/state';
const communityPath = $derived(page.url.pathname.replace(/\/ask$/, ''));
</script> </script>
<svelte:head> <svelte:head>
@@ -11,6 +16,7 @@
<Page.Root class="w-full"> <Page.Root class="w-full">
<Page.Header> <Page.Header>
<Page.HeaderContent> <Page.HeaderContent>
<BackButton href={resolve(communityPath, {})} label={$t('community.ask.go_back')} />
<Page.Title>{$t('community.ask.ask_the')}</Page.Title> <Page.Title>{$t('community.ask.ask_the')}</Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
</Page.Header> </Page.Header>
@@ -2,24 +2,33 @@
import { CoursesPage } from '$features/course/pages'; import { CoursesPage } from '$features/course/pages';
import { CreateCourseButton, CourseFilterPopover } from '$features/course/components'; import { CreateCourseButton, CourseFilterPopover } from '$features/course/components';
import { courseMetaDeta } from '$features/course/utils/store'; import { courseMetaDeta } from '$features/course/utils/store';
import { COURSE_SORT_OPTIONS } from '$features/course/utils/constants'; import {
CourseSortBy,
CourseSortOrder,
DEFAULT_COURSE_SORT,
DEFAULT_SORT_ORDER,
parseCourseSortOrder,
parseCourseSortValue
} from '$features/course/utils/constants';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { SvelteSet } from 'svelte/reactivity';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { coursesApi } from '$features/course/api'; import { coursesApi } from '$features/course/api';
let { data } = $props(); let { data } = $props();
const getInitialSelectedTags = () => data.activeTags ?? [];
let searchValue = $state(''); let searchValue = $state('');
let selectedId: string = $state('0'); let sortKey: CourseSortBy = $state(DEFAULT_COURSE_SORT);
let selectedOrder = $state<'asc' | 'desc'>('desc'); let selectedOrder = $state<CourseSortOrder>(DEFAULT_SORT_ORDER);
let selectedTags = $state<string[]>([]);
let hasInitializedFilters = $state(false);
let isFiltering = $state(false);
const validSortValues = new Set(COURSE_SORT_OPTIONS.map((option) => option.value)); let selectedTags = $state<string[]>(getInitialSelectedTags());
const validSortOrders = new Set(['asc', 'desc']);
let hasInitializedFilters = $state(false);
let hasCompletedInitialUrlSync = false;
let isFiltering = $state(false);
$effect(() => { $effect(() => {
if (data.courses) { if (data.courses) {
@@ -27,39 +36,6 @@
} }
}); });
$effect(() => {
selectedTags = data.activeTags ?? [];
});
function normalizeSortValue(sortValue: string | null): string {
if (sortValue && validSortValues.has(sortValue)) {
return sortValue;
}
return '0';
}
function normalizeSortOrder(sortOrder: string | null): 'asc' | 'desc' {
if (sortOrder && validSortOrders.has(sortOrder)) {
return sortOrder as 'asc' | 'desc';
}
return 'desc';
}
function getLegacySortState(sortValue: string | null) {
switch (sortValue) {
case '3':
return { selectedId: '0', selectedOrder: 'asc' as const };
case '4':
return { selectedId: '1', selectedOrder: 'asc' as const };
case '5':
return { selectedId: '2', selectedOrder: 'asc' as const };
default:
return null;
}
}
function updateFiltersUrl() { function updateFiltersUrl() {
if (!browser || !hasInitializedFilters) { if (!browser || !hasInitializedFilters) {
return; return;
@@ -73,13 +49,13 @@
nextUrl.searchParams.delete('tags'); nextUrl.searchParams.delete('tags');
} }
if (selectedId !== '0') { if (sortKey !== DEFAULT_COURSE_SORT) {
nextUrl.searchParams.set('sort', selectedId); nextUrl.searchParams.set('sort', sortKey);
} else { } else {
nextUrl.searchParams.delete('sort'); nextUrl.searchParams.delete('sort');
} }
if (selectedOrder !== 'desc') { if (selectedOrder !== DEFAULT_SORT_ORDER) {
nextUrl.searchParams.set('order', selectedOrder); nextUrl.searchParams.set('order', selectedOrder);
} else { } else {
nextUrl.searchParams.delete('order'); nextUrl.searchParams.delete('order');
@@ -94,8 +70,14 @@
return; return;
} }
localStorage.setItem('classroomio_filter_course_sort_key', selectedId); localStorage.setItem('classroomio_filter_course_sort_key', sortKey);
localStorage.setItem('classroomio_filter_course_order_key', selectedOrder); localStorage.setItem('classroomio_filter_course_order_key', selectedOrder);
if (!hasCompletedInitialUrlSync) {
hasCompletedInitialUrlSync = true;
return;
}
updateFiltersUrl(); updateFiltersUrl();
}); });
@@ -111,7 +93,7 @@
} }
function toggleTag(tagSlug: string, checked: boolean) { function toggleTag(tagSlug: string, checked: boolean) {
const next = new Set(selectedTags); const next = new SvelteSet(selectedTags);
if (checked) { if (checked) {
next.add(tagSlug); next.add(tagSlug);
@@ -123,8 +105,8 @@
} }
async function clearFilters() { async function clearFilters() {
selectedId = '0'; sortKey = DEFAULT_COURSE_SORT;
selectedOrder = 'desc'; selectedOrder = DEFAULT_SORT_ORDER;
if (selectedTags.length === 0) { if (selectedTags.length === 0) {
return; return;
@@ -144,21 +126,21 @@
const sortedCourses = [...filteredCourses]; const sortedCourses = [...filteredCourses];
if (selectedId === '0') { if (sortKey === CourseSortBy.DateCreated) {
return sortedCourses.sort((a, b) => return sortedCourses.sort((a, b) =>
selectedOrder === 'asc' selectedOrder === CourseSortOrder.Asc
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ? new Date(a.createdAt ?? '').getTime() - new Date(b.createdAt ?? '').getTime()
: new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() : new Date(b.createdAt ?? '').getTime() - new Date(a.createdAt ?? '').getTime()
); );
} else if (selectedId === '1') { } else if (sortKey === CourseSortBy.Published) {
return sortedCourses.sort((a, b) => return sortedCourses.sort((a, b) =>
selectedOrder === 'asc' selectedOrder === CourseSortOrder.Asc
? Number(a.isPublished) - Number(b.isPublished) ? Number(a.isPublished) - Number(b.isPublished)
: Number(b.isPublished) - Number(a.isPublished) : Number(b.isPublished) - Number(a.isPublished)
); );
} else if (selectedId === '2') { } else if (sortKey === CourseSortBy.Lessons) {
return sortedCourses.sort((a, b) => return sortedCourses.sort((a, b) =>
selectedOrder === 'asc' ? a.lessonCount - b.lessonCount : b.lessonCount - a.lessonCount selectedOrder === CourseSortOrder.Asc ? a.lessonCount - b.lessonCount : b.lessonCount - a.lessonCount
); );
} }
@@ -172,34 +154,22 @@
$courseMetaDeta.view = courseView; $courseMetaDeta.view = courseView;
} }
const searchParams = new URLSearchParams(window.location.search); const sortFromStorage = parseCourseSortValue(localStorage.getItem('classroomio_filter_course_sort_key'));
const legacySortFromUrl = getLegacySortState(searchParams.get('sort')); const orderFromStorage = parseCourseSortOrder(localStorage.getItem('classroomio_filter_course_order_key'));
const legacySortFromStorage = getLegacySortState(localStorage.getItem('classroomio_filter_course_key'));
const oldSortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_key'));
const sortFromUrl = normalizeSortValue(searchParams.get('sort'));
const sortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_sort_key'));
const orderFromUrl = normalizeSortOrder(searchParams.get('order'));
const orderFromStorage = normalizeSortOrder(localStorage.getItem('classroomio_filter_course_order_key'));
if (legacySortFromUrl) { const searchParams = new URLSearchParams(window.location.search);
selectedId = legacySortFromUrl.selectedId; const sortFromUrl = parseCourseSortValue(searchParams.get('sort'));
selectedOrder = legacySortFromUrl.selectedOrder; const orderFromUrl = parseCourseSortOrder(searchParams.get('order'));
} else if (sortFromUrl !== '0' || orderFromUrl !== 'desc') {
selectedId = sortFromUrl; if (sortFromUrl !== DEFAULT_COURSE_SORT || orderFromUrl !== DEFAULT_SORT_ORDER) {
sortKey = sortFromUrl;
selectedOrder = orderFromUrl; selectedOrder = orderFromUrl;
} else if (legacySortFromStorage) { } else if (sortFromStorage !== DEFAULT_COURSE_SORT || orderFromStorage !== DEFAULT_SORT_ORDER) {
selectedId = legacySortFromStorage.selectedId; sortKey = sortFromStorage;
selectedOrder = legacySortFromStorage.selectedOrder;
} else if (sortFromStorage !== '0' || orderFromStorage !== 'desc') {
selectedId = sortFromStorage;
selectedOrder = orderFromStorage; selectedOrder = orderFromStorage;
} else {
selectedId = oldSortFromStorage;
selectedOrder = 'desc';
} }
hasInitializedFilters = true; hasInitializedFilters = true;
updateFiltersUrl();
}); });
</script> </script>
@@ -218,10 +188,10 @@
</Page.Header> </Page.Header>
<Page.Body> <Page.Body>
{#snippet child()} {#snippet child()}
<CoursesPage courses={filteredCourses} bind:searchValue bind:selectedId showSortSelect={false}> <CoursesPage courses={filteredCourses} bind:searchValue bind:sortKey showSortSelect={false}>
{#snippet filterControls()} {#snippet filterControls()}
<CourseFilterPopover <CourseFilterPopover
bind:selectedId bind:sortKey
bind:selectedOrder bind:selectedOrder
{selectedTags} {selectedTags}
tagGroups={data.tagGroups} tagGroups={data.tagGroups}
+2 -1
View File
@@ -66,12 +66,13 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
user: { user: {
create: { create: {
after: async (user) => { after: async (user) => {
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id });
await createProfileHook(user); await createProfileHook(user);
} }
}, },
update: { update: {
after: async (user) => { after: async (user) => {
console.log('update user', user); console.log('[auth] databaseHooks.user.update.after: running', { userId: user.id });
await syncUserWithProfile(user); await syncUserWithProfile(user);
} }
} }
+1 -1
View File
@@ -37,7 +37,7 @@ async function isSelfHostedFirstSignup(): Promise<boolean> {
* Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org). * Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org).
*/ */
export const createProfileHook = async (user: User) => { export const createProfileHook = async (user: User) => {
console.debug('createProfileHook', user); console.log('[auth] createProfileHook: running', { userId: user.id });
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1); const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
console.debug('existingProfile', existingProfile); console.debug('existingProfile', existingProfile);
+1
View File
@@ -16,6 +16,7 @@ const afterHooks: Array<(context: AuthMiddlewareContext) => Promise<unknown>> =
* This function will be called by better-auth after each request * This function will be called by better-auth after each request
*/ */
export const combineAfterHooks = async (context: AuthMiddlewareContext) => { export const combineAfterHooks = async (context: AuthMiddlewareContext) => {
console.log('[auth] combineAfterHooks: running');
for (const hook of afterHooks) { for (const hook of afterHooks) {
try { try {
await hook(context); await hook(context);
+75 -11
View File
@@ -1,9 +1,11 @@
import * as schema from '@db/schema'; import * as schema from '@db/schema';
import { and, eq } from 'drizzle-orm'; import { and, eq, isNull } from 'drizzle-orm';
import { User } from 'better-auth'; import { User } from 'better-auth';
import { db } from '@db/drizzle'; import { db } from '@db/drizzle';
import { enrollUsersInCourseGroups } from '@db/queries/group';
import { getOrgCourseGroups } from '@db/queries/course';
import { markUserAndProfileEmailVerified } from '@db/queries/auth'; import { markUserAndProfileEmailVerified } from '@db/queries/auth';
const DEFAULT_STUDENT_ROLE_ID = 3; const DEFAULT_STUDENT_ROLE_ID = 3;
@@ -21,13 +23,25 @@ export async function ensureOrgMembership(
): Promise<void> { ): Promise<void> {
const emailLower = email.toLowerCase(); const emailLower = email.toLowerCase();
const [existing] = await db const [existingByProfile] = await db
.select() .select()
.from(schema.organizationmember) .from(schema.organizationmember)
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.profileId, userId))) .where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.profileId, userId)))
.limit(1); .limit(1);
if (existing) { if (existingByProfile) {
return;
}
/** Audience import pre-creates a row with org + email before profile exists; do not insert again. */
const [existingByEmail] = await db
.select()
.from(schema.organizationmember)
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.email, emailLower)))
.limit(1);
if (existingByEmail?.profileId && existingByEmail.profileId !== userId) {
console.error('ensureOrgMembership: org member email already linked to another profile');
return; return;
} }
@@ -38,20 +52,33 @@ export async function ensureOrgMembership(
and( and(
eq(schema.organizationInvite.organizationId, orgId), eq(schema.organizationInvite.organizationId, orgId),
eq(schema.organizationInvite.email, emailLower), eq(schema.organizationInvite.email, emailLower),
eq(schema.organizationInvite.isRevoked, false) eq(schema.organizationInvite.isRevoked, false),
isNull(schema.organizationInvite.acceptedAt)
) )
) )
.limit(1); .limit(1);
if (invite) { if (invite) {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.insert(schema.organizationmember).values({ if (existingByEmail) {
organizationId: orgId, await tx
profileId: userId, .update(schema.organizationmember)
email: emailLower, .set({
roleId: invite.roleId, profileId: userId,
verified: true email: emailLower,
}); roleId: invite.roleId,
verified: true
})
.where(eq(schema.organizationmember.id, existingByEmail.id));
} else {
await tx.insert(schema.organizationmember).values({
organizationId: orgId,
profileId: userId,
email: emailLower,
roleId: invite.roleId,
verified: true
});
}
await tx await tx
.update(schema.organizationInvite) .update(schema.organizationInvite)
@@ -65,10 +92,46 @@ export async function ensureOrgMembership(
await markUserAndProfileEmailVerified(userId, tx); await markUserAndProfileEmailVerified(userId, tx);
}); });
const metadata = invite.metadata as Record<string, unknown> | null;
const courseIds = Array.isArray(metadata?.courseIds) ? (metadata.courseIds as string[]) : [];
if (courseIds.length > 0) {
try {
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
await enrollUsersInCourseGroups(validGroupIds, [{ profileId: userId, email: emailLower }], invite.roleId);
} catch (error) {
console.error('ensureOrgMembership course enrollment error:', error);
}
}
console.debug('User joined org via invite:', orgId); console.debug('User joined org via invite:', orgId);
return; return;
} }
if (existingByEmail) {
const [policy] = await db
.select()
.from(schema.organizationAuthPolicy)
.where(eq(schema.organizationAuthPolicy.organizationId, orgId))
.limit(1);
const defaultRoleId = policy?.defaultRoleId ?? DEFAULT_STUDENT_ROLE_ID;
const finalRoleId = roleId ?? defaultRoleId;
await db
.update(schema.organizationmember)
.set({
profileId: userId,
verified: true,
roleId: finalRoleId
})
.where(eq(schema.organizationmember.id, existingByEmail.id));
console.debug('User linked to existing org membership row:', orgId);
return;
}
const [policy] = await db const [policy] = await db
.select() .select()
.from(schema.organizationAuthPolicy) .from(schema.organizationAuthPolicy)
@@ -94,6 +157,7 @@ export async function ensureOrgMembership(
* This runs after profile creation and creates organization membership * This runs after profile creation and creates organization membership
*/ */
export const ssoProvisioningHook = async (user: User) => { export const ssoProvisioningHook = async (user: User) => {
console.log('[auth] ssoProvisioningHook: running', { userId: user.id });
if (!user.email) { if (!user.email) {
console.debug('No email for user, skipping SSO provisioning'); console.debug('No email for user, skipping SSO provisioning');
return; return;
+1 -1
View File
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
import { hasVerifiedEmailProvider } from './create-profile'; import { hasVerifiedEmailProvider } from './create-profile';
export const syncUserWithProfile = async (user: User) => { export const syncUserWithProfile = async (user: User) => {
console.debug('syncUserWithProfile', user); console.log('[auth] syncUserWithProfile: running', { userId: user?.id });
if (!user?.id) { if (!user?.id) {
return; return;
@@ -0,0 +1,184 @@
CREATE TYPE "public"."AUTOMATION_USAGE_CATEGORY" AS ENUM('read', 'write', 'publish');--> statement-breakpoint
CREATE TYPE "public"."COURSE_IMPORT_DRAFT_STATUS" AS ENUM('DRAFT', 'PUBLISHED', 'ARCHIVED');--> statement-breakpoint
CREATE TYPE "public"."COURSE_IMPORT_SOURCE_TYPE" AS ENUM('prompt', 'pdf', 'course');--> statement-breakpoint
CREATE TYPE "public"."ORGANIZATION_API_KEY_TYPE" AS ENUM('mcp', 'api', 'zapier');--> statement-breakpoint
CREATE TYPE "public"."SSO_PROVIDER" AS ENUM('OKTA', 'GOOGLE_WORKSPACE', 'AUTH0');--> statement-breakpoint
CREATE TABLE "course_import_draft" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"source_type" "COURSE_IMPORT_SOURCE_TYPE" NOT NULL,
"status" "COURSE_IMPORT_DRAFT_STATUS" DEFAULT 'DRAFT' NOT NULL,
"title" varchar NOT NULL,
"locale" "LOCALE" DEFAULT 'en' NOT NULL,
"idempotency_key" varchar,
"summary" jsonb DEFAULT '{}'::jsonb NOT NULL,
"draft" jsonb NOT NULL,
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
"sourceArtifacts" jsonb DEFAULT '[]'::jsonb NOT NULL,
"published_course_id" uuid,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_api_key" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"type" "ORGANIZATION_API_KEY_TYPE" NOT NULL,
"label" varchar(120) NOT NULL,
"secret_prefix" varchar(32) NOT NULL,
"secret_hash" text NOT NULL,
"scopes" jsonb DEFAULT '[]'::jsonb NOT NULL,
"last_used_at" timestamp with time zone,
"expires_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_auth_policy" (
"organization_id" uuid PRIMARY KEY NOT NULL,
"force_sso" boolean DEFAULT false NOT NULL,
"auto_join_sso_domains" boolean DEFAULT false NOT NULL,
"break_glass_enabled" boolean DEFAULT true NOT NULL,
"default_role_id" bigint DEFAULT '3' NOT NULL,
"role_mapping" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_automation_usage" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"organization_api_key_id" uuid,
"type" "ORGANIZATION_API_KEY_TYPE" NOT NULL,
"action" varchar(120) NOT NULL,
"category" "AUTOMATION_USAGE_CATEGORY" NOT NULL,
"credits_consumed" integer DEFAULT 0 NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_sso_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"better_auth_provider_id" text NOT NULL,
"provider" "SSO_PROVIDER" NOT NULL,
"display_name" text NOT NULL,
"domain" text NOT NULL,
"is_active" boolean DEFAULT false NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"updated_by_profile_id" uuid,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "organization_sso_config_organization_id_unique" UNIQUE("organization_id")
);
--> statement-breakpoint
CREATE TABLE "organization_token_auth" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"signing_secret" text NOT NULL,
"is_active" boolean DEFAULT false NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "organization_token_auth_organization_id_unique" UNIQUE("organization_id")
);
--> statement-breakpoint
CREATE TABLE "sso_provider" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"issuer" text NOT NULL,
"oidc_config" text,
"saml_config" text,
"user_id" uuid NOT NULL,
"provider_id" text NOT NULL,
"organization_id" text,
"domain" text NOT NULL,
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
);
--> statement-breakpoint
CREATE TABLE "tag" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"group_id" uuid NOT NULL,
"name" varchar NOT NULL,
"slug" text NOT NULL,
"description" text,
"color" varchar NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "tag_org_slug_key" UNIQUE("organization_id","slug")
);
--> statement-breakpoint
CREATE TABLE "tag_assignment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tag_id" uuid NOT NULL,
"course_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "tag_assignment_tag_course_key" UNIQUE("tag_id","course_id")
);
--> statement-breakpoint
CREATE TABLE "tag_group" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"name" varchar NOT NULL,
"slug" text NOT NULL,
"description" text,
"order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "tag_group_org_slug_key" UNIQUE("organization_id","slug")
);
--> statement-breakpoint
ALTER TABLE "organization" ALTER COLUMN "theme" SET DEFAULT 'blue';--> statement-breakpoint
ALTER TABLE "option" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_signup" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_signup_message" text;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_email_password" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_google_auth" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "question" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
ALTER TABLE "question_answer" ADD COLUMN "answer_data" jsonb;--> statement-breakpoint
ALTER TABLE "submission" ADD COLUMN "grading_state" varchar DEFAULT 'queued' NOT NULL;--> statement-breakpoint
ALTER TABLE "submission" ADD COLUMN "overall_status" varchar DEFAULT 'manual_required' NOT NULL;--> statement-breakpoint
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_published_course_id_course_id_fk" FOREIGN KEY ("published_course_id") REFERENCES "public"."course"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_default_role_id_fkey" FOREIGN KEY ("default_role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_api_key_id_organization_api_key_id_fk" FOREIGN KEY ("organization_api_key_id") REFERENCES "public"."organization_api_key"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_updated_by_profile_id_profile_id_fk" FOREIGN KEY ("updated_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag" ADD CONSTRAINT "tag_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag" ADD CONSTRAINT "tag_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."tag_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag_group" ADD CONSTRAINT "tag_group_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_course_import_draft_org_id" ON "course_import_draft" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_course_import_draft_status" ON "course_import_draft" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX "course_import_draft_org_idempotency_key" ON "course_import_draft" USING btree ("organization_id","idempotency_key") WHERE "course_import_draft"."idempotency_key" IS NOT NULL;--> statement-breakpoint
CREATE INDEX "idx_organization_api_key_org_id" ON "organization_api_key" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_api_key_type" ON "organization_api_key" USING btree ("type");--> statement-breakpoint
CREATE UNIQUE INDEX "organization_api_key_secret_hash_unique" ON "organization_api_key" USING btree ("secret_hash");--> statement-breakpoint
CREATE INDEX "idx_organization_auth_policy_org_id" ON "organization_auth_policy" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_org_automation_usage_org_created_at" ON "organization_automation_usage" USING btree ("organization_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_org_automation_usage_key_created_at" ON "organization_automation_usage" USING btree ("organization_api_key_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_org_automation_usage_type_created_at" ON "organization_automation_usage" USING btree ("type","created_at");--> statement-breakpoint
CREATE INDEX "idx_organization_sso_config_org_id" ON "organization_sso_config" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_sso_config_domain" ON "organization_sso_config" USING btree ("domain");--> statement-breakpoint
CREATE INDEX "idx_organization_sso_config_provider_id" ON "organization_sso_config" USING btree ("better_auth_provider_id");--> statement-breakpoint
CREATE INDEX "idx_organization_token_auth_org_id" ON "organization_token_auth" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_tag_organization_id" ON "tag" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_tag_group_id" ON "tag" USING btree ("group_id");--> statement-breakpoint
CREATE INDEX "idx_tag_assignment_tag_id" ON "tag_assignment" USING btree ("tag_id");--> statement-breakpoint
CREATE INDEX "idx_tag_assignment_course_id" ON "tag_assignment" USING btree ("course_id");--> statement-breakpoint
CREATE INDEX "idx_tag_group_organization_id" ON "tag_group" USING btree ("organization_id");--> statement-breakpoint
CREATE UNIQUE INDEX "organizationmember_org_profile_unique" ON "organizationmember" USING btree ("organization_id","profile_id") WHERE "organizationmember"."profile_id" IS NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "organizationmember_org_email_unique" ON "organizationmember" USING btree ("organization_id","email") WHERE "organizationmember"."email" IS NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -50,6 +50,13 @@
"when": 1771176910627, "when": 1771176910627,
"tag": "0006_modern_the_initiative", "tag": "0006_modern_the_initiative",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1774154467188,
"tag": "0007_rainy_vivisector",
"breakpoints": true
} }
] ]
} }
+7 -1
View File
@@ -2,7 +2,7 @@ import * as schema from '@db/schema';
import type { TProfile } from '@db/types'; import type { TProfile } from '@db/types';
import { db, type DbOrTxClient } from '@db/drizzle'; import { db, type DbOrTxClient } from '@db/drizzle';
import { eq } from 'drizzle-orm'; import { eq, inArray } from 'drizzle-orm';
export const getProfileById = async (id: string) => { export const getProfileById = async (id: string) => {
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1); const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1);
@@ -16,6 +16,12 @@ export const getProfileByEmail = async (email: string) => {
return profile; return profile;
}; };
export async function getProfilesByEmails(emails: string[]) {
if (emails.length === 0) return [];
return db.select().from(schema.profile).where(inArray(schema.profile.email, emails));
}
export const updateProfile = async (id: string, data: Partial<Omit<TProfile, 'id' | 'createdAt' | 'updatedAt'>>) => { export const updateProfile = async (id: string, data: Partial<Omit<TProfile, 'id' | 'createdAt' | 'updatedAt'>>) => {
const [updatedProfile] = await db.update(schema.profile).set(data).where(eq(schema.profile.id, id)).returning(); const [updatedProfile] = await db.update(schema.profile).set(data).where(eq(schema.profile.id, id)).returning();

Some files were not shown because too many files have changed in this diff Show More